diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 840ac69064bb1..9b9725a09f567 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -246,14 +246,26 @@ jobs: - name: Run Docker-Compose working-directory: AppFlowy-Cloud env: - BACKEND_VERSION: 0.3.24-amd64 + APPFLOWY_CLOUD_VERSION: 0.6.4-amd64 run: | - if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then + 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 - echo "Docker container 'appflowy-cloud' is already running." + 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi fi - name: Checkout source code diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index fdabc29eb3c39..e1be95894d7fa 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -21,7 +21,7 @@ on: env: FLUTTER_VERSION: "3.22.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -45,12 +45,12 @@ jobs: - uses: futureware-tech/simulator-action@v3 id: simulator-action with: - model: 'iPhone 15' + model: "iPhone 15" shutdown_after_job: false build-macos: if: github.event.pull_request.head.repo.full_name != github.repository - runs-on: macos-latest + runs-on: macos-13 steps: - name: Checkout source code @@ -79,7 +79,7 @@ jobs: - uses: davidB/rust-cargo-make@v1 with: - version: "0.36.6" + version: "0.37.15" - name: Install prerequisites working-directory: frontend @@ -99,7 +99,7 @@ jobs: - uses: futureware-tech/simulator-action@v3 id: simulator-action with: - model: 'iPhone 15' + model: "iPhone 15" shutdown_after_job: false # - name: Run AppFlowy on simulator diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml index 42a8794edab31..566aef3b7b281 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -43,6 +43,31 @@ jobs: sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .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: 0.6.4-amd64 + 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi + - name: Run rust-lib tests working-directory: frontend/rust-lib env: @@ -104,15 +129,29 @@ jobs: sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env - - name: Run Docker-Compose + - name: Ensure AppFlowy-Cloud is Running with Correct Version working-directory: AppFlowy-Cloud + env: + APPFLOWY_CLOUD_VERSION: 0.6.4-amd64 run: | - if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then + 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 - echo "Docker container 'appflowy-cloud' is already running." + 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi fi - name: Run rust-lib tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d736863b707..f86f554620dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Bug Fixes - Removed Wayland header from AppImage build - Fixed the issue where pasting web image on mobile failed. +- Fixed the issue where the name of newly created card in board view didn't show up. ## Version 0.6.7 - 13/08/2024 ### New Features diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 63e451b09d509..bfcd639cb297e 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ 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.6.8" +APPFLOWY_VERSION = "0.6.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/android/app/build.gradle b/frontend/appflowy_flutter/android/app/build.gradle index fe788cbb529e7..35dcadda87420 100644 --- a/frontend/appflowy_flutter/android/app/build.gradle +++ b/frontend/appflowy_flutter/android/app/build.gradle @@ -87,6 +87,13 @@ android { path "src/main/CMakeLists.txt" } } + + // only support arm64-v8a + defaultConfig { + ndk { + abiFilters "arm64-v8a" + } + } } flutter { diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart index 39ef5386e4b87..00a02788dae3e 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'dart:ui'; -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -12,6 +10,7 @@ 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/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; @@ -19,6 +18,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; @@ -51,12 +51,12 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); // sign up with Google await tester.tapGoogleLoginInButton(); @@ -68,7 +68,7 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); @@ -85,7 +85,7 @@ void main() { await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); final userNameInput = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(userNameInput.name, 'Me'); }); }); diff --git a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart index 395e4ed24ac77..dacc900103d87 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart @@ -6,6 +6,7 @@ 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/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -41,11 +42,11 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeText(LocaleKeys.button_ok.tr()); await tester.tapButtonWithName(LocaleKeys.button_ok.tr()); @@ -67,11 +68,11 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeGoogleLoginButton(); }); diff --git a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart index d0377908c321b..253d533607404 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart @@ -2,8 +2,6 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -11,6 +9,7 @@ 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/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; @@ -18,6 +17,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; @@ -67,7 +67,7 @@ void main() { // Verify name final profileSetting = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(profileSetting.name, name); }); diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart index 3fe48b5f6f70a..387c35adf2af6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart @@ -22,7 +22,7 @@ void main() { const fieldName = "test change field"; await tester.createField( FieldType.RichText, - fieldName, + name: fieldName, layout: ViewLayoutPB.Board, ); await tester.tapButton(card1); diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart index 66ef6cfd95b18..28f50bf817f16 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart @@ -1,3 +1,4 @@ +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'; @@ -41,7 +42,7 @@ void main() { name: 'my grid', layout: ViewLayoutPB.Grid, ); - await tester.createField(FieldType.RichText, 'description'); + await tester.createField(FieldType.RichText, name: 'description'); await tester.editCell( rowIndex: 0, @@ -81,7 +82,7 @@ void main() { const fieldType = FieldType.Number; // Create a number field - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.editCell( rowIndex: 0, @@ -157,7 +158,7 @@ void main() { const fieldType = FieldType.CreatedTime; // Create a create time field // The create time field is not editable - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -175,7 +176,7 @@ void main() { const fieldType = FieldType.LastEditedTime; // Create a last time field // The last time field is not editable - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -191,7 +192,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.DateTime; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); // Tap the cell to invoke the field editor await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -366,7 +367,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.MultiSelect; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType, name: fieldType.i18n); // Tap the cell to invoke the selection option editor await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -449,7 +450,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.Checklist; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); // assert that there is no progress bar in the grid tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart index cc1187da21b31..45d05207ff067 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart @@ -1,12 +1,13 @@ +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/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: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 'package:intl/intl.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; @@ -56,11 +57,22 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.createField(FieldType.Checklist, 'checklist'); + await tester.createField(FieldType.Checklist); + tester.findFieldWithName(FieldType.Checklist.i18n); - // check the field is created successfully - tester.findFieldWithName('checklist'); - await tester.pumpAndSettle(); + // 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 { @@ -70,14 +82,14 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.createField(FieldType.Checkbox, 'New field 1'); + 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.tapDialogOkButton(); + await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); tester.noFieldWithName('New field 1'); await tester.pumpAndSettle(); @@ -90,10 +102,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField('New field 1'); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.RichText, name: 'New field 1'); // duplicate the field await tester.tapGridFieldWithName('New field 1'); @@ -126,26 +135,6 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('create checklist field', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - - // Open the type option menu - await tester.tapSwitchFieldTypeButton(); - - await tester.selectFieldType(FieldType.Checklist); - - // After update the field type, the cells should be updated - await tester.findCellByFieldType(FieldType.Checklist); - - await tester.pumpAndSettle(); - }); - testWidgets('create list of fields', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -162,18 +151,10 @@ void main() { FieldType.CreatedTime, FieldType.Checkbox, ]) { - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField(fieldType.name); - - // Open the type option menu - await tester.tapSwitchFieldTypeButton(); - - await tester.selectFieldType(fieldType); - await tester.dismissFieldEditor(); + await tester.createField(fieldType); // After update the field type, the cells should be updated - await tester.findCellByFieldType(fieldType); + tester.findCellByFieldType(fieldType); await tester.pumpAndSettle(); } }); @@ -190,15 +171,7 @@ void main() { FieldType.Checklist, FieldType.URL, ]) { - // create the field - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField(fieldType.i18n); - - // change field type - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(fieldType); - await tester.dismissFieldEditor(); + await tester.createField(fieldType); // open the field editor await tester.tapGridFieldWithName(fieldType.i18n); @@ -218,11 +191,7 @@ void main() { await tester.scrollToRight(find.byType(GridPage)); // create a number field - await tester.tapNewPropertyButton(); - await tester.renameField("Number"); - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(FieldType.Number); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.Number); // enter some data into the first number cell await tester.editCell( @@ -243,7 +212,7 @@ void main() { ); // open editor and change number format - await tester.tapGridFieldWithName('Number'); + await tester.tapGridFieldWithName(FieldType.Number.i18n); await tester.tapEditFieldButton(); await tester.changeNumberFieldFormat(); await tester.dismissFieldEditor(); @@ -292,11 +261,7 @@ void main() { await tester.scrollToRight(find.byType(GridPage)); // create a date field - await tester.tapNewPropertyButton(); - await tester.renameField(FieldType.DateTime.i18n); - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(FieldType.DateTime); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.DateTime); // edit the first date cell await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart new file mode 100644 index 0000000000000..ddbffa6ca743d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart @@ -0,0 +1,96 @@ +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/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/startup/startup.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', (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.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // 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.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart index 0934e7721be86..095d633b2dbac 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart @@ -1,3 +1,6 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/util/field_type_extension.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; @@ -121,15 +124,24 @@ void main() { FieldType.Checkbox, ]) { await tester.tapRowDetailPageCreatePropertyButton(); - await tester.renameField(fieldType.name); // 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 - await tester.findCellByFieldType(fieldType); + tester.findCellByFieldType(fieldType); await tester.scrollRowDetailByOffset(const Offset(0, -50)); } }); diff --git a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart index 51df6d6b14fc7..1f2f23dc2c96b 100644 --- a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -1,6 +1,6 @@ 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/settings_account_view.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'; @@ -20,12 +20,12 @@ extension AppFlowyAuthTest on WidgetTester { Future logout() async { final scrollable = find.findSettingsScrollable(); await scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: scrollable, ); - await tapButton(find.byType(SignInOutButton)); + await tapButton(find.byType(AccountSignInOutButton)); expectToSeeText(LocaleKeys.button_ok.tr()); await tapButtonWithName(LocaleKeys.button_ok.tr()); diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index fad17a00c7867..84ed1de3d30bd 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -1,5 +1,10 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +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'; @@ -30,10 +35,6 @@ 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/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 'emoji.dart'; diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 754e80342cffe..b25fb056a5866 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -41,6 +41,7 @@ import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.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'; @@ -50,6 +51,7 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_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'; @@ -72,7 +74,6 @@ 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/dialogs.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'; @@ -474,7 +475,7 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(); if (enter) { await testTextInput.receiveAction(TextInputAction.done); - await pumpAndSettle(); + await pumpAndSettle(const Duration(milliseconds: 500)); } else { await tapButton( find.descendant( @@ -627,12 +628,7 @@ extension AppFlowyDatabaseTest on WidgetTester { (w) => w is FieldActionCell && w.action == FieldAction.delete, ); await tapButton(deleteButton); - - final confirmButton = find.descendant( - of: find.byType(NavigatorAlertDialog), - matching: find.byType(PrimaryTextButton), - ); - await tapButton(confirmButton); + await tapButtonWithName(LocaleKeys.space_delete.tr()); } Future scrollRowDetailByOffset(Offset offset) async { @@ -786,7 +782,7 @@ extension AppFlowyDatabaseTest on WidgetTester { /// Each field has its own cell, so we can find the corresponding cell by /// the field type after create a new field. - Future findCellByFieldType(FieldType fieldType) async { + void findCellByFieldType(FieldType fieldType) { final finder = finderForFieldType(fieldType); expect(finder, findsWidgets); } @@ -857,6 +853,11 @@ extension AppFlowyDatabaseTest on WidgetTester { 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); @@ -887,18 +888,19 @@ extension AppFlowyDatabaseTest on WidgetTester { } Future createField( - FieldType fieldType, - String name, { + FieldType fieldType, { + String? name, ViewLayoutPB layout = ViewLayoutPB.Grid, }) async { if (layout == ViewLayoutPB.Grid) { await scrollToRight(find.byType(GridPage)); } await tapNewPropertyButton(); - await renameField(name); + if (name != null) { + await renameField(name); + } await tapSwitchFieldTypeButton(); await selectFieldType(fieldType); - await dismissFieldEditor(); } Future tapDatabaseSettingButton() async { @@ -1580,6 +1582,8 @@ Finder finderForFieldType(FieldType fieldType) { 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'); } diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index 20193dfd9ba36..e06634efef9cb 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -2,7 +2,7 @@ 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/settings_account_view.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/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; @@ -77,14 +77,14 @@ extension AppFlowySettings on WidgetTester { Future enterUserName(String name) async { // Enable editing username final editUsernameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byFlowySvg(FlowySvgs.edit_s), ); await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); final userNameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byType(FlowyTextField), ); await enterText(userNameFinder, name); diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 8829c71074cf9..28d37bfa2331c 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -67,11 +67,11 @@ PODS: - SDWebImage (5.14.2): - SDWebImage/Core (= 5.14.2) - SDWebImage/Core (5.14.2) - - Sentry/HybridSDK (8.33.0) - - sentry_flutter (8.7.0): + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.33.0) + - Sentry/HybridSDK (= 8.35.1) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -184,8 +184,8 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 - Sentry: 8560050221424aef0bebc8e31eedf00af80f90a6 - sentry_flutter: e26b861f744e5037a3faf9bf56603ec65d658a61 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index cca5b3716c594..0c8c1eff43371 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -46,10 +46,8 @@ NSAllowsArbitraryLoads - NSCameraUsageDescription - AppFlowy requires access to the camera. NSPhotoLibraryUsageDescription - AppFlowy requires access to the photo library. + AppFlowy needs access to your photos to let you add images to your documents UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName @@ -69,5 +67,7 @@ UIViewControllerBasedStatusBarAppearance + UISupportsDocumentBrowser + diff --git a/frontend/appflowy_flutter/lib/core/frameless_window.dart b/frontend/appflowy_flutter/lib/core/frameless_window.dart index ea6c730855596..15732f163dfa6 100644 --- a/frontend/appflowy_flutter/lib/core/frameless_window.dart +++ b/frontend/appflowy_flutter/lib/core/frameless_window.dart @@ -1,15 +1,7 @@ import 'dart:io'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/shared/window_title_bar.dart'; -import 'package:appflowy/workspace/application/home/home_setting_bloc.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/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; class CocoaWindowChannel { CocoaWindowChannel._(); @@ -38,11 +30,9 @@ class MoveWindowDetector extends StatefulWidget { const MoveWindowDetector({ super.key, this.child, - this.showTitleBar = false, }); final Widget? child; - final bool showTitleBar; @override MoveWindowDetectorState createState() => MoveWindowDetectorState(); @@ -54,28 +44,10 @@ class MoveWindowDetectorState extends State { @override Widget build(BuildContext context) { - if (!Platform.isMacOS && !Platform.isWindows) { + if (!Platform.isMacOS) { return widget.child ?? const SizedBox.shrink(); } - if (Platform.isWindows) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.showTitleBar) ...[ - WindowTitleBar( - leftChildren: [ - _buildToggleMenuButton(context), - ], - ), - ] else ...[ - const SizedBox(height: 5), - ], - widget.child ?? const SizedBox.shrink(), - ], - ); - } - return GestureDetector( // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack behavior: HitTestBehavior.translucent, @@ -96,45 +68,4 @@ class MoveWindowDetectorState extends State { child: widget.child, ); } - - Widget _buildToggleMenuButton(BuildContext context) { - if (!context.read().state.isMenuCollapsed) { - 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: (_) => context - .read() - .add(const HomeSettingEvent.collapseMenu()), - child: FlowyHover( - child: Container( - width: 24, - padding: const EdgeInsets.all(4), - child: const RotatedBox( - quarterTurns: 2, - child: FlowySvg(FlowySvgs.hide_menu_s), - ), - ), - ), - ), - ); - } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart index a80a28c124f52..90ebd4f61f97e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart @@ -69,8 +69,10 @@ class _MobileBoardPageState extends State { loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - error: (err) => AppFlowyErrorPage( - error: err.error, + error: (err) => Center( + child: AppFlowyErrorPage( + error: err.error, + ), ), ready: (data) => const _BoardContent(), orElse: () => const SizedBox.shrink(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart index 0b0c16f951e36..f80525786ed6d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart @@ -1,9 +1,12 @@ +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'; @@ -11,7 +14,6 @@ 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'; @@ -200,7 +202,7 @@ class MobileHiddenGroup extends StatelessWidget { children: [ Expanded( child: Text( - context.read().generateGroupNameFromGroup(group), + group.generateGroupName(databaseController), style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart index 6a54646301170..4b080e540dda7 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -1,3 +1,5 @@ +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.dart'; @@ -23,7 +25,6 @@ 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'; @@ -306,6 +307,8 @@ class MobileRowDetailPageContentState viewId: viewId, rowCache: rowCache, ); + rowController.initialize(); + cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart index 7c26879a4f420..95298daeff3ae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.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/database_controller.dart'; @@ -6,7 +8,6 @@ import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.d import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/util/field_type_extension.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 { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart index 48d8b2f097374..26978410051ef 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart @@ -1,9 +1,16 @@ +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/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:flutter/material.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'; class MobileCardContent extends StatelessWidget { const MobileCardContent({ @@ -21,19 +28,47 @@ class MobileCardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); + return Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: cells.map( - (cellMeta) { - return cellBuilder.build( - cellContext: cellMeta.cellContext(), - styleMap: mobileBoardCardCellStyleMap(context), - hasNotes: !rowMeta.isDocumentEmpty, - ); - }, - ).toList(), + children: [ + ...cells.map( + (cellMeta) { + return cellBuilder.build( + cellContext: cellMeta.cellContext(), + styleMap: mobileBoardCardCellStyleMap(context), + hasNotes: !rowMeta.isDocumentEmpty, + ); + }, + ), + if (attachmentCount > 0) ...[ + const VSpace(4), + Padding( + padding: const EdgeInsets.only(left: 8), + 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: ['$attachmentCount']), + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart index d794817339bf4..a51e3561f8464 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart @@ -1,12 +1,13 @@ +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/mobile/presentation/database/field/mobile_full_field_editor.dart'; -import 'package:appflowy/plugins/database/domain/field_backend_service.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 { @@ -49,7 +50,7 @@ class _MobileEditPropertyScreenState extends State { return PopScope( onPopInvoked: (didPop) { - if (didPop) { + if (!didPop) { context.pop(_fieldOptionValues); } }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart index fb1bda724d310..74c7a5a56b141 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart @@ -29,6 +29,7 @@ const mobileSupportedFieldTypes = [ FieldType.CreatedTime, FieldType.Checkbox, FieldType.Checklist, + FieldType.Media, // FieldType.Time, ]; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart index 0b415b04a67bd..311794aa42ec0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart @@ -1,3 +1,5 @@ +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'; @@ -11,7 +13,6 @@ import 'package:appflowy/plugins/database/widgets/setting/field_visibility_exten 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:go_router/go_router.dart'; @@ -61,7 +62,8 @@ class _QuickEditFieldState extends State { create: (_) => FieldEditorBloc( viewId: widget.viewId, fieldController: widget.fieldController, - field: widget.fieldInfo.field, + fieldInfo: widget.fieldInfo, + isNew: false, ), child: BlocConsumer( listenWhen: (previous, current) => @@ -99,7 +101,7 @@ class _QuickEditFieldState extends State { context.pop(); }, ), - if (!widget.fieldInfo.isPrimary) + if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: fieldVisibility.isVisibleState() @@ -115,7 +117,6 @@ class _QuickEditFieldState extends State { } }, ), - if (!widget.fieldInfo.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertLeft.tr(), @@ -132,6 +133,7 @@ class _QuickEditFieldState extends State { ); }, ), + ], FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertRight.tr(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 1078d12b1fe93..f2d866533d0ee 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - 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'; @@ -197,10 +195,10 @@ class _HomePageState extends State<_HomePage> { children: [ // Header Padding( - padding: EdgeInsets.only( + padding: const EdgeInsets.only( left: HomeSpaceViewSizes.mHorizontalPadding, right: 8.0, - top: Platform.isAndroid ? 8.0 : 0.0, + ), child: MobileHomePageHeader( userProfile: widget.userProfile, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 8742dce817192..03d444e0b9035 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -46,7 +46,9 @@ class MobileHomePageHeader extends StatelessWidget { ? _MobileWorkspace(userProfile: userProfile) : _MobileUser(userProfile: userProfile), ), - const HomePageSettingsPopupMenu(), + HomePageSettingsPopupMenu( + userProfile: userProfile, + ), const HSpace(8.0), ], ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 07ee4de7d659e..1e0ddb5a5163f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -87,6 +87,7 @@ class _MobileHomeSettingPageState extends State { const SupportSettingGroup(), const AboutSettingGroup(), UserSessionSettingGroup( + userProfile: userProfile, showThirdPartyLogin: showThirdPartyLogin, ), const VSpace(20), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart index 2ee9e14175685..73a5381d42c2c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart @@ -64,11 +64,7 @@ class MobileHomeTrashPage extends StatelessWidget { ], ), body: state.objects.isEmpty - ? FlowyMobileStateContainer.info( - emoji: '🗑️', - title: LocaleKeys.trash_mobile_empty.tr(), - description: LocaleKeys.trash_mobile_emptyDescription.tr(), - ) + ? const _EmptyTrashBin() : _DeletedFilesListView(state), ); }, @@ -82,6 +78,41 @@ enum _TrashActionType { 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({ diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index a13f7b3c75e14..f5afc10465b8d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -4,6 +4,7 @@ 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' @@ -18,10 +19,16 @@ enum _MobileSettingsPopupMenuItem { } class HomePageSettingsPopupMenu extends StatelessWidget { - const HomePageSettingsPopupMenu({super.key}); + 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, @@ -32,13 +39,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget { ), shadowColor: const Color(0x68000000), elevation: 10, - color: Theme.of(context).colorScheme.surface, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: FlowySvg( - FlowySvgs.m_settings_more_s, - ), - ), + color: context.popupMenuBackgroundColor, itemBuilder: (BuildContext context) => >[ _buildItem( @@ -46,12 +47,15 @@ class HomePageSettingsPopupMenu extends StatelessWidget { svg: FlowySvgs.m_notification_settings_s, text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), - const PopupMenuDivider(height: 0.5), - _buildItem( - value: _MobileSettingsPopupMenuItem.members, - svg: FlowySvgs.m_settings_member_s, - text: LocaleKeys.settings_popupMenuItem_members.tr(), - ), + // only show the member items in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + 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, @@ -81,6 +85,12 @@ class HomePageSettingsPopupMenu extends StatelessWidget { break; } }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart index 1b99be9a424b8..2de40600f2cc8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart @@ -6,7 +6,10 @@ 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}); + const EmptySpacePlaceholder({ + super.key, + required this.type, + }); final MobilePageCardType type; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart index 1c0f5933fb636..8181e0a10aa3e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -200,9 +200,6 @@ class _MobileSpaceTabState extends State viewSection: FolderSpaceType.public.toViewSectionPB, ), ); - context.read().add( - const FolderEvent.expandOrUnExpand(isExpanded: true), - ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart index 1e1cd88ad903d..1de6c568d3067 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -1,9 +1,11 @@ +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/shared/red_dot.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; @@ -193,25 +195,44 @@ class _HomePageNavigationBar extends StatelessWidget { border: context.border, color: context.backgroundColor, ), - 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), + 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 == _addLabel) { // show an add dialog diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart index b414158aa541d..dfa277f2ef33d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -36,12 +36,7 @@ class NotificationSettingsPopupMenu extends StatelessWidget { // todo: replace it with shadows shadowColor: const Color(0x68000000), elevation: 10, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: FlowySvg( - FlowySvgs.m_settings_more_s, - ), - ), + color: context.popupMenuBackgroundColor, itemBuilder: (BuildContext context) => >[ _buildItem( @@ -87,6 +82,12 @@ class NotificationSettingsPopupMenu extends StatelessWidget { break; } }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 1d91c3b9f680f..15172aa68c38b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -1,10 +1,14 @@ +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:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -13,60 +17,203 @@ 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) { return Column( children: [ - if (showThirdPartyLogin) ...[ - BlocProvider( - create: (context) => getIt(), - child: BlocConsumer( - listener: (context, state) { - state.successOrFail?.fold( - (result) => runAppFlowy(), - (e) => Log.error(e), - ); - }, - builder: (context, state) { - return const ThirdPartySignInButtons( - expanded: true, - ); - }, + // third party sign in buttons + if (showThirdPartyLogin) _buildThirdPartySignInButtons(context), + const VSpace(8.0), + + // 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.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const VSpace(16.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => _showDeleteAccountDialog(context), + ), + ], + ], + ); + } + + 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 const ThirdPartySignInButtons( + expanded: true, + ); + }, + ), + ); + } + + 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(8), + 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, + onSuccess: () => Navigator.of(context).pop(), + ), + ), + const VSpace(12.0), + MobileLogoutButton( + text: LocaleKeys.button_cancel.tr(), + onPressed: () => Navigator.of(context).pop(), + ), + const VSpace(36.0), ], - MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_logout.tr(), - onPressed: () async { - await 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(); - }, - ); - }, + ), + ); + } + + 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, + ), ), ], ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart index 1d9f250d3a3fa..7c653209ab3f5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart @@ -5,6 +5,7 @@ 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_editor/appflowy_editor.dart' show PlatformExtension; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -72,10 +73,10 @@ class _MemberItem extends StatelessWidget { final canDelete = myRole.canDelete && member.email != userProfile.email; final textColor = member.role.isOwner ? Theme.of(context).hintColor : null; - Widget child = Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( + Widget child; + + if (PlatformExtension.isDesktop) { + child = Row( children: [ Expanded( child: FlowyText.medium( @@ -93,7 +94,33 @@ class _MemberItem extends StatelessWidget { ), ), ], - ), + ); + } else { + child = Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(36.0), + FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 15.0, + textAlign: TextAlign.end, + ), + ], + ); + } + + child = Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: child, ); if (canDelete) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart new file mode 100644 index 0000000000000..9026fbee65d5d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart @@ -0,0 +1,220 @@ +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/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/media_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(); + } + + 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) { + emit(state.copyWith(fieldName: fieldName)); + }, + 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); + if (from < to) { + to--; + } + + 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)); + }, + ); + }, + ); + } + + 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 MediaUploadTypePB 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; +} + +@freezed +class MediaCellState with _$MediaCellState { + const factory MediaCellState({ + UserProfilePB? userProfile, + required String fieldName, + @Default([]) List files, + }) = _MediaCellState; + + factory MediaCellState.initial(MediaCellController cellController) { + final cellData = cellController.getCellData(); + + return MediaCellState( + fieldName: cellController.fieldInfo.field.name, + files: cellData?.files ?? const [], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart index 22baf2659996a..700cd86cc2936 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart @@ -2,6 +2,7 @@ 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'; @@ -42,9 +43,6 @@ class TextCellBloc extends Bloc { emit(state.copyWith(wrap: wrap)); } }, - didUpdateEmoji: (String emoji) { - emit(state.copyWith(emoji: emoji)); - }, updateText: (String text) { if (state.content != text) { cellController.saveCellData(text, debounce: true); @@ -66,13 +64,6 @@ class TextCellBloc extends Bloc { } }, onFieldChanged: _onFieldChangedListener, - onRowMetaChanged: cellController.fieldInfo.isPrimary - ? () { - if (!isClosed) { - add(TextCellEvent.didUpdateEmoji(cellController.icon ?? "")); - } - } - : null, ); } @@ -91,14 +82,14 @@ class TextCellEvent with _$TextCellEvent { _DidUpdateField; const factory TextCellEvent.updateText(String text) = _UpdateText; const factory TextCellEvent.enableEdit(bool enabled) = _EnableEdit; - const factory TextCellEvent.didUpdateEmoji(String emoji) = _UpdateEmoji; } @freezed class TextCellState with _$TextCellState { const factory TextCellState({ required String content, - required String emoji, + required ValueNotifier? emoji, + required ValueNotifier? hasDocument, required bool enableEdit, required bool wrap, }) = _TextCellState; @@ -106,13 +97,18 @@ class TextCellState with _$TextCellState { factory TextCellState.initial(TextCellController cellController) { final cellData = cellController.getCellData() ?? ""; final wrap = cellController.fieldInfo.wrapCellContent ?? true; - final emoji = - cellController.fieldInfo.isPrimary ? cellController.icon ?? "" : ""; + 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, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart index 51c372fb6eec3..2fd71d744feb2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart @@ -5,7 +5,6 @@ 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/domain/row_meta_listener.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'; @@ -61,10 +60,8 @@ class CellController { final CellDataPersistence _cellDataPersistence; CellListener? _cellListener; - RowMetaListener? _rowMetaListener; CellDataNotifier? _cellDataNotifier; - VoidCallback? _onRowMetaChanged; Timer? _loadDataOperation; Timer? _saveDataOperation; @@ -75,8 +72,9 @@ class CellController { FieldInfo get fieldInfo => _fieldController.getField(_cellContext.fieldId)!; FieldType get fieldType => _fieldController.getField(_cellContext.fieldId)!.fieldType; - RowMetaPB? get rowMeta => _rowCache.getRow(rowId)?.rowMeta; - String? get icon => rowMeta?.icon; + 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 @@ -89,15 +87,6 @@ class CellController { fieldId: _cellContext.fieldId, ); - _rowCache.addListener( - rowId: rowId, - onRowChanged: (context, reason) { - if (reason == const ChangedReason.didFetchRow()) { - _onRowMetaChanged?.call(); - } - }, - ); - // 1. Listen on user edit event and load the new cell data if needed. // For example: // user input: 12 @@ -116,23 +105,12 @@ class CellController { fieldId, onFieldChanged: _onFieldChangedListener, ); - - // 3. If the field is primary listen to row meta changes. - if (fieldInfo.field.isPrimary) { - _rowMetaListener = RowMetaListener(_cellContext.rowId); - _rowMetaListener?.start( - callback: (newRowMeta) { - _onRowMetaChanged?.call(); - }, - ); - } } /// Add a new listener VoidCallback? addListener({ required void Function(T?) onCellChanged, void Function(FieldInfo fieldInfo)? onFieldChanged, - VoidCallback? onRowMetaChanged, }) { /// an adaptor for the onCellChanged listener void onCellChangedFn() => onCellChanged(_cellDataNotifier?.value); @@ -145,8 +123,6 @@ class CellController { ); } - _onRowMetaChanged = onRowMetaChanged; - // Return the function pointer that can be used when calling removeListener. return onCellChangedFn; } @@ -242,9 +218,6 @@ class CellController { } Future dispose() async { - await _rowMetaListener?.stop(); - _rowMetaListener = null; - await _cellListener?.stop(); _cellListener = null; @@ -258,7 +231,6 @@ class CellController { _saveDataOperation?.cancel(); _cellDataNotifier?.dispose(); _cellDataNotifier = null; - _onRowMetaChanged = null; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart index 50ef7ccb74cd0..afe05e8b70bf5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -18,6 +18,7 @@ typedef RelationCellController = CellController; typedef SummaryCellController = CellController; typedef TimeCellController = CellController; typedef TranslateCellController = CellController; +typedef MediaCellController = CellController; CellController makeCellController( DatabaseController databaseController, @@ -170,6 +171,18 @@ CellController makeCellController( ), 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; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart index 1c03239cde055..cfab4668ae1c8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -196,3 +196,19 @@ class TimeCellDataParser implements CellDataParser { } } } + +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; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart index b33f9f44eedb4..6f87403604b2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart @@ -2,6 +2,7 @@ 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'; @@ -17,30 +18,33 @@ part 'field_editor_bloc.freezed.dart'; class FieldEditorBloc extends Bloc { FieldEditorBloc({ required this.viewId, + required this.fieldInfo, required this.fieldController, this.onFieldInserted, - required FieldPB field, - }) : fieldId = field.id, - fieldService = FieldBackendService( + required this.isNew, + }) : _fieldService = FieldBackendService( viewId: viewId, - fieldId: field.id, + fieldId: fieldInfo.id, ), fieldSettingsService = FieldSettingsBackendService(viewId: viewId), - super(FieldEditorState(field: FieldInfo.initial(field))) { + super(FieldEditorState(field: fieldInfo)) { _dispatch(); _startListening(); _init(); } final String viewId; - final String fieldId; + final FieldInfo fieldInfo; + final bool isNew; final FieldController fieldController; - final FieldBackendService fieldService; + 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( @@ -58,11 +62,20 @@ class FieldEditorBloc extends Bloc { emit(state.copyWith(field: fieldInfo)); }, switchFieldType: (fieldType) async { - await fieldService.updateType(fieldType: fieldType); + 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); + final result = await _fieldService.updateField(name: newName); _logIfError(result); + emit(state.copyWith(wasRenameManually: true)); }, updateTypeOption: (typeOptionData) async { final result = await FieldBackendService.updateFieldTypeOption( @@ -73,14 +86,14 @@ class FieldEditorBloc extends Bloc { _logIfError(result); }, insertLeft: () async { - final result = await fieldService.createBefore(); + 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(); + final result = await _fieldService.createAfter(); result.fold( (newField) => onFieldInserted?.call(newField.id), (err) => Log.error("Failed creating field $err"), @@ -94,7 +107,7 @@ class FieldEditorBloc extends Bloc { ? FieldVisibility.AlwaysShown : FieldVisibility.AlwaysHidden; final result = await fieldSettingsService.updateFieldSettings( - fieldId: state.field.id, + fieldId: fieldId, fieldVisibility: newVisibility, ); _logIfError(result); @@ -164,5 +177,6 @@ class FieldEditorEvent with _$FieldEditorEvent { class FieldEditorState with _$FieldEditorState { const factory FieldEditorState({ required final FieldInfo field, + @Default(false) bool wasRenameManually, }) = _FieldEditorState; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart index 1390d9ff97bac..b03a5c69fef9a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart @@ -35,6 +35,8 @@ class RelatedRowDetailPageBloc on((event, emit) async { event.when( didInitialize: (databaseController, rowController) { + rowController.initialize(); + state.maybeWhen( ready: (_, oldRowController) async { await oldRowController.dispose(); @@ -93,6 +95,7 @@ class RelatedRowDetailPageBloc viewId: inlineView.id, rowCache: databaseController.rowCache, ); + add( RelatedRowDetailPageEvent.didInitialize( databaseController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart index 4d9f594471454..2c671ebcc7031 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -92,12 +92,19 @@ class RowCache { } void setRowMeta(RowMetaPB rowMeta) { - final rowInfo = buildGridRow(rowMeta); - _rowList.add(rowInfo); + var rowInfo = _rowList.get(rowMeta.id); + if (rowInfo != null) { + rowInfo.updateRowMeta(rowMeta); + } else { + rowInfo = buildGridRow(rowMeta); + _rowList.add(rowInfo); + } + _changedNotifier?.receive(const ChangedReason.didFetchRow()); } void dispose() { + _rowList.dispose(); _rowLifeCycle.onRowDisposed(); _changedNotifier?.dispose(); _changedNotifier = null; @@ -176,8 +183,10 @@ class RowCache { } } - final updatedIndexs = - _rowList.updateRows(updatedList, (rowId) => buildGridRow(rowId)); + final updatedIndexs = _rowList.updateRows( + rowMetas: updatedList, + builder: (rowId) => buildGridRow(rowId), + ); if (updatedIndexs.isNotEmpty) { _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); @@ -251,9 +260,7 @@ class RowCache { final rowInfo = _rowList.get(rowMetaPB.id); final rowIndex = _rowList.indexOfRow(rowMetaPB.id); if (rowInfo != null && rowIndex != null) { - final updatedRowInfo = rowInfo.copyWith(rowMeta: rowMetaPB); - _rowList.remove(rowMetaPB.id); - _rowList.insert(rowIndex, updatedRowInfo); + rowInfo.rowMetaNotifier.value = rowMetaPB; final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); updatedIndexs[rowMetaPB.id] = UpdatedIndex( @@ -308,15 +315,38 @@ class RowChangesetNotifier extends ChangeNotifier { } } -@unfreezed -class RowInfo with _$RowInfo { - const RowInfo._(); - factory RowInfo({ - required UnmodifiableListView fields, +class RowInfo { + RowInfo({ + required this.fields, required RowMetaPB rowMeta, - }) = _RowInfo; + }) : rowMetaNotifier = ValueNotifier(rowMeta), + rowIconNotifier = ValueNotifier(rowMeta.icon), + rowDocumentNotifier = ValueNotifier( + !(rowMeta.hasIsDocumentEmpty() ? rowMeta.isDocumentEmpty : true), + ); - String get rowId => rowMeta.id; + 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(); + } } typedef InsertedIndexs = List; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart index 1ba336b321dde..4dd42d9200c13 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart @@ -38,6 +38,9 @@ class RowController { 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( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart index b31a3022e9c31..00b074544870b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:math'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; @@ -117,20 +118,23 @@ class RowList { return deletedIndex; } - UpdatedIndexMap updateRows( - List rowMetas, - RowInfo Function(RowMetaPB) builder, - ) { + 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(index, rowInfo); + insert(insertIndex, rowInfo); updatedIndexs[rowMeta.id] = UpdatedIndex( - index: index, + index: insertIndex, rowId: rowMeta.id, ); } @@ -162,4 +166,11 @@ class RowList { bool contains(RowId rowId) { return rowInfoByRowId[rowId] != null; } + + void dispose() { + for (final rowInfo in _rowInfos) { + rowInfo.dispose(); + } + _rowInfos.clear(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart index 03eba2fc780a1..312eddf7441a1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart @@ -38,7 +38,7 @@ class RowBackendService { } Future> initRow(RowId rowId) async { - final payload = RowIdPB() + final payload = DatabaseViewRowIdPB() ..viewId = viewId ..rowId = rowId; @@ -65,7 +65,7 @@ class RowBackendService { required String viewId, required String rowId, }) { - final payload = RowIdPB() + final payload = DatabaseViewRowIdPB() ..viewId = viewId ..rowId = rowId; @@ -73,7 +73,7 @@ class RowBackendService { } Future> getRowMeta(RowId rowId) { - final payload = RowIdPB.create() + final payload = DatabaseViewRowIdPB.create() ..viewId = viewId ..rowId = rowId; @@ -119,7 +119,7 @@ class RowBackendService { String viewId, RowId rowId, ) { - final payload = RowIdPB( + final payload = DatabaseViewRowIdPB( viewId: viewId, rowId: rowId, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart index abdf606e93836..cdb1708165569 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart @@ -1,10 +1,13 @@ import 'dart:async'; import 'dart:collection'; +import 'package:flutter/foundation.dart'; + import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/domain/group_service.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/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -13,17 +16,14 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:calendar_view/calendar_view.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'; @@ -93,9 +93,9 @@ class BoardBloc extends Bloc { (event, emit) async { await event.when( initial: () async { - emit(BoardState.initial(viewId)); _startListening(); await _openDatabase(emit); + emit(BoardState.initial(viewId)); }, createRow: (groupId, position, title, targetRowId, url) async { final primaryField = databaseController.fieldController.fieldInfos @@ -213,13 +213,13 @@ class BoardBloc extends Bloc { endEditingHeader: (String groupId, String? groupName) async { final group = groupControllers[groupId]?.group; if (group != null) { - if (generateGroupNameFromGroup(group) != groupName) { + final currentName = group.generateGroupName(databaseController); + if (currentName != groupName) { await groupBackendSvc.updateGroup( fieldId: groupControllers.values.first.group.fieldId, groupId: groupId, name: groupName, ); - return; } } @@ -445,7 +445,9 @@ class BoardBloc extends Bloc { boardController.getGroupController(group.groupId); if (columnController != null) { // remove the group or update its name - columnController.updateGroupName(generateGroupNameFromGroup(group)); + columnController.updateGroupName( + group.generateGroupName(databaseController), + ); if (!group.isVisible) { boardController.removeGroup(group.groupId); } @@ -525,7 +527,7 @@ class BoardBloc extends Bloc { AppFlowyGroupData _initializeGroupData(GroupPB group) { return AppFlowyGroupData( id: group.groupId, - name: generateGroupNameFromGroup(group), + name: group.generateGroupName(databaseController), items: _buildGroupItems(group), customData: GroupData( group: group, @@ -533,103 +535,6 @@ class BoardBloc extends Bloc { ), ); } - - String generateGroupNameFromGroup(GroupPB group) { - final field = fieldController.getField(group.fieldId); - if (field == null) { - return ""; - } - - // if the group is the default group, then - if (group.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 == group.groupId); - return option == null ? "" : option.name; - case FieldType.MultiSelect: - final options = - MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) - .options; - final option = - options.firstWhereOrNull((option) => option.id == group.groupId); - return option == null ? "" : option.name; - case FieldType.Checkbox: - return group.groupId; - case FieldType.URL: - return group.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(group.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 ""; - } - } } @freezed @@ -719,10 +624,7 @@ class GroupItem extends AppFlowyGroupItem { GroupItem({ required this.row, required this.fieldInfo, - bool draggable = true, - }) { - super.draggable.value = draggable; - } + }); final RowMetaPB row; final FieldInfo fieldInfo; @@ -811,7 +713,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { return Log.warn("fieldInfo should not be null"); } - final item = GroupItem(row: row, fieldInfo: fieldInfo, draggable: false); + final item = GroupItem(row: row, fieldInfo: fieldInfo); if (index != null) { controller.insertGroupItem(group.groupId, index, item); diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart new file mode 100644 index 0000000000000..dcb1578930da7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/domain/group_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../application/database_controller.dart'; +import '../../application/field/field_controller.dart'; + +part 'column_header_bloc.freezed.dart'; + +class ColumnHeaderBloc extends Bloc { + ColumnHeaderBloc({ + required this.databaseController, + required this.fieldId, + required this.group, + }) : super(const ColumnHeaderState.loading()) { + groupBackendSvc = GroupBackendService(viewId); + _dispatch(); + } + + final DatabaseController databaseController; + final String fieldId; + final GroupPB group; + + late final GroupBackendService groupBackendSvc; + + FieldController get fieldController => databaseController.fieldController; + String get viewId => databaseController.viewId; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + final name = group.generateGroupName(databaseController); + emit(ColumnHeaderState.initial(name)); + }, + startEditing: () async { + state.maybeMap( + ready: (state) => emit(state.copyWith(isEditing: true)), + orElse: () {}, + ); + }, + endEditing: (String? groupName) async { + if (groupName != null) { + final stateGroupName = state.maybeMap( + ready: (state) => state.groupName, + orElse: () => null, + ); + + if (stateGroupName == null || stateGroupName == groupName) { + state.maybeMap( + ready: (state) => emit( + state.copyWith( + groupName: stateGroupName!, + isEditing: false, + ), + ), + orElse: () {}, + ); + } + + await groupBackendSvc.renameGroup( + groupId: group.groupId, + fieldId: fieldId, + name: groupName, + ); + state.maybeMap( + ready: (state) { + emit(state.copyWith(groupName: groupName, isEditing: false)); + }, + orElse: () {}, + ); + } + }, + ); + }, + ); + } +} + +@freezed +class ColumnHeaderEvent with _$ColumnHeaderEvent { + const factory ColumnHeaderEvent.initial() = _Initial; + const factory ColumnHeaderEvent.startEditing() = _StartEditing; + const factory ColumnHeaderEvent.endEditing(String? groupName) = _EndEditing; +} + +@freezed +class ColumnHeaderState with _$ColumnHeaderState { + const ColumnHeaderState._(); + + const factory ColumnHeaderState.loading() = _ColumnHeaderLoadingState; + + const factory ColumnHeaderState.error({ + required FlowyError error, + }) = _ColumnHeaderErrorState; + + const factory ColumnHeaderState.ready({ + required String groupName, + @Default(false) bool isEditing, + @Default(false) bool canEdit, + }) = _ColumnHeaderReadyState; + + factory ColumnHeaderState.initial(String name) => + ColumnHeaderState.ready(groupName: name); + + bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false); + bool get isError => maybeMap(error: (_) => true, orElse: () => false); + bool get isReady => maybeMap(ready: (_) => true, orElse: () => false); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart new file mode 100644 index 0000000000000..5c69a66e62a0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart @@ -0,0 +1,106 @@ +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 ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 7c1da7b19f32b..c2c162928f33b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -6,6 +6,7 @@ import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.da 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/application/column_header_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/grid/presentation/widgets/header/field_type_extension.dart'; @@ -15,6 +16,7 @@ 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_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; @@ -23,7 +25,6 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart' hide Card; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -201,9 +202,10 @@ class _DesktopBoardPageState extends State { loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - error: (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + error: (err) => Center( + child: AppFlowyErrorPage( + error: err.error, + ), ), orElse: () => _BoardContent( onEditStateChanged: widget.onEditStateChanged, @@ -341,8 +343,22 @@ class _BoardContentState extends State<_BoardContent> { false ? BoardTrailing(scrollController: scrollController) : const HSpace(40), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), + headerBuilder: (_, groupData) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider( + create: (context) => ColumnHeaderBloc( + databaseController: databaseController, + fieldId: (groupData.customData as GroupData).fieldInfo.id, + group: context + .read() + .groupControllers[groupData.headerData.groupId]! + .group, + )..add(const ColumnHeaderEvent.initial()), + ), + ], child: BoardColumnHeader( groupData: groupData, margin: config.groupHeaderPadding, diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart index 0ee31e41f42ae..8e08b69c64319 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart @@ -1,6 +1,10 @@ +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/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/column_header_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -9,8 +13,6 @@ import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_popover/appflowy_popover.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 BoardColumnHeader extends StatefulWidget { @@ -63,12 +65,12 @@ class _BoardColumnHeaderState extends State { Widget build(BuildContext context) { final boardCustomData = widget.groupData.customData as GroupData; - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { - if (state.editingHeaderId != null) { + if (state.isEditing) { WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -76,7 +78,7 @@ class _BoardColumnHeaderState extends State { Widget title = Expanded( child: FlowyText.medium( - widget.groupData.headerData.groupName, + state.groupName, overflow: TextOverflow.ellipsis, ), ); @@ -90,11 +92,11 @@ class _BoardColumnHeaderState extends State { child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () => context.read().add( - BoardEvent.startEditingHeader(widget.groupData.id), - ), + onTap: () => context + .read() + .add(const ColumnHeaderEvent.startEditing()), child: FlowyText.medium( - widget.groupData.headerData.groupName, + state.groupName, overflow: TextOverflow.ellipsis, ), ), @@ -103,7 +105,7 @@ class _BoardColumnHeaderState extends State { ); } - if (state.editingHeaderId == widget.groupData.id) { + if (state.isEditing) { title = _buildTextField(context); } @@ -190,9 +192,11 @@ class _BoardColumnHeaderState extends State { ); } - void _saveEdit() => context - .read() - .add(BoardEvent.endEditingHeader(widget.groupData.id, _controller.text)); + void _saveEdit() { + context + .read() + .add(ColumnHeaderEvent.endEditing(_controller.text)); + } Widget _buildHeaderIcon(GroupData customData) => switch (customData.fieldType) { @@ -263,8 +267,8 @@ enum GroupOptions { switch (this) { case rename: context - .read() - .add(BoardEvent.startEditingHeader(group.groupId)); + .read() + .add(const ColumnHeaderEvent.startEditing()); break; case hide: context diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart index a44cf1c55d3e0..a013d370f8be2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart @@ -1,5 +1,7 @@ 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/plugins/database/application/cell/cell_controller.dart'; @@ -7,6 +9,7 @@ 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'; @@ -18,7 +21,6 @@ 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 { @@ -278,7 +280,8 @@ class HiddenGroupButtonContent extends StatelessWidget { ), const HSpace(4), FlowyText.medium( - bloc.generateGroupNameFromGroup(group), + group + .generateGroupName(bloc.databaseController), overflow: TextOverflow.ellipsis, ), const HSpace(6), @@ -355,11 +358,11 @@ class HiddenGroupCardActions extends StatelessWidget { class HiddenGroupPopupItemList extends StatelessWidget { const HiddenGroupPopupItemList({ + super.key, required this.groupId, required this.viewId, required this.primaryFieldId, required this.rowCache, - super.key, }); final String groupId; @@ -380,11 +383,12 @@ class HiddenGroupPopupItemList extends StatelessWidget { if (group == null) { return const SizedBox.shrink(); } + final bloc = context.read(); final cells = [ Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: FlowyText.medium( - context.read().generateGroupNameFromGroup(group), + group.generateGroupName(bloc.databaseController), fontSize: 10, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, @@ -397,6 +401,7 @@ class HiddenGroupPopupItemList extends StatelessWidget { viewId: viewId, rowCache: rowCache, ); + rowController.initialize(); final databaseController = context.read().databaseController; diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart index 8c9e36c2c36a4..3e9b4ac7d7785 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart @@ -220,7 +220,7 @@ class CalendarBloc extends Bloc { } Future?> _loadEvent(RowId rowId) async { - final payload = RowIdPB(viewId: viewId, rowId: rowId); + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); return DatabaseEventGetCalendarEvent(payload).send().then((result) { return result.fold( (eventPB) => _calendarEventDataFromEventPB(eventPB), diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart index b122d951bedca..48e159475c4c8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart @@ -29,6 +29,8 @@ class CalendarEventEditorBloc (event, emit) async { await event.when( initial: () { + rowController.initialize(); + _startListening(); final primaryFieldId = fieldController.fieldInfos .firstWhere((fieldInfo) => fieldInfo.isPrimary) diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart index 208906f00b348..e0bcc071ed9ff 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart @@ -77,7 +77,7 @@ class UnscheduleEventsBloc Future _loadEvent( RowId rowId, ) async { - final payload = RowIdPB(viewId: viewId, rowId: rowId); + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); return DatabaseEventGetCalendarEvent(payload).send().then( (result) => result.fold( (eventPB) => eventPB, diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart index dfbf0d6bc0df5..8a57bea2bd530 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -16,6 +15,7 @@ import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.d import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart index 9bc873f7f1e78..fe23916d2df8e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart @@ -110,12 +110,18 @@ class FieldBackendService { 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(); } @@ -177,11 +183,13 @@ class FieldBackendService { Future> updateType({ required FieldType fieldType, + String? fieldName, }) => updateFieldType( viewId: viewId, fieldId: fieldId, fieldType: fieldType, + fieldName: fieldName, ); Future> delete() => diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart index e618da5de96b3..7434c5e49724f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -281,6 +281,30 @@ class FilterBackendService { ); } + 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 fieldId, required String filterId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart index 934bbba8d163d..36bb847356197 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart @@ -60,4 +60,18 @@ class GroupBackendService { return DatabaseEventDeleteGroup(payload).send(); } + + Future> renameGroup({ + required String groupId, + required String fieldId, + required String name, + }) { + final payload = RenameGroupPB.create() + ..fieldId = fieldId + ..viewId = viewId + ..groupId = groupId + ..name = name; + + return DatabaseEventRenameGroup(payload).send(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index a27b0bf00074d..b4a8dc72b77d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -3,13 +3,7 @@ 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/filter_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/number_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -149,6 +143,11 @@ class GridCreateFilterBloc fieldId: fieldId, condition: TextFilterConditionPB.TextContains, ); + case FieldType.Media: + return _filterBackendSvc.insertMediaFilter( + fieldId: fieldId, + condition: MediaFilterConditionPB.MediaIsNotEmpty, + ); default: throw UnimplementedError(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart index 5c25fc851f153..3fcaae3332b68 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart @@ -20,6 +20,8 @@ class RowDetailBloc extends Bloc { _dispatch(); _startListening(); _init(); + + rowController.initialize(); } final FieldController fieldController; @@ -80,6 +82,15 @@ class RowDetailBloc extends Bloc { ), ); }, + startEditingField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId)); + }, + startEditingNewField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId)); + }, + endEditingField: () { + emit(state.copyWith(editingFieldId: "", newFieldId: "")); + }, ); }, ); @@ -217,6 +228,16 @@ class RowDetailEvent with _$RowDetailEvent { /// 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; } @freezed @@ -226,6 +247,8 @@ class RowDetailState with _$RowDetailState { required List visibleCells, required bool showHiddenFields, required int numHiddenFields, + required String editingFieldId, + required String newFieldId, }) = _RowDetailState; factory RowDetailState.initial() => const RowDetailState( @@ -233,5 +256,7 @@ class RowDetailState with _$RowDetailState { visibleCells: [], showHiddenFields: false, numHiddenFields: 0, + editingFieldId: "", + newFieldId: "", ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index 19c4e11483819..601c41db63b0e 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -1,23 +1,22 @@ import 'dart:math'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_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/application/view/view_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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; -import 'package:flowy_infra_ui/widget/error_page.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'; @@ -150,8 +149,9 @@ class _GridPageState extends State { }, ), builder: (context, state) => state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), finish: (result) => result.successOrFail.fold( (_) => GridShortcuts( child: GridPageContent( @@ -159,9 +159,10 @@ class _GridPageState extends State { view: widget.view, ), ), - (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), ), ), idle: (_) => const SizedBox.shrink(), @@ -352,9 +353,12 @@ class _GridRowsState extends State<_GridRows> { scrollController: widget.scrollController.verticalController, physics: const ClampingScrollPhysics(), buildDefaultDragHandles: false, - proxyDecorator: (child, index, animation) => Material( - color: Colors.white.withOpacity(.1), - child: Opacity(opacity: .5, child: child), + proxyDecorator: (child, _, __) => Provider.value( + value: context.read(), + child: Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), ), onReorder: (fromIndex, newIndex) { final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart index 210bd8b2a70a4..f70d98ab7722d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart @@ -105,8 +105,10 @@ class _MobileGridPageState extends State { _openRow(context, widget.initialRowId, true); return result.successOrFail.fold( (_) => GridShortcuts(child: GridPageContent(view: widget.view)), - (err) => AppFlowyErrorPage( - error: err, + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart index d28cc5c263d74..849a436e801a2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart @@ -78,12 +78,9 @@ class _GridCreateFilterListState extends State { child: ListView.separated( shrinkWrap: true, itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), ), ), ]; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart index d127ef6f912ec..6c1809d6910e5 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart @@ -86,7 +86,8 @@ class _GridFieldCellState extends State { return FieldEditor( viewId: widget.viewId, fieldController: widget.fieldController, - field: widget.fieldInfo.field, + fieldInfo: widget.fieldInfo, + isNewField: widget.isNew, initialPage: widget.isNew ? FieldEditorPage.details : FieldEditorPage.general, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart index 3267c74ad7160..c4d446f2162be 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart @@ -22,3 +22,10 @@ extension FieldTypeListExtension on FieldType { _ => false, }; } + +extension RowDetailAccessoryExtension on FieldType { + bool get showRowDetailAccessory => switch (this) { + FieldType.Media => false, + _ => true, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart index bf0bf78e08127..97532bcf3d04f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart @@ -174,7 +174,7 @@ class _CellTrailing extends StatelessWidget { } } -class CreateFieldButton extends StatefulWidget { +class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, @@ -184,11 +184,6 @@ class CreateFieldButton extends StatefulWidget { final String viewId; final void Function(String fieldId) onFieldCreated; - @override - State createState() => _CreateFieldButtonState(); -} - -class _CreateFieldButtonState extends State { @override Widget build(BuildContext context) { return FlowyButton( @@ -202,10 +197,10 @@ class _CreateFieldButtonState extends State { hoverColor: AFThemeExtension.of(context).greyHover, onTap: () async { final result = await FieldBackendService.createField( - viewId: widget.viewId, + viewId: viewId, ); result.fold( - (field) => widget.onFieldCreated(field.id), + (field) => onFieldCreated(field.id), (err) => Log.error("Failed to create field type option: $err"), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart index cca01bc036f3f..209c439ad164d 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart @@ -1,3 +1,5 @@ +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'; @@ -7,7 +9,6 @@ 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart index 19a5da2438f09..b803fbbd2867a 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -1,3 +1,5 @@ +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/cell/cell_controller.dart'; @@ -11,13 +13,13 @@ 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/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 StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index bd6bce8dd463c..44f62d0d619bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -1,4 +1,7 @@ +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/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; @@ -8,14 +11,16 @@ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.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/hover.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'card_bloc.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'; @@ -146,13 +151,11 @@ class _RowCardState extends State { triggerActions: PopoverTriggerFlags.none, constraints: BoxConstraints.loose(const Size(140, 200)), direction: PopoverDirection.rightWithCenterAligned, - popupBuilder: (_) { - return RowActionMenu.board( - viewId: _cardBloc.viewId, - rowId: _cardBloc.rowController.rowId, - groupId: widget.groupId, - ); - }, + popupBuilder: (_) => RowActionMenu.board( + viewId: _cardBloc.viewId, + rowId: _cardBloc.rowController.rowId, + groupId: widget.groupId, + ), child: RowCardContainer( buildAccessoryWhen: () => state.isEditing == false, accessories: accessories ?? [], @@ -196,11 +199,38 @@ class _CardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); final child = Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: _makeCells(context, rowMeta, cells), + children: [ + ..._makeCells(context, rowMeta, cells), + if (attachmentCount > 0) ...[ + const VSpace(2), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.media_s, + size: Size.square(12), + ), + const HSpace(4), + Flexible( + child: FlowyText.regular( + LocaleKeys.grid_media_attachmentsHint + .tr(args: ['$attachmentCount']), + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); return styleConfiguration.hoverStyle == null diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart index fa066db319ec8..a8ede243f73ed 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart @@ -32,6 +32,7 @@ class CardBloc extends Bloc { rowController.rowMeta, ), ) { + rowController.initialize(); _dispatch(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index aff11f6584cdd..d17c522de6a87 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -8,6 +8,7 @@ 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'; @@ -113,6 +114,12 @@ class CardCellBuilder { databaseController: databaseController, cellContext: cellContext, ), + FieldType.Media => MediaCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), _ => throw UnimplementedError, }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart index 3b47971fdbe3e..746a0c677dcf1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart @@ -1,7 +1,9 @@ +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'; @@ -44,7 +46,9 @@ class _DateCellState extends State { ); }, child: BlocBuilder( - buildWhen: (previous, current) => previous.dateStr != current.dateStr, + buildWhen: (previous, current) => + previous.dateStr != current.dateStr || + previous.data != current.data, builder: (context, state) { if (state.dateStr.isEmpty) { return const SizedBox.shrink(); @@ -53,9 +57,20 @@ class _DateCellState extends State { return Container( alignment: Alignment.centerLeft, padding: widget.style.padding, - child: Text( - state.dateStr, - style: widget.style.textStyle, + child: Row( + children: [ + Flexible( + child: Text( + state.dateStr, + style: widget.style.textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (state.data?.reminderId.isNotEmpty ?? false) ...[ + const HSpace(4), + const FlowySvg(FlowySvgs.clock_alarm_s), + ], + ], ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart new file mode 100644 index 0000000000000..f3569a57dc842 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart @@ -0,0 +1,39 @@ +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/card_cell_skeleton/card_cell.dart'; + +class MediaCardCellStyle extends CardCellStyle { + const MediaCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +// This is a placeholder for the MediaCardCell, it is not implemented +// as we use the [RowMetaPB.attachmentCount] to display cumulative attachments +// on a Card. +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 const SizedBox.shrink(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart index 36d4f24142005..187cd6af4a4dd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart @@ -1,13 +1,15 @@ +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:appflowy/plugins/database/application/cell/bloc/text_cell_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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_builder.dart'; @@ -60,7 +62,6 @@ class _TextCellState extends State { @override void initState() { super.initState(); - _textEditingController = TextEditingController(text: cellBloc.state.content) ..addListener(() { if (_textEditingController.value.composing.isCollapsed) { @@ -79,15 +80,17 @@ class _TextCellState extends State { // 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(() { - if (!focusNode.hasFocus) { - widget.editableNotifier?.isCellEditing.value = false; - cellBloc.add(const TextCellEvent.enableEdit(false)); - } - }); + focusNode.addListener(_onFocusChanged); _bindEditableNotifier(); } + void _onFocusChanged() { + if (!focusNode.hasFocus) { + widget.editableNotifier?.isCellEditing.value = false; + cellBloc.add(const TextCellEvent.enableEdit(false)); + } + } + void _bindEditableNotifier() { widget.editableNotifier?.isCellEditing.addListener(() { if (!mounted) { @@ -96,9 +99,8 @@ class _TextCellState extends State { final isEditing = widget.editableNotifier?.isCellEditing.value ?? false; if (isEditing) { - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); + WidgetsBinding.instance + .addPostFrameCallback((_) => focusNode.requestFocus()); } cellBloc.add(TextCellEvent.enableEdit(isEditing)); }); @@ -140,12 +142,13 @@ class _TextCellState extends State { } Widget? _buildIcon(TextCellState state) { - if (state.emoji.isNotEmpty) { + if (state.emoji?.value.isNotEmpty ?? false) { return Text( - state.emoji, + state.emoji?.value ?? '', style: widget.style.titleTextStyle, ); } + if (widget.showNotes) { return FlowyTooltip( message: LocaleKeys.board_notesTooltip.tr(), @@ -206,12 +209,15 @@ class _TextCellState extends State { bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => focusNode.unfocus(), + const SimpleActivator(LogicalKeyboardKey.enter): () => + focusNode.unfocus(), }, child: TextField( controller: _textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), - maxLines: isEditing ? null : 2, + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, minLines: 1, textInputAction: TextInputAction.done, readOnly: !isEditing, @@ -237,3 +243,27 @@ class _TextCellState extends State { ); } } + +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]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart index df7bb72f60a20..23a8a2451feb9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.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/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) { @@ -90,5 +91,9 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart index 7fcb289c1d884..6d9f08fb5b2a5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.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/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) { @@ -95,5 +96,9 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart index 3911678176210..93d98f013ee77 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -1,5 +1,6 @@ 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'; @@ -94,5 +95,9 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart new file mode 100644 index 0000000000000..9c27546d753b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -0,0 +1,237 @@ +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/presentation/bottom_sheet/bottom_sheet.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/shared/appflowy_network_image.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/platform_extension.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.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 = PlatformExtension.isMobile; + + Widget child = BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + final wrapContent = context.read().wrapContent; + final children = [ + ...filesToDisplay.map((file) => _FilePreviewRender(file: file)), + if (extraCount > 0) _ExtraInfo(extraCount: extraCount), + ]; + + if (filesToDisplay.isEmpty && isMobile) { + 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.all(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(6), + children: children, + ), + ), + ), + ); + }, + ); + + 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: BorderRadius.circular(12), + onTap: () { + showMobileBottomSheet( + context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const MobileMediaCellEditor(), + ), + ); + }, + hoverColor: Colors.transparent, + child: child, + ); + } + + return BlocProvider.value( + value: bloc, + child: Builder(builder: (context) => child), + ); + } +} + +class _FilePreviewRender extends StatelessWidget { + const _FilePreviewRender({required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + Widget child; + if (file.fileType == MediaFileTypePB.Image) { + if (file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + file.url, + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else if (file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(file.url), + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: file.url, + userProfilePB: context.read().state.userProfile, + height: 32, + width: 32, + ); + } + } else { + child = Container( + height: 32, + width: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + ), + child: FlowySvg( + file.fileType.icon, + color: AFThemeExtension.of(context).textColor, + ), + ); + } + + return Container( + margin: const EdgeInsets.all(2), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({required this.extraCount}); + + final int extraCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2), + child: Container( + height: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_moreFilesHint.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart index 4cfcfb2ddb321..8345cadc59310 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart @@ -1,3 +1,4 @@ +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'; @@ -20,26 +21,7 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { padding: GridSize.cellContentInsets, child: Row( children: [ - BlocBuilder( - buildWhen: (p, c) => p.emoji != c.emoji, - builder: (context, state) { - if (state.emoji.isEmpty) { - return const SizedBox.shrink(); - } - return Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlowyText( - state.emoji, - fontSize: 16, - ), - const HSpace(6), - ], - ), - ); - }, - ), + const _IconOrEmoji(), Expanded( child: TextField( controller: textEditingController, @@ -62,3 +44,49 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { ); } } + +class _IconOrEmoji extends StatelessWidget { + const _IconOrEmoji(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.emoji != null) + ValueListenableBuilder( + valueListenable: state.emoji!, + builder: (context, value, child) { + if (value.isEmpty) { + return const SizedBox.shrink(); + } else { + return FlowyText( + value, + fontSize: 16, + ); + } + }, + ), + if (state.hasDocument != null) + ValueListenableBuilder( + valueListenable: state.hasDocument!, + builder: (context, hasDocument, child) { + if ((state.emoji?.value.isEmpty ?? true) && hasDocument) { + return FlowySvg( + FlowySvgs.notes_s, + color: Theme.of(context).hintColor, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + const HSpace(6), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart new file mode 100644 index 0000000000000..9b9334e101e40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -0,0 +1,533 @@ +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/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/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_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/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.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/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.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_bloc/flutter_bloc.dart'; + +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: Builder( + builder: (context) => BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + return SizedBox( + width: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + if (state.files.isEmpty) { + return GestureDetector( + onTap: () => popoverController.show(), + child: AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: FlowyHover( + style: HoverStyle( + hoverColor: + AFThemeExtension.of(context).lightGreyHover, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FlowyText.medium( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); + } + + final size = constraints.maxWidth / 2 - 6; + return Wrap( + runSpacing: 12, + spacing: 12, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => + cellContainerNotifier.isFocus = false, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + ), + child: FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + const FlowySvg(FlowySvgs.edit_s), + const HSpace(4), + FlowyText.regular( + LocaleKeys.button_edit.tr(), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ...filesToDisplay.map( + (file) => _FilePreviewRender( + key: ValueKey(file.id), + file: file, + size: size, + mutex: mutex, + ), + ), + if (extraCount > 0) + _ExtraInfo( + extraCount: extraCount, + controller: popoverController, + mutex: mutex, + cellContainerNotifier: cellContainerNotifier, + ), + ], + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class _FilePreviewRender extends StatefulWidget { + const _FilePreviewRender({ + super.key, + required this.file, + required this.size, + required this.mutex, + }); + + final MediaFilePB file; + final double size; + final PopoverMutex mutex; + + @override + State<_FilePreviewRender> createState() => _FilePreviewRenderState(); +} + +class _FilePreviewRenderState extends State<_FilePreviewRender> { + final controller = PopoverController(); + + @override + void dispose() { + controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.file.fileType == MediaFileTypePB.Image) { + if (widget.file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + widget.file.url, + fit: BoxFit.cover, + ); + } else if (widget.file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(widget.file.url), + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: widget.file.url, + userProfilePB: context.read().state.userProfile, + ); + } + } else { + child = DecoratedBox( + decoration: BoxDecoration(color: widget.file.fileType.color), + child: Center( + child: Container( + padding: const EdgeInsets.all(8), + child: FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(32), + ), + ), + ), + ); + } + + return Stack( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Corners.s6Radius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 2, + ), + ], + ), + child: Column( + children: [ + Container( + height: widget.size, + width: widget.size, + constraints: BoxConstraints( + maxHeight: widget.size < 150 ? 100 : 195, + minHeight: widget.size < 150 ? 100 : 195, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + topLeft: Corners.s6Radius, + topRight: Corners.s6Radius, + ), + ), + child: child, + ), + Container( + height: 28, + width: widget.size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? Theme.of(context).cardColor + : AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + bottomLeft: Corners.s6Radius, + bottomRight: Corners.s6Radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: FlowyText.medium( + widget.file.name, + overflow: TextOverflow.ellipsis, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ), + ), + ], + ), + ), + Positioned(top: 5, right: 5, child: FileItemMenu(file: widget.file)), + ], + ); + } +} + +class FileItemMenu extends StatefulWidget { + const FileItemMenu({super.key, required this.file}); + + final MediaFilePB file; + + @override + State createState() => _FileItemMenuState(); +} + +class _FileItemMenuState extends State { + final popoverController = PopoverController(); + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + + @override + void initState() { + super.initState(); + nameController.text = widget.file.name; + } + + @override + void dispose() { + popoverController.close(); + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 5), + popupBuilder: (_) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () { + popoverController.close(); + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: + context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => context + .read() + .deleteFile(widget.file.id), + ), + ), + ); + }, + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: const FlowySvg(FlowySvgs.edit_s), + text: FlowyText.regular(LocaleKeys.grid_media_rename.tr()), + onTap: () { + popoverController.close(); + + nameController.text = widget.file.name; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: LocaleKeys + .document_plugins_file_renameFile_description + .tr(), + closeOnConfirm: false, + builder: (dialogContext) => FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(context), + disposeController: false, + ), + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(context), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.all(Corners.s8Radius), + ), + child: Padding( + padding: const EdgeInsets.all(3), + child: FlowyIconButton( + width: 20, + radius: BorderRadius.circular(0), + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ); + } + + void _saveName(BuildContext context) { + final newName = nameController.text.trim(); + if (newName.isEmpty) { + return; + } + + context + .read() + .add(MediaCellEvent.renameFile(fileId: widget.file.id, name: newName)); + Navigator.of(context).pop(); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({ + required this.extraCount, + required this.controller, + required this.mutex, + required this.cellContainerNotifier, + }); + + final int extraCount; + final PopoverController controller; + final PopoverMutex mutex; + final CellContainerNotifier cellContainerNotifier; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: const Key('extra_info'), + mutex: mutex, + controller: controller, + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: controller.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Container( + height: 38, + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_showMore.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart index 31dc63abe4995..dff883db1f45b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.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'; @@ -7,7 +9,6 @@ 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: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'; @@ -25,16 +26,13 @@ class DesktopRowDetailSelectOptionCellSkin controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext popoverContext) { - WidgetsBinding.instance.addPostFrameCallback((_) { - cellContainerNotifier.isFocus = true; - }); - return SelectOptionCellEditor( - cellController: bloc.cellController, - ); - }, onClose: () => cellContainerNotifier.isFocus = false, + onOpen: () => cellContainerNotifier.isFocus = true, + popupBuilder: (_) => SelectOptionCellEditor( + cellController: bloc.cellController, + ), child: BlocBuilder( builder: (context, state) { return Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index 155a6003ceb9e..9e0f334ec8fe0 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -3,6 +3,7 @@ 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'; @@ -134,6 +135,13 @@ class EditableCellBuilder { skin: IEditableTranslateCellSkin.fromStyle(style), key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableMediaCellSkin.fromStyle(style), + style: style, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -226,6 +234,13 @@ class EditableCellBuilder { skin: skinMap.timeSkin!, key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.mediaSkin!, + style: EditableCellStyle.desktopGrid, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -368,6 +383,12 @@ class SingleListenerFocusNode extends FocusNode { removeListener(_listener!); } } + + @override + void dispose() { + removeAllListener(); + super.dispose(); + } } class EditableCellSkinMap { @@ -382,6 +403,7 @@ class EditableCellSkinMap { this.urlSkin, this.relationSkin, this.timeSkin, + this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -394,6 +416,7 @@ class EditableCellSkinMap { final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -410,6 +433,7 @@ class EditableCellSkinMap { FieldType.RichText => textSkin != null, FieldType.URL => urlSkin != null, FieldType.Time => timeSkin != null, + FieldType.Media => mediaSkin != null, _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart new file mode 100644 index 0000000000000..55adb853345d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart @@ -0,0 +1,106 @@ +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; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart index c91ec05f79521..40e8c3531982c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart @@ -22,7 +22,7 @@ class MobileGridTextCellSkin extends IEditableTextCellSkin { buildWhen: (p, c) => p.emoji != c.emoji, builder: (context, state) => Center( child: FlowyText.emoji( - state.emoji, + state.emoji?.value ?? "", fontSize: 15, optimizeEmojiAlign: true, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart index 2e9e4b1a24987..279e79091386e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.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'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index 15fe9b59ffb70..e86a2c47c7f9d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -174,6 +174,8 @@ class _ChecklistItemState extends State { meta: Platform.isMacOS, control: !Platform.isMacOS, ): const _SelectTaskIntent(), + const SingleActivator(LogicalKeyboardKey.enter): + const _EndEditingTaskIntent(), const SingleActivator(LogicalKeyboardKey.escape): const _EndEditingTaskIntent(), }; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart index abd519f31d1fb..9d013402268c2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart @@ -61,6 +61,7 @@ class ChecklistCellTextfield extends StatelessWidget { controller: textController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium, + maxLines: null, decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart new file mode 100644 index 0000000000000..d4995c7f3609e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart @@ -0,0 +1,507 @@ +import 'dart:io'; + +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/appflowy_network_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/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.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) { + return Padding( + padding: const EdgeInsets.all(4), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.files.isNotEmpty) ...[ + ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (_, index) => BlocProvider.value( + key: Key(state.files[index].id), + value: context.read(), + child: RenderMedia( + file: state.files[index], + index: index, + enableReordering: state.files.length > 1, + mutex: itemMutex, + ), + ), + itemCount: state.files.length, + onReorder: (from, to) => context + .read() + .add(MediaCellEvent.reorderFiles(from: from, to: to)), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: SizeTransition( + sizeFactor: animation, + child: child, + ), + ), + ), + const Divider(height: 8), + ], + AppFlowyPopover( + controller: addFilePopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + ), + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (popoverContext) => FileUploadMenu( + onInsertLocalFile: (file) async => insertLocalFile( + context, + file, + userProfile: + context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + 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: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + + addFilePopoverController.close(); + }, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: addFilePopoverController.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(18), + ), + const HSpace(8), + FlowyText( + LocaleKeys.grid_media_addFileOrImage.tr(), + lineHeight: 1.0, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +extension ToCustomImageType on MediaUploadTypePB { + CustomImageType toCustomImageType() => switch (this) { + MediaUploadTypePB.NetworkMedia => CustomImageType.external, + MediaUploadTypePB.CloudMedia => CustomImageType.internal, + _ => CustomImageType.local, + }; +} + +@visibleForTesting +class RenderMedia extends StatefulWidget { + const RenderMedia({ + super.key, + required this.index, + required this.file, + required this.enableReordering, + required this.mutex, + }); + + final int index; + final MediaFilePB file; + final bool enableReordering; + final PopoverMutex mutex; + + @override + State createState() => _RenderMediaState(); +} + +class _RenderMediaState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? AFThemeExtension.of(context).greyHover + : Colors.transparent, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + children: [ + ReorderableDragStartListener( + index: widget.index, + enabled: widget.enableReordering, + child: const FlowySvg(FlowySvgs.drag_element_s), + ), + const HSpace(8), + if (widget.file.fileType == MediaFileTypePB.Image && + widget.file.uploadType == MediaUploadTypePB.CloudMedia) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: FlowyNetworkImage( + url: widget.file.url, + userProfilePB: + context.read().state.userProfile, + ), + ), + ), + ] else if (widget.file.fileType == MediaFileTypePB.Image) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: widget.file.uploadType == + MediaUploadTypePB.NetworkMedia + ? Image.network( + widget.file.url, + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ) + : Image.file( + File(widget.file.url), + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ), + ), + ), + ] else ...[ + Expanded( + child: GestureDetector( + onTap: () => afLaunchUrlString(widget.file.url), + child: Row( + children: [ + FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(18), + ), + const HSpace(8), + Flexible( + child: FlowyText( + widget.file.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AppFlowyPopover( + mutex: widget.mutex, + asBarrier: true, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + popupBuilder: (popoverContext) => BlocProvider.value( + value: context.read(), + child: MediaItemMenu( + file: widget.file, + closeContext: popoverContext, + ), + ), + child: FlowyIconButton( + width: 24, + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _openInteractiveViewer( + BuildContext context, { + required MediaFilePB file, + required Widget child, + }) => + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => + context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ), + child: child, + ); +} + +class MediaItemMenu extends StatefulWidget { + const MediaItemMenu({ + super.key, + required this.file, + this.closeContext, + }); + + final MediaFilePB file; + final BuildContext? closeContext; + + @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(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () => showDialog( + context: widget.closeContext ?? context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => + context.read().deleteFile(widget.file.id), + ), + ), + ), + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: FlowySvg( + FlowySvgs.edit_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + leftIconSize: const Size(18, 18), + text: FlowyText.regular( + LocaleKeys.grid_media_rename.tr(), + color: AFThemeExtension.of(context).textColor, + ), + onTap: () { + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + 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), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + } + + 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(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart index fb8b11474c221..b8054bb7a79ce 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart @@ -1,5 +1,7 @@ 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'; @@ -8,14 +10,11 @@ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_b 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'; class MobileChecklistCellEditScreen extends StatefulWidget { - const MobileChecklistCellEditScreen({ - super.key, - }); + const MobileChecklistCellEditScreen({super.key}); @override State createState() => @@ -48,21 +47,8 @@ class _MobileChecklistCellEditScreenState } Widget _buildHeader(BuildContext context) { - const iconWidth = 36.0; - const height = 44.0; return Stack( children: [ - Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(iconWidth), - ), - width: iconWidth, - onPressed: () => context.pop(), - ), - ), SizedBox( height: 44.0, child: Align( @@ -72,7 +58,7 @@ class _MobileChecklistCellEditScreenState ), ), ), - ].map((e) => SizedBox(height: height, child: e)).toList(), + ], ); } } @@ -152,7 +138,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 5), - height: 44, + constraints: const BoxConstraints(minHeight: 44), child: Row( children: [ InkWell( @@ -178,6 +164,8 @@ class _ChecklistItemState extends State<_ChecklistItem> { controller: _textController, focusNode: _focusNode, style: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.multiline, + maxLines: null, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart new file mode 100644 index 0000000000000..825de97d643d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart @@ -0,0 +1,376 @@ +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/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_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/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/util/xfile_ext.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:cross_file/cross_file.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 '../../../document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class MobileMediaCellEditor extends StatelessWidget { + const MobileMediaCellEditor({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + 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: 44.0, + child: Align( + child: FlowyText.medium( + LocaleKeys.grid_field_mediaFieldName.tr(), + fontSize: 18, + ), + ), + ), + const Divider(height: 0.5), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: FlowyButton( + margin: const EdgeInsets.all(12), + 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: (file) async { + dialogContext.pop(); + + await insertLocalFile( + context, + file, + userProfile: context + .read() + .state + .userProfile, + documentId: + context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = + context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + fileType: + file.fileType.toMediaFileTypePB(), + ), + ); + }, + ); + }, + onInsertNetworkFile: (url) async => + _onInsertNetworkFile( + url, + dialogContext, + context, + ), + ), + ), + ), + text: const Row( + children: [ + FlowySvg( + FlowySvgs.add_s, + size: Size.square(20), + ), + HSpace(8), + FlowyText('Add a file or image', fontSize: 15), + ], + ), + ), + ), + if (state.files.isNotEmpty) const Divider(height: .5), + ...state.files.map( + (file) => _FileItem(key: Key(file.id), file: file), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + } +} + +class _FileItem extends StatelessWidget { + const _FileItem({super.key, required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Row( + children: [ + if (file.fileType != MediaFileTypePB.Image) ...[ + 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: 125), + child: GestureDetector( + onTap: () => openInteractiveViewer(context), + child: ImageRender( + userProfile: + context.read().state.userProfile, + fit: BoxFit.fitHeight, + image: ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ), + ), + ), + ), + ], + FlowyIconButton( + width: 40, + icon: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(20), + ), + onPressed: () => showMobileBottomSheet( + context, + showDragHandle: true, + builder: (_) => BlocProvider.value( + value: context.read(), + child: _EditFileSheet(file: file), + ), + ), + ), + const HSpace(6), + ], + ), + ), + const Divider(height: .5), + ], + ); + } + + 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), + _FileTextField( + file: file, + controller: controller, + onChanged: (name) => + context.read().renameFile(file.id, name), + ), + const VSpace(20), + if (file.fileType == MediaFileTypePB.Image) + FlowyOptionTile.text( + text: LocaleKeys.grid_media_open.tr(), + leftIcon: const FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(20), + ), + onTap: () => openInteractiveViewer(context), + ), + FlowyOptionTile.text( + text: file.fileType == MediaFileTypePB.Link + ? LocaleKeys.grid_media_open.tr() + : LocaleKeys.grid_media_download.tr(), + leftIcon: FlowySvg( + file.fileType == MediaFileTypePB.Link + ? FlowySvgs.m_link_m + : FlowySvgs.import_s, + size: const Size.square(20), + ), + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + onDownloadBegin: () { + loader?.stop(); + loader = Loading(context); + loader?.start(); + }, + onDownloadEnd: () => loader?.stop(), + ), + ), + 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, + ); +} + +class _FileTextField extends StatelessWidget { + const _FileTextField({ + required this.file, + required this.controller, + required this.onChanged, + }); + + final MediaFilePB file; + final TextEditingController controller; + final void Function(String) onChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.textField( + controller: controller, + textFieldPadding: const EdgeInsets.symmetric(horizontal: 12), + onTextChanged: onChanged, + leftIcon: Container( + height: 38, + width: 38, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: file.fileType.color, + ), + child: Center( + child: FlowySvg( + file.fileType.icon, + size: const Size.square(22), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart index 63ea44008efe0..599ddeb017592 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -2,8 +2,15 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -126,7 +133,7 @@ class _RelationCellEditorContentState shrinkWrap: true, slivers: [ _CellEditorTitle( - databaseName: widget.relatedDatabaseMeta.databaseName, + databaseMeta: widget.relatedDatabaseMeta, ), _SearchField( focusNode: focusNode, @@ -204,10 +211,10 @@ class _RelationCellEditorContentState class _CellEditorTitle extends StatelessWidget { const _CellEditorTitle({ - required this.databaseName, + required this.databaseMeta, }); - final String databaseName; + final DatabaseMeta databaseMeta; @override Widget build(BuildContext context) { @@ -223,15 +230,20 @@ class _CellEditorTitle extends StatelessWidget { fontSize: 11, color: Theme.of(context).hintColor, ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: FlowyText.regular( - databaseName, - fontSize: 11, - overflow: TextOverflow.ellipsis, + 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, + ), + ), ), ), ], @@ -239,6 +251,28 @@ class _CellEditorTitle extends StatelessWidget { ), ); } + + void _openRelatedDatbase(BuildContext context) { + FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId)) + .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 { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart index 8ff2ccea13c85..dbcf4b8cb0cc4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart @@ -31,17 +31,19 @@ class FieldEditor extends StatefulWidget { const FieldEditor({ super.key, required this.viewId, - required this.field, + required this.fieldInfo, required this.fieldController, + required this.isNewField, this.initialPage = FieldEditorPage.details, this.onFieldInserted, }); final String viewId; - final FieldPB field; + final FieldInfo fieldInfo; final FieldController fieldController; final FieldEditorPage initialPage; final void Function(String fieldId)? onFieldInserted; + final bool isNewField; @override State createState() => _FieldEditorState(); @@ -49,13 +51,13 @@ class FieldEditor extends StatefulWidget { class _FieldEditorState extends State { late FieldEditorPage _currentPage; - late final TextEditingController textController; + late final TextEditingController textController = + TextEditingController(text: widget.fieldInfo.name); @override void initState() { super.initState(); _currentPage = widget.initialPage; - textController = TextEditingController(text: widget.field.name); } @override @@ -69,13 +71,14 @@ class _FieldEditorState extends State { return BlocProvider( create: (_) => FieldEditorBloc( viewId: widget.viewId, - field: widget.field, + fieldInfo: widget.fieldInfo, fieldController: widget.fieldController, onFieldInserted: widget.onFieldInserted, + isNew: widget.isNewField, ), - child: _currentPage == FieldEditorPage.details - ? _fieldDetails() - : _fieldGeneral(), + child: _currentPage == FieldEditorPage.general + ? _fieldGeneral() + : _fieldDetails(), ); } @@ -117,6 +120,21 @@ class _FieldEditorState extends State { ); } + 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, @@ -126,19 +144,6 @@ class _FieldEditorState extends State { ), ); } - - Widget _actionCell(FieldAction action) { - return BlocBuilder( - builder: (context, state) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: FieldActionCell( - viewId: widget.viewId, - fieldInfo: state.field, - action: action, - ), - ), - ); - } } class _EditFieldButton extends StatelessWidget { @@ -319,32 +324,33 @@ enum FieldAction { ); break; case FieldAction.clearData: - NavigatorAlertDialog( - constraints: const BoxConstraints( - maxWidth: 250, - maxHeight: 260, - ), - title: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), - confirm: () { + 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, ); }, - ).show(context); - PopoverContainer.of(context).close(); + ); break; case FieldAction.delete: - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { + 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, ); }, - ).show(context); - PopoverContainer.of(context).close(); + ); break; case FieldAction.wrap: context @@ -571,7 +577,10 @@ class _FieldNameTextFieldState extends State { } class SwitchFieldButton extends StatefulWidget { - const SwitchFieldButton({super.key, required this.popoverMutex}); + const SwitchFieldButton({ + super.key, + required this.popoverMutex, + }); final PopoverMutex popoverMutex; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart index 69fe3635a81ba..bb56bfa83eaf4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart @@ -24,6 +24,7 @@ const List _supportedFieldTypes = [ FieldType.Summary, // FieldType.Time, FieldType.Translate, + FieldType.Media, ]; class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart index e4bcdd4911ef5..624f9f1fb2d76 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart @@ -2,6 +2,7 @@ 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'; @@ -38,6 +39,7 @@ abstract class TypeOptionEditorFactory { FieldType.Summary => const SummaryTypeOptionEditorFactory(), FieldType.Time => const TimeTypeOptionEditorFactory(), FieldType.Translate => const TranslateTypeOptionEditorFactory(), + FieldType.Media => const MediaTypeOptionEditorFactory(), _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart new file mode 100644 index 0000000000000..ecd1ba73fb173 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.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, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart new file mode 100644 index 0000000000000..d4ae050dfa70d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart @@ -0,0 +1,28 @@ +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.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.document_s, + }; + + Color get color => switch (this) { + MediaFileTypePB.Image => const Color(0xFF5465A1), + MediaFileTypePB.Link => const Color(0xFFA35F94), + MediaFileTypePB.Document => const Color(0xFFBAAC74), + MediaFileTypePB.Archive => const Color(0xFF40AAB8), + MediaFileTypePB.Video => const Color(0xFF5465A1), + MediaFileTypePB.Audio => const Color(0xFF5465A1), + MediaFileTypePB.Text => const Color(0xFF87B3A8), + _ => const Color(0xFF87B3A8), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart index febd6a6749144..437d125f53ec6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart @@ -2,6 +2,7 @@ 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'; @@ -124,6 +125,12 @@ class _AccessoryHoverState extends State { @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( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart index 22a9ce13811fe..7f0d850c209bb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart @@ -1,12 +1,13 @@ 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'; -import '../../cell/editable_cell_builder.dart'; class CellContainer extends StatelessWidget { const CellContainer({ diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 1fd5b8822e851..862751f365165 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -1,3 +1,6 @@ +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'; @@ -5,6 +8,7 @@ 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/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'; @@ -13,7 +17,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/em import 'package:appflowy_popover/appflowy_popover.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 _kBannerActionHeight = 40.0; @@ -270,7 +273,7 @@ class RowActionButton extends StatelessWidget { width: 20, height: 20, icon: const FlowySvg(FlowySvgs.details_horizontal_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), ), ); @@ -286,27 +289,36 @@ class _TitleSkin extends IEditableTextCellSkin { FocusNode focusNode, TextEditingController textEditingController, ) { - return TextField( - controller: textEditingController, - focusNode: focusNode, - autofocus: true, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), - 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, - ), - onChanged: (text) { - if (textEditingController.value.composing.isCollapsed) { - bloc.add(TextCellEvent.updateText(text)); - } + 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, + ), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + bloc.add(TextCellEvent.updateText(text)); + } + }, + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart index 4d58d6fc329ab..415e741ad1603 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -1,15 +1,14 @@ -import 'package:flutter/material.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/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/flowy_error_page.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:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RowDocument extends StatelessWidget { @@ -27,17 +26,22 @@ class RowDocument extends StatelessWidget { return BlocProvider( create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId) ..add(const RowDocumentEvent.initial()), - child: BlocBuilder( + 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) => FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + error: (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), ), - finish: () => RowEditor( + finish: () => _RowEditor( viewPB: state.viewPB!, onIsEmptyChanged: (isEmpty) => context .read() @@ -50,9 +54,8 @@ class RowDocument extends StatelessWidget { } } -class RowEditor extends StatefulWidget { - const RowEditor({ - super.key, +class _RowEditor extends StatelessWidget { + const _RowEditor({ required this.viewPB, this.onIsEmptyChanged, }); @@ -60,36 +63,23 @@ class RowEditor extends StatefulWidget { final ViewPB viewPB; final void Function(bool)? onIsEmptyChanged; - @override - State createState() => _RowEditorState(); -} - -class _RowEditorState extends State { - late final DocumentBloc documentBloc; - - @override - void initState() { - super.initState(); - documentBloc = DocumentBloc(documentId: widget.viewPB.id) - ..add(const DocumentEvent.initial()); - } - - @override - void dispose() { - documentBloc.close(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [BlocProvider.value(value: documentBloc)], + return BlocProvider( + create: (context) => DocumentBloc(documentId: viewPB.id) + ..add(const DocumentEvent.initial()), child: BlocListener( listenWhen: (previous, current) => previous.isDocumentEmpty != current.isDocumentEmpty, - listener: (context, state) { + listener: (_, state) { if (state.isDocumentEmpty != null) { - widget.onIsEmptyChanged?.call(state.isDocumentEmpty!); + 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'); } }, child: BlocBuilder( @@ -101,18 +91,18 @@ class _RowEditorState extends State { final editorState = state.editorState; final error = state.error; if (error != null || editorState == null) { - Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); } - return IntrinsicHeight( - child: Container( - constraints: const BoxConstraints(minHeight: 300), - child: BlocProvider( - create: (context) => ViewInfoBloc(view: widget.viewPB), + return BlocProvider( + create: (context) => ViewInfoBloc(view: viewPB), + child: IntrinsicHeight( + child: Container( + constraints: const BoxConstraints(minHeight: 300), child: AppFlowyEditorPage( shrinkWrap: true, autoFocus: false, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart index 60dc940cea45c..300e84961c381 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:flutter/foundation.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/cell/cell_controller.dart'; @@ -11,18 +14,16 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/header/deskt 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:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_popover/appflowy_popover.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 '../cell/editable_cell_builder.dart'; + import 'accessory/cell_accessory.dart'; /// Display the row properties in a list. Only used in [RowDetailPage]. @@ -128,49 +129,11 @@ class _PropertyCell extends StatefulWidget { class _PropertyCellState extends State<_PropertyCell> { final PopoverController _popoverController = PopoverController(); - final PopoverController _fieldPopoverController = PopoverController(); final ValueNotifier _isFieldHover = ValueNotifier(false); @override Widget build(BuildContext context) { - final dragThumb = MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grab, - child: SizedBox( - width: 16, - height: 30, - child: AppFlowyPopover( - controller: _fieldPopoverController, - constraints: BoxConstraints.loose(const Size(240, 600)), - margin: EdgeInsets.zero, - triggerActions: PopoverTriggerFlags.none, - direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (popoverContext) => FieldEditor( - viewId: widget.fieldController.viewId, - field: widget.fieldController - .getField(widget.cellContext.fieldId)! - .field, - fieldController: widget.fieldController, - ), - child: ValueListenableBuilder( - valueListenable: _isFieldHover, - builder: (_, isHovering, child) => - isHovering ? child! : const SizedBox.shrink(), - child: BlockActionButton( - onTap: () => _fieldPopoverController.show(), - svg: FlowySvgs.drag_element_s, - richMessage: TextSpan( - text: LocaleKeys.grid_rowPage_fieldDragElementTooltip.tr(), - style: context.tooltipTextStyle(), - ), - ), - ), - ), - ), - ); - final cell = widget.cellBuilder.buildStyled( widget.cellContext, EditableCellStyle.desktopRowDetail, @@ -207,52 +170,12 @@ class _PropertyCellState extends State<_PropertyCell> { return ReorderableDragStartListener( index: widget.index, enabled: value, - child: dragThumb, + child: _buildDragHandle(context), ); }, ), const HSpace(4), - 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, - popupBuilder: (popoverContext) => FieldEditor( - viewId: widget.fieldController.viewId, - field: fieldInfo.field, - fieldController: widget.fieldController, - ), - 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: () => _popoverController.show(), - radius: BorderRadius.circular(6), - margin: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - ), - ), - ), - ); - }, - ), + _buildFieldButton(context), const HSpace(8), Expanded(child: gesture), ], @@ -260,6 +183,96 @@ class _PropertyCellState extends State<_PropertyCell> { ), ); } + + 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 { @@ -353,7 +366,7 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { } } -class CreateRowFieldButton extends StatefulWidget { +class CreateRowFieldButton extends StatelessWidget { const CreateRowFieldButton({ super.key, required this.viewId, @@ -363,60 +376,35 @@ class CreateRowFieldButton extends StatefulWidget { final String viewId; final FieldController fieldController; - @override - State createState() => _CreateRowFieldButtonState(); -} - -class _CreateRowFieldButtonState extends State { - final PopoverController popoverController = PopoverController(); - FieldPB? createdField; - @override Widget build(BuildContext context) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(240, 200)), - controller: popoverController, - direction: PopoverDirection.topWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - child: SizedBox( - height: 30, - child: FlowyButton( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - text: FlowyText.medium( - 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: widget.viewId, - ); - result.fold( - (newField) { - createdField = newField; - popoverController.show(); - }, - (r) => Log.error("Failed to create field type option: $r"), - ); - }, - leftIcon: FlowySvg( - FlowySvgs.add_m, - color: Theme.of(context).hintColor, - ), + return SizedBox( + height: 30, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + text: FlowyText.medium( + 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, ), ), - popupBuilder: (BuildContext popoverContext) { - if (createdField == null) { - return const SizedBox.shrink(); - } - return FieldEditor( - viewId: widget.viewId, - field: createdField!, - fieldController: widget.fieldController, - ); - }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart index f7dd93d9bc7fa..87c7002e09e4b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart @@ -204,8 +204,9 @@ class _DatabasePropertyCellState extends State { popupBuilder: (BuildContext context) { return FieldEditor( viewId: widget.viewId, - field: widget.fieldInfo.field, + fieldInfo: widget.fieldInfo, fieldController: widget.fieldController, + isNewField: false, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart index c08b32d9ec139..65b6542d4ffc9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -1,4 +1,3 @@ -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'; @@ -9,14 +8,13 @@ import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.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' hide Log; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -83,9 +81,10 @@ class _DatabaseDocumentPageState extends State { final error = state.error; if (error != null || editorState == null) { Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart index d509aa2f25727..4005bfcfacbed 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart @@ -1,3 +1,5 @@ +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/cell/bloc/text_cell_bloc.dart'; @@ -12,7 +14,6 @@ import 'package:appflowy_popover/appflowy_popover.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 'database_document_title_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart index 5f8bf7ca08aec..f35f0ee8f6036 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart @@ -1,3 +1,5 @@ +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'; @@ -87,6 +89,8 @@ class DatabaseDocumentTitleBloc viewId: view.id, rowCache: databaseController.rowCache, ); + unawaited(rowController.initialize()); + final primaryFieldId = await FieldBackendService.getPrimaryField(viewId: view.id).fold( (primaryField) => primaryField.id, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index cc99dd60c5dfd..53b9084fabfc8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; + 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'; @@ -32,7 +34,6 @@ import 'package:appflowy_editor/appflowy_editor.dart' Position, paragraphNode; 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'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart index 5118620d98852..57b86ce2d48c9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -117,17 +117,19 @@ class DocumentService { required String documentId, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); - return workspace.fold((l) async { - final payload = UploadFileParamsPB( - workspaceId: l.id, - localFilePath: localFilePath, - documentId: documentId, - ); - final result = await DocumentEventUploadFile(payload).send(); - return result; - }, (r) async { - return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); - }); + 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. diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 1827a42bf6f00..de1a230ec7d4d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.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/banner.dart'; @@ -12,7 +14,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/mult import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.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'; @@ -22,7 +24,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart index 205725b81a434..ed1295656d42c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart @@ -4,7 +4,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.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_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -64,7 +65,7 @@ class BlockOptionButton extends StatelessWidget { }, onSelected: (action, controller) { if (action is OptionActionWrapper) { - _onSelectAction(action.inner); + _onSelectAction(context, action.inner); controller.close(); } }, @@ -121,7 +122,7 @@ class BlockOptionButton extends StatelessWidget { ); } - void _onSelectAction(OptionAction action) { + void _onSelectAction(BuildContext context, OptionAction action) { final node = blockComponentContext.node; final transaction = editorState.transaction; switch (action) { @@ -129,10 +130,7 @@ class BlockOptionButton extends StatelessWidget { transaction.deleteNode(node); break; case OptionAction.duplicate: - transaction.insertNode( - node.path.next, - node.copyWith(), - ); + _duplicateBlock(context, transaction, node); break; case OptionAction.turnInto: break; @@ -150,4 +148,102 @@ class BlockOptionButton extends StatelessWidget { } editorState.apply(transaction); } + + void _duplicateBlock( + BuildContext context, + Transaction transaction, + Node node, + ) { + // 1. verify the node integrity + final type = node.type; + final builder = + context.read().renderer.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'); + } + + // 2. duplicate the node + // the _copyBlock will fix the table block + final newNode = _copyBlock(context, node); + + // 3. insert the node to the next of the current node + transaction.insertNode( + node.path.next, + newNode, + ); + } + + Node _copyBlock(BuildContext context, Node node) { + Node copiedNode = node.copyWith(); + + final type = node.type; + final builder = + context.read().renderer.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); + } + } + } + + 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.copyWith()); + } else { + newChildren.add( + tableCellNode('', i, j), + ); + } + } + } + + return node.copyWith( + children: newChildren, + attributes: { + ...node.attributes, + TableBlockKeys.colsLen: colsLen, + TableBlockKeys.rowsLen: rowsLen, + }, + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart index 57ebe69fc6e0a..b779735252f57 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart @@ -6,7 +6,7 @@ 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/common_patterns.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/widgets/dialogs.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart index ba6f01e9088d5..92c19db47b59b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart @@ -1,10 +1,11 @@ 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/home/toast.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'; @@ -59,25 +60,15 @@ class _ErrorBlockComponentWidgetState extends State @override Widget build(BuildContext context) { - Widget child = DecoratedBox( + Widget child = Container( + width: double.infinity, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), - child: FlowyButton( - onTap: () async { - showSnackBarMessage( - context, - LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), - ); - await getIt().setData( - ClipboardServiceData(plainText: jsonEncode(node.toJson())), - ); - }, - text: PlatformExtension.isDesktopOrWeb - ? _buildDesktopErrorBlock(context) - : _buildMobileErrorBlock(context), - ), + child: PlatformExtension.isDesktopOrWeb + ? _buildDesktopErrorBlock(context) + : _buildMobileErrorBlock(context), ); child = Padding( @@ -107,40 +98,61 @@ class _ErrorBlockComponentWidgetState extends State Widget _buildDesktopErrorBlock(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, + child: Row( children: [ - const HSpace(4), + const HSpace(12), FlowyText.regular( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), + LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), ), - const HSpace(4), - FlowyText.regular( - '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', - color: Theme.of(context).hintColor, + const Spacer(), + OutlinedRoundedButton( + text: LocaleKeys.document_errorBlock_copyBlockContent.tr(), + onTap: _copyBlockContent, ), + const HSpace(12), ], ), ); } Widget _buildMobileErrorBlock(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.regular( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), - ), - const VSpace(6), - FlowyText.regular( - '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', - color: Theme.of(context).hintColor, - fontSize: 12.0, - ), - ], + 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( + context, + message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), + ); + + getIt().setData( + ClipboardServiceData(plainText: jsonEncode(node.toJson())), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index 5b1b3aa979040..0e3a83ba7c501 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.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'; @@ -237,7 +238,7 @@ class FileBlockComponentState extends State }, onDragDone: (details) { if (dropManagerState.isDropEnabled) { - insertFileFromLocal(details.files.first.path); + insertFileFromLocal(details.files.first); } }, child: AppFlowyPopover( @@ -359,7 +360,8 @@ class FileBlockComponentState extends State } } - Future insertFileFromLocal(String path) async { + Future insertFileFromLocal(XFile file) async { + final path = file.path; final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; @@ -382,12 +384,11 @@ class FileBlockComponentState extends State // Remove the file block from the drop state manager dropManagerState.remove(FileBlockKeys.type); - final name = Uri.tryParse(path)?.pathSegments.last ?? url; final transaction = editorState.transaction; transaction.updateNode(widget.node, { FileBlockKeys.url: url, FileBlockKeys.urlType: urlType.toIntValue(), - FileBlockKeys.name: name, + FileBlockKeys.name: file.name, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, }); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart index 872f0d61d0636..133a9fb77adca 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -79,8 +79,7 @@ class _FileBlockMenuState extends State { closeOnConfirm: false, builder: (context) { renameContext = context; - - return _RenameTextField( + return FileRenameTextField( nameController: nameController, errorMessage: errorMessage, onSubmitted: _saveName, @@ -146,22 +145,26 @@ class _FileBlockMenuState extends State { } } -class _RenameTextField extends StatefulWidget { - const _RenameTextField({ +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<_RenameTextField> createState() => _RenameTextFieldState(); + State createState() => _FileRenameTextFieldState(); } -class _RenameTextFieldState extends State<_RenameTextField> { +class _FileRenameTextFieldState extends State { @override void initState() { super.initState(); @@ -171,7 +174,9 @@ class _RenameTextFieldState extends State<_RenameTextField> { @override void dispose() { widget.errorMessage.removeListener(_setState); - widget.nameController.dispose(); + if (widget.disposeController) { + widget.nameController.dispose(); + } super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart index 4baa8506fea21..e1762ba8260f4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.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'; @@ -21,7 +22,7 @@ class FileUploadMenu extends StatefulWidget { required this.onInsertNetworkFile, }); - final void Function(String path) onInsertLocalFile; + final void Function(XFile file) onInsertLocalFile; final void Function(String url) onInsertNetworkFile; @override @@ -39,9 +40,7 @@ class _FileUploadMenuState extends State { mainAxisSize: MainAxisSize.min, children: [ TabBar( - onTap: (value) => setState(() { - currentTab = value; - }), + onTap: (value) => setState(() => currentTab = value), isScrollable: true, padding: EdgeInsets.zero, overlayColor: WidgetStatePropertyAll( @@ -61,9 +60,9 @@ class _FileUploadMenuState extends State { const Divider(height: 4), if (currentTab == 0) ...[ _FileUploadLocal( - onFilePicked: (path) { - if (path != null) { - widget.onInsertLocalFile(path); + onFilePicked: (file) { + if (file != null) { + widget.onInsertLocalFile(file); } }, ), @@ -98,7 +97,7 @@ class _Tab extends StatelessWidget { class _FileUploadLocal extends StatefulWidget { const _FileUploadLocal({required this.onFilePicked}); - final void Function(String?) onFilePicked; + final void Function(XFile?) onFilePicked; @override State<_FileUploadLocal> createState() => _FileUploadLocalState(); @@ -112,12 +111,34 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { final constraints = PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + if (PlatformExtension.isMobile) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: SizedBox( + height: 32, + width: 300, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(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(4), child: DropTarget( onDragEntered: (_) => setState(() => isDragging = true), onDragExited: (_) => setState(() => isDragging = false), - onDragDone: (details) => widget.onFilePicked(details.files.first.path), + onDragDone: (details) => widget.onFilePicked(details.files.first), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -181,7 +202,9 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { Future _uploadFile(BuildContext context) async { final result = await getIt().pickFiles(dialogTitle: ''); - widget.onFilePicked(result?.files.first.path); + final file = + result?.files.isNotEmpty ?? false ? result?.files.first.xFile : null; + widget.onFilePicked(file); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index f4066a94f2c28..5be1234e08564 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -1,13 +1,28 @@ +import 'dart:convert'; 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: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_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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/platform_extension.dart'; import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; Future saveFileToLocalStorage(String localFilePath) async { @@ -36,21 +51,144 @@ Future saveFileToLocalStorage(String localFilePath) async { Future<(String? path, String? errorMessage)> saveFileToCloudStorage( String localFilePath, - String documentId, -) async { + 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) => (s.url, null), + (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, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); + 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 ([ + MediaUploadTypePB.NetworkMedia, + MediaUploadTypePB.LocalMedia, + ].contains(file.uploadType)) { + /// When the file is a network file or a local file, we can directly open the file. + await afLaunchUrl(Uri.parse(file.url)); + } else { + if (userProfile == null) { + return showSnapBar( + context, + "Failed to download file, could not find user token", + ); + } + + final uri = Uri.parse(file.url); + final token = jsonDecode(userProfile.token)['access_token']; + + if (PlatformExtension.isMobile) { + onDownloadBegin?.call(); + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final tempFile = File(uri.pathSegments.last); + await FilePicker().saveFile( + fileName: p.basename(tempFile.path), + bytes: response.bodyBytes, + ); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + + onDownloadEnd?.call(); + } else { + final savePath = await FilePicker().saveFile(fileName: file.name); + if (savePath == null) { + return; + } + + 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(), + ); + } + } + } +} + +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?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + 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); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index 08ad0a9ac988d..b01b5a6939d97 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -11,7 +11,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/comm 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/common_patterns.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'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart index e7b818ead7700..efa5721382fc3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:flutter/widgets.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'; @@ -13,6 +11,7 @@ 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; @@ -61,8 +60,11 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( return (s.url, null); }, (err) { + final message = Platform.isIOS + ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() + : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); if (err.isStorageLimitExceeded) { - return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); + return (null, message); } else { return (null, err.msg); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart index fd9e1f51692a8..9f6b10cf3c725 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart @@ -10,7 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/imag 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/common_patterns.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'; @@ -231,7 +231,7 @@ class _ImageBrowserLayoutState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( - FlowySvgs.import_s, + FlowySvgs.download_s, size: Size.square(28), ), const HSpace(12), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart index 4e1cfd1fcb358..57e8c1142f8da 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -1,5 +1,7 @@ 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/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; @@ -10,7 +12,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/comm 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/common_patterns.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'; @@ -22,7 +24,6 @@ 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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart index 6c4a8dcfd34d8..a81abf368bd12 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.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'; @@ -9,7 +11,6 @@ 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 'widgets/embed_image_url_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart index 894dde0442395..6d7d0860ef1a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart @@ -245,12 +245,12 @@ class _MobileToolbarState extends State<_MobileToolbar> children: [ const Divider( height: 0.5, - color: Color(0xFFEDEDED), + color: Color(0x7FEDEDED), ), _buildToolbar(context), const Divider( height: 0.5, - color: Color(0xFFEDEDED), + color: Color(0x7FEDEDED), ), _buildMenuOrSpacer(context), ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart index 014889261fb93..12e7d1bef7a9c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart @@ -135,7 +135,7 @@ class _AppFlowyMobileToolbarIconItemState } void _rebuild() { - if (!context.mounted) { + if (!mounted) { return; } setState(() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart index 0c3868a50a25f..09ea9faa859da 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart @@ -264,7 +264,13 @@ final referencedDocSlashMenuItem = SelectionMenuItem( isSelected: isSelected, style: style, ), - keywords: ['page', 'notes', 'referenced page', 'referenced document'], + keywords: [ + 'page', + 'notes', + 'referenced page', + 'referenced document', + 'link to page', + ], handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart index 0dabe397bc9b5..da7ce92106b1b 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/me 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/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; @@ -18,7 +19,6 @@ 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/dialog/styled_dialogs.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; // const _channel = "InlinePageReference"; @@ -177,9 +177,8 @@ class InlinePageReferenceService extends InlineActionsDelegate { if (context.mounted) { return Dialogs.show( context, - child: FlowyErrorPage.message( - e.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: AppFlowyErrorPage( + error: e, ), ); } diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index baa585a88a9fb..fb9cd9f226449 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -13,9 +13,6 @@ const _imgUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); -const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; -final imgExtensionRegex = RegExp(_imgExtensionPattern); - /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following video extensions: diff --git a/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart new file mode 100644 index 0000000000000..418dd47f3d5b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart @@ -0,0 +1,29 @@ +/// 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); diff --git a/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart index e6ace027fa1fb..0de43a84e848a 100644 --- a/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart @@ -33,6 +33,12 @@ 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. @@ -569,7 +575,7 @@ class _CheckedPopupMenuItemState } } -class _PopupMenu extends StatelessWidget { +class _PopupMenu extends StatefulWidget { const _PopupMenu({ super.key, required this.itemKeys, @@ -585,10 +591,15 @@ class _PopupMenu extends StatelessWidget { 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 / - (route.items.length + + (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); @@ -597,16 +608,16 @@ class _PopupMenu extends StatelessWidget { ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); - for (int i = 0; i < route.items.length; i += 1) { + 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: route.animation!, + parent: widget.route.animation!, curve: Interval(start, end), ); - Widget item = route.items[i]; - if (route.initialValue != null && - route.items[i].represents(route.initialValue)) { + 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, @@ -615,10 +626,10 @@ class _PopupMenu extends StatelessWidget { children.add( _MenuItem( onLayout: (Size size) { - route.itemSizes[i] = size; + widget.route.itemSizes[i] = size; }, child: FadeTransition( - key: itemKeys[i], + key: widget.itemKeys[i], opacity: opacity, child: item, ), @@ -630,10 +641,10 @@ class _PopupMenu extends StatelessWidget { 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 * route.items.length)); + CurveTween(curve: Interval(0.0, unit * widget.route.items.length)); final Widget child = ConstrainedBox( - constraints: constraints ?? + constraints: widget.constraints ?? const BoxConstraints( minWidth: _kMenuMinWidth, maxWidth: _kMenuMaxWidth, @@ -644,7 +655,7 @@ class _PopupMenu extends StatelessWidget { scopesRoute: true, namesRoute: true, explicitChildNodes: true, - label: semanticLabel, + label: widget.semanticLabel, child: SingleChildScrollView( padding: const EdgeInsets.symmetric( vertical: _kMenuVerticalPadding, @@ -656,28 +667,28 @@ class _PopupMenu extends StatelessWidget { ); return AnimatedBuilder( - animation: route.animation!, + animation: widget.route.animation!, builder: (BuildContext context, Widget? child) { return FadeTransition( - opacity: opacity.animate(route.animation!), + opacity: opacity.animate(widget.route.animation!), child: Material( - shape: route.shape ?? popupMenuTheme.shape ?? defaults.shape, - color: route.color ?? popupMenuTheme.color ?? defaults.color, - clipBehavior: clipBehavior, + shape: widget.route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: widget.route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: widget.clipBehavior, type: MaterialType.card, - elevation: route.elevation ?? + elevation: widget.route.elevation ?? popupMenuTheme.elevation ?? defaults.elevation!, - shadowColor: route.shadowColor ?? + shadowColor: widget.route.shadowColor ?? popupMenuTheme.shadowColor ?? defaults.shadowColor, - surfaceTintColor: route.surfaceTintColor ?? + surfaceTintColor: widget.route.surfaceTintColor ?? popupMenuTheme.surfaceTintColor ?? defaults.surfaceTintColor, child: Align( alignment: AlignmentDirectional.topEnd, - widthFactor: width.evaluate(route.animation!), - heightFactor: height.evaluate(route.animation!), + widthFactor: width.evaluate(widget.route.animation!), + heightFactor: height.evaluate(widget.route.animation!), child: child, ), ), @@ -686,6 +697,21 @@ class _PopupMenu extends StatelessWidget { 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. @@ -937,7 +963,9 @@ class _PopupMenuRoute extends PopupRoute { scrollTo(selectedItemIndex); } + _kPopupMenuKey ??= GlobalKey<_PopupMenuState>(); final Widget menu = _PopupMenu( + key: _kPopupMenuKey, route: this, itemKeys: itemKeys, semanticLabel: semanticLabel, @@ -1478,7 +1506,7 @@ class PopupMenuButtonState extends State> { if (items.isNotEmpty) { var popUpAnimationStyle = widget.popUpAnimationStyle; if (popUpAnimationStyle == null && - Theme.of(context).platform == TargetPlatform.iOS) { + defaultTargetPlatform == TargetPlatform.iOS) { popUpAnimationStyle = AnimationStyle( curve: Curves.easeInOut, duration: const Duration(milliseconds: 300), @@ -1526,7 +1554,7 @@ class PopupMenuButtonState extends State> { if (widget.child != null) { return AnimatedGestureDetector( - scaleFactor: 0.99, + scaleFactor: 0.95, onTapUp: widget.enabled ? showButtonMenu : null, child: widget.child!, ); @@ -1607,3 +1635,12 @@ class _PopupMenuDefaultsM3 extends PopupMenuThemeData { 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); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 60b18fd8d61dc..3741380567c84 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -14,6 +14,7 @@ import 'deps_resolver.dart'; import 'entry_point.dart'; import 'launch_configuration.dart'; import 'plugin/plugin.dart'; +import 'tasks/file_storage_task.dart'; import 'tasks/prelude.dart'; final getIt = GetIt.instance; @@ -126,6 +127,7 @@ class FlowyRunner { InitRustSDKTask(customApplicationPath: applicationDataDirectory), // Load Plugins, like document, grid ... const PluginLoadTask(), + const FileStorageTask(), // init the app widget // ignore in test mode diff --git a/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart new file mode 100644 index 0000000000000..ffa331f9be2e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart @@ -0,0 +1,148 @@ +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 { + context.getIt.registerSingleton( + FileStorageService(), + dispose: (service) async => service.dispose(), + ); + } + + @override + Future dispose() async {} +} + +class FileStorageService { + FileStorageService() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) { + final fileProgress = FileProgress.fromJsonString(event); + if (fileProgress != null) { + Log.debug( + "Upload progress: file: ${fileProgress.fileUrl} ${fileProgress.progress}", + ); + final notifier = _notifierList[fileProgress.fileUrl]; + if (notifier != null) { + notifier.value = fileProgress; + } + } + }, + ); + + 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(); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index eebb8df1cde90..0c32a8f6ac3ad 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + 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'; @@ -32,8 +35,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/m import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.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'; diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index c5e9d7e132bd3..24f53e48e80d9 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -463,7 +463,7 @@ class ReminderUpdate { ? scheduledAt!.isBefore(DateTime.now()) : a.isAck; - final meta = a.meta; + final meta = {...a.meta}; if (includeTime != a.includeTime) { meta[ReminderMetaKeys.includeTime] = includeTime.toString(); } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index cbec8235397e8..5a75a4df3edf4 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; - 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'; @@ -9,6 +7,7 @@ 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( @@ -292,4 +291,9 @@ class UserBackendService implements IUserBackendService { return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(request).send(); } + + // NOTE: This function is irreversible and will delete the current user's account. + static Future> deleteCurrentAccount() { + return UserEventDeleteAccount().send(); + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 7c023befeaba7..0d39757201319 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -12,7 +12,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DesktopSignInScreen extends StatelessWidget { - const DesktopSignInScreen({super.key}); + const DesktopSignInScreen({ + super.key, + }); @override Widget build(BuildContext context) { @@ -20,38 +22,32 @@ class DesktopSignInScreen extends StatelessWidget { return BlocBuilder( builder: (context, state) { return Scaffold( - appBar: PreferredSize( - preferredSize: - Size.fromHeight(PlatformExtension.isWindows ? 40 : 60), - child: PlatformExtension.isWindows - ? const WindowTitleBar() - : const MoveWindowDetector(), - ), + appBar: _buildAppBar(), body: Center( child: AuthFormContainer( children: [ + const Spacer(), + const VSpace(20), + + // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), logoSize: const Size(60, 60), ), const VSpace(20), + // magic link sign in const SignInWithMagicLinkButtons(), - - // third-party sign in. const VSpace(20), + // third-party sign in. if (isAuthEnabled) ...[ const _OrDivider(), const VSpace(20), const ThirdPartySignInButtons(), + const VSpace(20), ], - const VSpace(20), - - // anonymous sign in - const SignInAnonymousButtonV2(), - const VSpace(16), // sign in agreement const SignInAgreement(), @@ -64,6 +60,12 @@ class DesktopSignInScreen extends StatelessWidget { ) : const VSpace(indicatorMinHeight), const VSpace(20), + + const Spacer(), + + // anonymous sign in + const SignInAnonymousButtonV2(), + const VSpace(16), ], ), ), @@ -71,6 +73,15 @@ class DesktopSignInScreen extends StatelessWidget { }, ); } + + PreferredSize _buildAppBar() { + return PreferredSize( + preferredSize: Size.fromHeight(PlatformExtension.isWindows ? 40 : 60), + child: PlatformExtension.isWindows + ? const WindowTitleBar() + : const MoveWindowDetector(), + ); + } } class _OrDivider extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart index d95f3ecbcf64e..30ff0addd9e95 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart @@ -21,10 +21,12 @@ class SignInWithMagicLinkButtons extends StatefulWidget { class _SignInWithMagicLinkButtonsState extends State { final controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); @override void dispose() { controller.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -37,6 +39,7 @@ class _SignInWithMagicLinkButtonsState height: PlatformExtension.isMobile ? 38.0 : 48.0, child: FlowyTextField( autoFocus: false, + focusNode: _focusNode, controller: controller, borderRadius: BorderRadius.circular(4.0), hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), @@ -49,6 +52,7 @@ class _SignInWithMagicLinkButtonsState ), keyboardType: TextInputType.emailAddress, onSubmitted: (_) => _sendMagicLink(context, controller.text), + onTapOutside: (_) => _focusNode.unfocus(), ), ), const VSpace(12), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index 58509aee5a005..5146e29962740 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -2,16 +2,18 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -class MobileSignInOrLogoutButton extends StatelessWidget { - const MobileSignInOrLogoutButton({ +class MobileLogoutButton extends StatelessWidget { + const MobileLogoutButton({ super.key, this.icon, - required this.labelText, + required this.text, + this.textColor, required this.onPressed, }); final FlowySvgData? icon; - final String labelText; + final String text; + final Color? textColor; final VoidCallback onPressed; @override @@ -26,7 +28,7 @@ class MobileSignInOrLogoutButton extends StatelessWidget { Radius.circular(4), ), border: Border.all( - color: style.colorScheme.outline, + color: textColor ?? style.colorScheme.outline, width: 0.5, ), ), @@ -52,9 +54,10 @@ class MobileSignInOrLogoutButton extends StatelessWidget { const HSpace(8), ], FlowyText( - labelText, + text, fontSize: 14.0, fontWeight: FontWeight.w400, + color: textColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart index c51634bcf5d5c..36d83ea3bcd3a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart @@ -1,7 +1,9 @@ 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/user/presentation/widgets/widgets.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'; @@ -12,6 +14,21 @@ enum ThirdPartySignInButtonType { 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: @@ -135,3 +152,69 @@ class MobileThirdPartySignInButton extends StatelessWidget { ); } } + + +class DesktopSignInButton extends StatelessWidget { + const DesktopSignInButton({ + super.key, + required this.type, + required this.onPressed, + }); + + final ThirdPartySignInButtonType type; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + // In desktop, the width of button is limited by [AuthFormContainer] + return SizedBox( + height: 48, + width: AuthFormContainer.width, + child: OutlinedButton.icon( + // In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left. + icon: Container( + width: AuthFormContainer.width / 4, + alignment: Alignment.centerRight, + child: SizedBox( + // Some icons are not square, so we just use a fixed width here. + width: 24, + child: FlowySvg( + type.icon, + blendMode: type.blendMode, + ), + ), + ), + label: Container( + padding: const EdgeInsets.only(left: 8), + alignment: Alignment.centerLeft, + child: FlowyText( + type.labelText, + fontSize: 14, + ), + ), + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.hovered)) { + return style.colorScheme.onSecondaryContainer; + } + return null; + }, + ), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: Corners.s6Border, + ), + ), + side: WidgetStateProperty.all( + BorderSide( + color: style.dividerColor, + ), + ), + ), + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart index 6972eb2105208..bd84dfbc1c87a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart @@ -1,22 +1,21 @@ import 'dart:io'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy/user/presentation/presentation.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/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'third_party_sign_in_button.dart'; +typedef _SignInCallback = void Function(ThirdPartySignInButtonType signInType); + @visibleForTesting const Key signInWithGoogleButtonKey = Key('signInWithGoogleButton'); -class ThirdPartySignInButtons extends StatefulWidget { +class ThirdPartySignInButtons extends StatelessWidget { /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin const ThirdPartySignInButtons({ super.key, @@ -25,196 +24,176 @@ class ThirdPartySignInButtons extends StatefulWidget { final bool expanded; - @override - State createState() => - _ThirdPartySignInButtonsState(); -} - -class _ThirdPartySignInButtonsState extends State { - bool expanded = false; - - @override - void initState() { - super.initState(); - - expanded = widget.expanded; - } - @override Widget build(BuildContext context) { if (PlatformExtension.isDesktopOrWeb) { - const padding = 16.0; - return Column( - children: [ - _DesktopSignInButton( - key: signInWithGoogleButtonKey, - type: ThirdPartySignInButtonType.google, - onPressed: () { - _signInWithGoogle(context); - }, - ), - const VSpace(padding), - _DesktopSignInButton( - type: ThirdPartySignInButtonType.github, - onPressed: () { - _signInWithGithub(context); - }, - ), - const VSpace(padding), - _DesktopSignInButton( - type: ThirdPartySignInButtonType.discord, - onPressed: () { - _signInWithDiscord(context); - }, - ), - ], + return _DesktopThirdPartySignIn( + onSignIn: (type) => _signIn(context, type.provider), ); } else { - const padding = 8.0; - return BlocBuilder( - builder: (context, state) { - return Column( - children: [ - if (Platform.isIOS) ...[ - MobileThirdPartySignInButton( - type: ThirdPartySignInButtonType.apple, - onPressed: () { - _signInWithApple(context); - }, - ), - const VSpace(padding), - ], - MobileThirdPartySignInButton( - type: ThirdPartySignInButtonType.google, - onPressed: () { - _signInWithGoogle(context); - }, - ), - if (expanded) ...[ - const VSpace(padding), - MobileThirdPartySignInButton( - type: ThirdPartySignInButtonType.github, - onPressed: () { - _signInWithGithub(context); - }, - ), - const VSpace(padding), - MobileThirdPartySignInButton( - type: ThirdPartySignInButtonType.discord, - onPressed: () { - _signInWithDiscord(context); - }, - ), - ], - if (!expanded) ...[ - const VSpace(padding * 2), - GestureDetector( - onTap: () { - setState(() { - expanded = !expanded; - }); - }, - child: FlowyText( - LocaleKeys.signIn_continueAnotherWay.tr(), - color: Theme.of(context).colorScheme.onSurface, - decoration: TextDecoration.underline, - fontSize: 14, - ), - ), - ], - ], - ); - }, + return _MobileThirdPartySignIn( + isExpanded: expanded, + onSignIn: (type) => _signIn(context, type.provider), ); } } - void _signInWithApple(BuildContext context) { + void _signIn(BuildContext context, String provider) { context.read().add( - const SignInEvent.signedInWithOAuth('apple'), + SignInEvent.signedInWithOAuth(provider), ); } +} - void _signInWithGoogle(BuildContext context) { - context.read().add( - const SignInEvent.signedInWithOAuth('google'), - ); +class _DesktopThirdPartySignIn extends StatefulWidget { + const _DesktopThirdPartySignIn({ + required this.onSignIn, + }); + + final _SignInCallback onSignIn; + + @override + State<_DesktopThirdPartySignIn> createState() => + _DesktopThirdPartySignInState(); +} + +class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { + static const padding = 12.0; + + bool isExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DesktopSignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); } - void _signInWithGithub(BuildContext context) { - context - .read() - .add(const SignInEvent.signedInWithOAuth('github')); + List _buildExpandedButtons() { + return [ + const VSpace(padding * 1.5), + DesktopSignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; } - void _signInWithDiscord(BuildContext context) { - context - .read() - .add(const SignInEvent.signedInWithOAuth('discord')); + List _buildCollapsedButtons() { + return [ + const VSpace(padding), + GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, + ), + ), + ]; } } -class _DesktopSignInButton extends StatelessWidget { - const _DesktopSignInButton({ - super.key, - required this.type, - required this.onPressed, +class _MobileThirdPartySignIn extends StatefulWidget { + const _MobileThirdPartySignIn({ + required this.isExpanded, + required this.onSignIn, }); - final ThirdPartySignInButtonType type; - final VoidCallback onPressed; + 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) { - final style = Theme.of(context); - // In desktop, the width of button is limited by [AuthFormContainer] - return SizedBox( - height: 48, - width: AuthFormContainer.width, - child: OutlinedButton.icon( - // In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left. - icon: Container( - width: AuthFormContainer.width / 4, - alignment: Alignment.centerRight, - child: SizedBox( - // Some icons are not square, so we just use a fixed width here. - width: 24, - child: FlowySvg( - type.icon, - blendMode: type.blendMode, - ), - ), - ), - label: Container( - padding: const EdgeInsets.only(left: 8), - alignment: Alignment.centerLeft, - child: FlowyText( - type.labelText, - fontSize: 14, + return Column( + children: [ + // only display apple sign in button on iOS + if (Platform.isIOS) ...[ + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), + const VSpace(padding), + ], + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), - style: ButtonStyle( - overlayColor: WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.hovered)) { - return style.colorScheme.onSecondaryContainer; - } - return null; - }, - ), - shape: WidgetStateProperty.all( - const RoundedRectangleBorder( - borderRadius: Corners.s6Border, - ), - ), - side: WidgetStateProperty.all( - BorderSide( - color: style.dividerColor, - ), - ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + return [ + const VSpace(padding * 2), + GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, ), - onPressed: onPressed, ), - ); + ]; } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart index 2be9ed6484fc5..af5e7367e5611 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart @@ -1,10 +1,10 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -33,9 +33,10 @@ class DesktopWorkspaceStartScreen extends StatelessWidget { Widget _renderBody(WorkspaceState state) { final body = state.successOrFailure.fold( (_) => _renderList(state.workspaces), - (error) => FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), ), ); return body; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart index b3c1f1cd0afe9..59b61aa54b9f9 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart @@ -1,10 +1,10 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -129,9 +129,10 @@ class _MobileWorkspaceStartScreenState ); }, (error) { - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); }, ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart index 9927ee24570c4..8ce09a5b7f08c 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; class AuthFormContainer extends StatelessWidget { - const AuthFormContainer({super.key, required this.children}); + const AuthFormContainer({ + super.key, + required this.children, + }); final List children; @@ -11,14 +14,10 @@ class AuthFormContainer extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: width, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: children, - ), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: children, ), ); } diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 4ca63aa4aed0e..12d06da7b2387 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -24,6 +24,7 @@ extension FieldTypeExtension on FieldType { 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(), }; @@ -42,6 +43,7 @@ extension FieldTypeExtension on FieldType { 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(), }; @@ -66,6 +68,7 @@ extension FieldTypeExtension on FieldType { FieldType.Summary => const Color(0xFFBECCFF), FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFFBECCFF), + FieldType.Media => const Color(0xFFFCBEBE), _ => throw UnimplementedError(), }; diff --git a/frontend/appflowy_flutter/lib/util/xfile_ext.dart b/frontend/appflowy_flutter/lib/util/xfile_ext.dart new file mode 100644 index 0000000000000..593ea337c1a3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/xfile_ext.dart @@ -0,0 +1,109 @@ +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; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart index 863482cca8756..8d2a65c0298bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart @@ -1,11 +1,11 @@ import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; -const _localFmt = 'M/d/y'; -const _usFmt = 'y/M/d'; -const _isoFmt = 'y-M-d'; -const _friendlyFmt = 'MMM d, y'; -const _dmyFmt = 'd/M/y'; +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 => DateFormat(_toFormat[this] ?? _friendlyFmt); diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart index e8265703ef4bf..feb4afe7e88c6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -117,6 +117,7 @@ class SidebarPlanBloc extends Bloc { userProfile: userProfile, ), ); + _checkWorkspaceUsage(); }, updateWorkspaceUsage: (WorkspaceUsagePB usage) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index 405360f55b7a9..d1e1b2c221fdd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -1,8 +1,16 @@ -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + 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/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'; @@ -10,9 +18,10 @@ 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:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; @@ -47,6 +56,17 @@ class HomeStack extends StatelessWidget { builder: (context, state) { return Column( children: [ + if (Platform.isWindows) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + WindowTitleBar( + leftChildren: [ + _buildToggleMenuButton(context), + ], + ), + ], + ), Padding( padding: EdgeInsets.only(left: layout.menuSpacing), child: TabsManager(pageController: pageController), @@ -73,6 +93,47 @@ class HomeStack extends StatelessWidget { ), ); } + + Widget _buildToggleMenuButton(BuildContext context) { + if (!context.read().state.isMenuCollapsed) { + 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: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + child: FlowyHover( + child: Container( + width: 24, + padding: const EdgeInsets.all(4), + child: const RotatedBox( + quarterTurns: 2, + child: FlowySvg(FlowySvgs.hide_menu_s), + ), + ), + ), + ), + ); + } } class PageStack extends StatefulWidget { @@ -230,7 +291,6 @@ class PageManager { child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (_, __, child) => MoveWindowDetector( - showTitleBar: true, child: HomeTopBar(layout: layout), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart index fa86082e74862..ff33a79683381 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart @@ -9,6 +9,7 @@ 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'; @@ -26,11 +27,26 @@ class SidebarFooter extends StatelessWidget { return const SidebarToast(); }, ), - const SidebarTemplateButton(), - const SidebarTrashButton(), + 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 { @@ -39,12 +55,9 @@ class SidebarTemplateButton extends StatelessWidget { @override Widget build(BuildContext context) { return SidebarFooterButton( - leftIconSize: const Size.square(24.0), - leftIcon: const Padding( - padding: EdgeInsets.all(2.0), - child: FlowySvg( - FlowySvgs.icon_template_s, - ), + leftIconSize: const Size.square(18.0), + leftIcon: const FlowySvg( + FlowySvgs.icon_template_s, ), text: LocaleKeys.template_label.tr(), onTap: () => afLaunchUrlString('https://appflowy.io/templates'), @@ -61,9 +74,9 @@ class SidebarTrashButton extends StatelessWidget { valueListenable: getIt().notifier, builder: (context, value, child) { return SidebarFooterButton( - leftIconSize: const Size.square(24.0), + leftIconSize: const Size.square(18.0), leftIcon: const FlowySvg( - FlowySvgs.sidebar_footer_trash_m, + FlowySvgs.icon_delete_s, ), text: LocaleKeys.trash_text.tr(), onTap: () { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart index f83e1dd046365..cbb969d191d11 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart @@ -26,11 +26,15 @@ class SidebarFooterButton extends StatelessWidget { child: FlowyButton( leftIcon: leftIcon, leftIconSize: leftIconSize, - iconPadding: 8.0, margin: const EdgeInsets.all(4.0), - text: FlowyText.regular( - text, - lineHeight: 1.15, + expandText: false, + text: Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FlowyText( + text, + fontWeight: FontWeight.w400, + figmaLineHeight: 18.0, + ), ), onTap: onTap, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart index c25314b169f20..5d581a913696f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -15,6 +15,7 @@ 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/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarToast extends StatelessWidget { @@ -39,13 +40,13 @@ class SidebarToast extends StatelessWidget { storageLimitHit: () => PlanIndicator( planName: SubscriptionPlanPB.Free.label, text: LocaleKeys.sideBar_upgradeToPro.tr(), - onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.Pro), + onTap: () => _handleOnTap(context, SubscriptionPlanPB.Pro), reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), ), aiMaxiLimitHit: () => PlanIndicator( planName: SubscriptionPlanPB.AiMax.label, text: LocaleKeys.sideBar_upgradeToAIMax.tr(), - onTap: () => _hanldeOnTap(context, SubscriptionPlanPB.AiMax), + onTap: () => _handleOnTap(context, SubscriptionPlanPB.AiMax), reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(), ), ); @@ -61,12 +62,12 @@ class SidebarToast extends StatelessWidget { LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), onConfirm: () { WidgetsBinding.instance.addPostFrameCallback( - (_) => _hanldeOnTap(context, SubscriptionPlanPB.Pro), + (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), ); }, ); - void _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) { + void _handleOnTap(BuildContext context, SubscriptionPlanPB plan) { final userProfile = context.read().state.userProfile; if (userProfile == null) { return Log.error( @@ -91,9 +92,16 @@ class SidebarToast extends StatelessWidget { SettingsPage.plan, ); } else { - final message = plan == SubscriptionPlanPB.AiMax - ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr() - : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr(); + 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, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index bfe836ba46ba3..586120000a941 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -321,18 +321,23 @@ class _ConfirmPopupState extends State { Navigator.of(context).pop(); } }, - child: Padding( + child: Container( padding: const EdgeInsets.symmetric( vertical: 20.0, horizontal: 20.0, ), + color: PlatformExtension.isDesktop + ? null + : Theme.of(context).colorScheme.surface, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), - const VSpace(6), - _buildDescription(), + if (widget.description.isNotEmpty) ...[ + const VSpace(6), + _buildDescription(), + ], if (widget.child != null) ...[ const VSpace(12), widget.child!, @@ -373,6 +378,10 @@ class _ConfirmPopupState extends State { } Widget _buildDescription() { + if (widget.description.isEmpty) { + return const SizedBox.shrink(); + } + return FlowyText.regular( widget.description, fontSize: 16.0, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart index e5897d7470820..9d6e41ca08489 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/material.dart' hide Icon; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; @@ -14,7 +16,6 @@ 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_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart' hide Icon; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarSpaceHeader extends StatefulWidget { @@ -63,6 +64,7 @@ class _SidebarSpaceHeaderState extends State { .read() .add(SpaceEvent.expand(widget.space, !widget.isExpanded)), child: FlowyHoverContainer( + isHovering: isHovered, style: style, child: _buildSpaceName(), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart index 07870a152d7f3..51f3e0b766cac 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.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'; @@ -74,8 +75,10 @@ class _TabsManagerState extends State dividerColor: Colors.transparent, isScrollable: true, controller: _controller, - onTap: (newIndex) => - context.read().add(TabsEvent.selectTab(newIndex)), + onTap: (newIndex) { + AFFocusManager.of(context).notifyLoseFocus(); + context.read().add(TabsEvent.selectTab(newIndex)); + }, tabs: state.pageManagers .map( (pm) => FlowyTab( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart new file mode 100644 index 0000000000000..7b337d8d789b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart @@ -0,0 +1,3 @@ +export 'account_deletion.dart'; +export 'account_sign_in_out.dart'; +export 'account_user_profile.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart new file mode 100644 index 0000000000000..f59053b55f271 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -0,0 +1,267 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.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/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_editor/appflowy_editor.dart' show PlatformExtension; +import 'package:appflowy_result/appflowy_result.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:toastification/toastification.dart'; + +const _confirmText = 'DELETE MY ACCOUNT'; +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 textColor = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF4F4F4F) + : const Color(0xFFB0B0B0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.button_deleteAccount.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 21.0, + color: textColor, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), + fontSize: 12.0, + figmaLineHeight: 13.0, + maxLines: 2, + color: textColor, + ), + ), + const HSpace(32), + FlowyTextButton( + LocaleKeys.button_deleteAccount.tr(), + constraints: const BoxConstraints(minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), + fillColor: Colors.transparent, + radius: Corners.s8Border, + hoverColor: Theme.of(context).colorScheme.error.withOpacity(0.1), + fontColor: Theme.of(context).colorScheme.error, + fontHoverColor: Colors.white, + fontSize: 12, + isDangerous: true, + lineHeight: 18.0 / 12.0, + onPressed: () { + 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: () { + Navigator.of(context).popUntil((route) { + if (route.settings.name == '/') { + return true; + } + return false; + }); + }, + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +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: _confirmText, + 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); +} + +Future deleteMyAccount( + BuildContext context, + String confirmText, + bool isChecked, { + VoidCallback? onSuccess, + VoidCallback? onFailure, +}) async { + final bottomPadding = PlatformExtension.isMobile + ? MediaQuery.of(context).viewInsets.bottom + : 0.0; + + if (!isChecked) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_checkToConfirmError + .tr(), + ); + return; + } + + if (!context.mounted) { + return; + } + + if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { + showToastNotification( + context, + 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( + context, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_deleteAccountSuccess + .tr(), + bottomPadding: bottomPadding, + ); + + // delay 1 second to make sure the toast notification is shown + Future.delayed(const Duration(seconds: 1), () async { + onSuccess?.call(); + + // restart the application + await getIt().signOut(); + await runAppFlowy(); + }); + }, + (f) { + Log.error('account deletion failed, error: $f'); + + loading.stop(); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: bottomPadding, + message: f.msg, + ); + + onFailure?.call(); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart new file mode 100644 index 0000000000000..d897a7931bd3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -0,0 +1,185 @@ +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/prelude.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.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: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 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 PrimaryRoundedButton( + text: signIn + ? LocaleKeys.settings_accountPage_login_loginLabel.tr() + : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () => + signIn ? _showSignInDialog(context) : _showLogoutDialog(context), + ); + } + + void _showLogoutDialog(BuildContext context) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + description: userProfile.encryptionType == EncryptionTypePB.Symmetric + ? LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr() + : LocaleKeys.settings_menu_logoutPrompt.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 _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 SignInWithMagicLinkButtons(), + if (isAuthEnabled) ...[ + const VSpace(20), + const _OrDivider(), + const VSpace(10), + SettingThirdPartyLogin( + didLogin: () {}, + ), // TODO: Pass onAction + ], + ], + ), + ), + ), + ), + ); + } +} + +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)), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart new file mode 100644 index 0000000000000..90c4b6c2ba30b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -0,0 +1,179 @@ +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/workspace/presentation/widgets/user_avatar.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'; + +// 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(), + child: UserAvatar( + iconUrl: widget.iconUrl, + name: widget.name, + size: 48, + fontSize: 20, + isHovering: isHovering, + ), + ), + ), + ); + } + + Widget _buildNameDisplay() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText.medium( + widget.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => isEditing = true), + child: const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg(FlowySvgs.edit_s), + ), + ), + ), + ], + ), + ); + } + + 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( + 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); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index d2ac61e4724aa..bb0e4aac9f317 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -1,25 +1,15 @@ 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/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/prelude.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.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_input_field.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsAccountView extends StatefulWidget { @@ -56,10 +46,11 @@ class _SettingsAccountViewState extends State { return SettingsBody( title: LocaleKeys.settings_accountPage_title.tr(), children: [ + // user profile SettingsCategory( title: LocaleKeys.settings_accountPage_general_title.tr(), children: [ - UserProfileSetting( + AccountUserProfile( name: userName, iconUrl: state.userProfile.iconUrl, onSave: (newName) { @@ -75,6 +66,7 @@ class _SettingsAccountViewState extends State { ], ), + // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && state.userProfile.authenticator != AuthenticatorPB.Local) ...[ @@ -82,62 +74,15 @@ class _SettingsAccountViewState extends State { title: LocaleKeys.settings_accountPage_email_title.tr(), children: [ FlowyText.regular(state.userProfile.email), - // Enable when/if we need change email feature - // SingleSettingAction( - // label: state.userProfile.email, - // buttonLabel: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // onPressed: () => SettingsAlertDialog( - // title: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // confirmLabel: LocaleKeys.button_save.tr(), - // confirm: () { - // context.read().add( - // SettingsUserEvent.updateUserEmail( - // _emailController.text, - // ), - // ); - // Navigator.of(context).pop(); - // }, - // children: [ - // SettingsInputField( - // label: LocaleKeys.settings_accountPage_email_title - // .tr(), - // value: state.userProfile.email, - // hideActions: true, - // textController: _emailController, - // ), - // ], - // ).show(context), - // ), ], ), ], - /// Enable when we have change password feature and 2FA - // const SettingsCategorySpacer(), - // SettingsCategory( - // title: 'Account & security', - // children: [ - // SingleSettingAction( - // label: '**********', - // buttonLabel: 'Change password', - // onPressed: () {}, - // ), - // SingleSettingAction( - // label: '2-step authentication', - // buttonLabel: 'Enable 2FA', - // onPressed: () {}, - // ), - // ], - // ), - + // user sign in/out SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ - SignInOutButton( + AccountSignInOutButton( userProfile: state.userProfile, onAction: state.userProfile.authenticator == AuthenticatorPB.Local @@ -149,22 +94,10 @@ class _SettingsAccountViewState extends State { ], ), - /// Enable when we can delete accounts - // const SettingsCategorySpacer(), - // SettingsSubcategory( - // title: 'Delete account', - // children: [ - // SingleSettingAction( - // label: - // 'Permanently delete your account and remove access from all teamspaces.', - // labelMaxLines: 4, - // onPressed: () {}, - // buttonLabel: 'Delete my account', - // isDangerous: true, - // fontSize: 12, - // ), - // ], - // ), + // user deletion + if (widget.userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + const AccountDeletionButton(), ], ); }, @@ -172,308 +105,3 @@ class _SettingsAccountViewState extends State { ); } } - -@visibleForTesting -class SignInOutButton extends StatelessWidget { - const SignInOutButton({ - 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 Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 48, - child: PrimaryRoundedButton( - text: signIn - ? LocaleKeys.settings_accountPage_login_loginLabel.tr() - : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - fontWeight: FontWeight.w600, - radius: 12.0, - onTap: () { - if (signIn) { - _showSignInDialog(context); - } else { - showConfirmDialog( - context: context, - title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - description: switch (userProfile.encryptionType) { - EncryptionTypePB.Symmetric => - LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr(), - _ => LocaleKeys.settings_menu_logoutPrompt.tr(), - }, - onConfirm: () async { - await getIt().signOut(); - onAction(); - }, - ); - } - }, - ), - ), - ], - ); - } - - Future _showSignInDialog(BuildContext context) async { - await showDialog( - context: context, - builder: (context) => BlocProvider( - create: (context) => getIt(), - child: FlowyDialog( - constraints: const BoxConstraints( - maxHeight: 485, - maxWidth: 375, - ), - child: ScaffoldMessenger( - child: Scaffold( - body: Padding( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - 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, - ), - ), - ), - ], - ), - 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, - ), - ), - ], - ), - const VSpace(16), - const SignInWithMagicLinkButtons(), - if (isAuthEnabled) ...[ - const VSpace(20), - 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)), - ], - ), - const VSpace(10), - SettingThirdPartyLogin(didLogin: onAction), - ], - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} - -@visibleForTesting -class UserProfileSetting extends StatefulWidget { - const UserProfileSetting({ - super.key, - required this.name, - required this.iconUrl, - this.onSave, - }); - - final String name; - final String iconUrl; - final void Function(String)? onSave; - - @override - State createState() => _UserProfileSettingState(); -} - -class _UserProfileSettingState extends State { - late final _nameController = TextEditingController(text: widget.name); - late final FocusNode focusNode; - bool isEditing = false; - bool isHovering = false; - - @override - void initState() { - super.initState(); - focusNode = FocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape && - isEditing && - mounted) { - setState(() => isEditing = false); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - )..addListener(() { - if (!focusNode.hasFocus && isEditing && mounted) { - widget.onSave?.call(_nameController.text); - setState(() => isEditing = false); - } - }); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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(), - child: UserAvatar( - iconUrl: widget.iconUrl, - name: widget.name, - size: 48, - fontSize: 20, - isHovering: isHovering, - ), - ), - ), - ), - const HSpace(16), - if (!isEditing) ...[ - Flexible( - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText.medium( - widget.name, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => isEditing = true), - child: const FlowyHover( - resetHoverOnRebuild: false, - child: Padding( - padding: EdgeInsets.all(4), - child: FlowySvg(FlowySvgs.edit_s), - ), - ), - ), - ], - ), - ), - ), - ] else ...[ - Flexible( - child: SettingsInputField( - textController: _nameController, - value: widget.name, - focusNode: focusNode..requestFocus(), - onCancel: () => setState(() => isEditing = false), - onSave: (val) { - widget.onSave?.call(val); - setState(() => isEditing = false); - }, - ), - ), - ], - ], - ); - } - - Future _showIconPickerDialog(BuildContext context) { - return showDialog( - context: context, - builder: (dialogContext) => SimpleDialog( - children: [ - Container( - height: 380, - width: 360, - margin: const EdgeInsets.all(0), - child: FlowyIconEmojiPicker( - onSelectedEmoji: (r) { - context - .read() - .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); - Navigator.of(dialogContext).pop(); - }, - ), - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart index aa4e5f2465fe5..8bccf2b9daeec 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -1,7 +1,6 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - +import 'package:appflowy/shared/flowy_error_page.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'; @@ -21,8 +20,8 @@ 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/error_page.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'; @@ -82,9 +81,10 @@ class _SettingsBillingViewState extends State { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), - child: FlowyErrorPage.message( - state.error!.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart index f2dec9550c7f3..3ce6c6aca2646 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -668,6 +668,9 @@ final _planLabels = [ label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(), tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFileUpload.tr(), + ), ]; class _CellItem { @@ -703,6 +706,9 @@ final List<_CellItem> _freeLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFileUpload.tr(), + ), ]; final List<_CellItem> _proLabels = [ @@ -731,4 +737,7 @@ final List<_CellItem> _proLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFileUpload.tr(), + ), ]; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart index 135259a04ed43..24e35ce454bf5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -1,9 +1,8 @@ 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/shared/flowy_error_page.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'; @@ -21,7 +20,7 @@ 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/widget/error_page.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; @@ -76,9 +75,10 @@ class _SettingsPlanViewState extends State { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), - child: FlowyErrorPage.message( - state.error!.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), ), ); } @@ -121,11 +121,7 @@ class _SettingsPlanViewState extends State { priceInfo: LocaleKeys .settings_planPage_planUsage_addons_aiMax_priceInfo .tr(), - recommend: LocaleKeys - .settings_planPage_planUsage_addons_aiMax_recommend - .tr( - args: [SubscriptionPlanPB.AiMax.priceMonthBilling], - ), + recommend: '', buttonText: state.subscriptionInfo.hasAIMax ? LocaleKeys .settings_planPage_planUsage_addons_activeLabel diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart index 1491af3d4c9cd..6860a811d6ace 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -1,6 +1,3 @@ -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/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; @@ -23,6 +20,8 @@ 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:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsShortcutsView extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart index abe56bf611cd7..7758a375b76f7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -1,5 +1,7 @@ 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/plugins/document/application/document_appearance_cubit.dart'; @@ -43,7 +45,6 @@ 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'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart index 744c042c624ec..ee73768c9dd5b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart @@ -67,8 +67,8 @@ class RestartButton extends StatelessWidget { // ], // ); } else { - return MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_restartApp.tr(), + return MobileLogoutButton( + text: LocaleKeys.settings_menu_restartApp.tr(), onPressed: onClick, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index b8421235d3971..e993c5f79e949 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -393,7 +393,7 @@ class _MToast extends StatelessWidget { ), const HSpace(8.0), ], - hintText, + Expanded(child: hintText), ], ) : hintText, @@ -523,3 +523,35 @@ Future showCustomConfirmDialog({ }, ); } + +Future showCancelAndDeleteDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onDelete, + 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: () => onDelete?.call(), + closeOnAction: false, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.error, + child: builder(context), + ), + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart index 5c07d6d5b13d1..9897a0c55a1fa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -177,7 +177,7 @@ class InteractiveImageToolbar extends StatelessWidget { ? currentImage.isLocal ? FlowySvgs.folder_m : FlowySvgs.m_aa_link_s - : FlowySvgs.import_s, + : FlowySvgs.download_s, onTap: () => _locateOrDownloadImage(context), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart index 37960b3e7904e..7ea37e3ce2e32 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -1,10 +1,12 @@ 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'; @@ -187,3 +189,25 @@ class _InteractiveImageViewerState extends State { _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, + ), + ), + ); diff --git a/frontend/appflowy_flutter/macos/.gitignore b/frontend/appflowy_flutter/macos/.gitignore index 9aad20e46dce0..d2fd3772308cc 100644 --- a/frontend/appflowy_flutter/macos/.gitignore +++ b/frontend/appflowy_flutter/macos/.gitignore @@ -4,4 +4,3 @@ # Xcode-related **/xcuserdata/ -Podfile.lock \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock new file mode 100644 index 0000000000000..0fc6b09590a8d --- /dev/null +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - app_links (1.0.0): + - FlutterMacOS + - appflowy_backend (0.0.1): + - FlutterMacOS + - bitsdojo_window_macos (0.0.1): + - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - ReachabilitySwift + - desktop_drop (0.0.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - flowy_infra_ui (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - HotKey (0.2.0) + - hotkey_manager (0.0.1): + - FlutterMacOS + - HotKey + - irondash_engine_context (0.0.1): + - FlutterMacOS + - local_notifier (0.1.0): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - ReachabilitySwift (5.2.3) + - screen_retriever (0.0.1): + - FlutterMacOS + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.35.1) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - appflowy_backend (from `Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos`) + - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - hotkey_manager (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +SPEC REPOS: + trunk: + - HotKey + - ReachabilitySwift + - Sentry + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + appflowy_backend: + :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + bitsdojo_window_macos: + :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flowy_infra_ui: + :path: Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos + FlutterMacOS: + :path: Flutter/ephemeral + hotkey_manager: + :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + local_notifier: + :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + sentry_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + HotKey: e96d8a2ddbf4591131e2bb3f54e69554d90cdca6 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: c6c371695f914641ab7bc8601944f7e358632d0b + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 + screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + +PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 + +COCOAPODS: 1.15.2 diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 746e95fbd8ca6..552c6a268f202 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -17,6 +17,7 @@ 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'; @@ -38,6 +39,7 @@ part 'dart_event/flowy-config/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, diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart index 2e4d082761392..1e6f6a99e29d6 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart @@ -1,5 +1,7 @@ -import 'package:flowy_infra/file_picker/file_picker_service.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 @@ -35,6 +37,11 @@ class FilePicker implements FilePickerService { 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, @@ -43,6 +50,7 @@ class FilePicker implements FilePickerService { FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, + Uint8List? bytes, }) async { final result = await fp.FilePicker.platform.saveFile( dialogTitle: dialogTitle, @@ -51,6 +59,7 @@ class FilePicker implements FilePickerService { type: type, allowedExtensions: allowedExtensions, lockParentWindow: lockParentWindow, + bytes: bytes, ); return result; diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index 742fb9baffdad..1db56b826792a 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -258,17 +258,32 @@ class FlowyButton extends StatelessWidget { child = IntrinsicWidth(child: child); } - final decoration = this.decoration ?? + var decoration = this.decoration; + + if (decoration == null && (showDefaultBoxDecorationOnMobile && - (Platform.isIOS || Platform.isAndroid) - ? BoxDecoration( - border: Border.all( - color: borderColor ?? Theme.of(context).colorScheme.outline, - width: 1.0, - ), - borderRadius: radius, - ) - : null); + (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, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 27bff59045b9a..80ff69d259e0d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -57,23 +57,21 @@ class _FlowyHoverState extends State { return MouseRegion( cursor: widget.cursor != null ? widget.cursor! : SystemMouseCursors.click, opaque: false, - onHover: (p) { - if (_onHover) return; - _setOnHover(true); - }, - onEnter: (p) { - if (_onHover) return; - _setOnHover(true); - }, - onExit: (p) { - if (!_onHover) return; - _setOnHover(false); - }, - child: renderWidget(), + onHover: (_) => _setOnHover(true), + onEnter: (_) => _setOnHover(true), + onExit: (_) => _setOnHover(false), + child: FlowyHoverContainer( + isHovering: _onHover || (widget.isSelected?.call() ?? false), + style: widget.style ?? + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + 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) { @@ -81,31 +79,6 @@ class _FlowyHoverState extends State { } } } - - Widget renderWidget() { - bool showHover = _onHover; - if (!showHover && widget.isSelected != null) { - showHover = widget.isSelected!(); - } - - final child = widget.child ?? widget.builder!(context, _onHover); - final style = widget.style ?? - HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary); - if (showHover) { - return FlowyHoverContainer( - style: style, - child: child, - ); - } else { - return Container( - decoration: BoxDecoration( - color: style.backgroundColor, - borderRadius: style.borderRadius, - ), - child: child, - ); - } - } } class HoverStyle { @@ -140,15 +113,27 @@ class HoverStyle { class FlowyHoverContainer extends StatelessWidget { final HoverStyle style; final Widget child; + final bool isHovering; const FlowyHoverContainer({ super.key, required this.child, required this.style, + required this.isHovering, }); @override Widget build(BuildContext context) { + if (!isHovering) { + return Container( + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: style.borderRadius, + ), + child: child, + ); + } + final hoverBorder = Border.all( color: style.borderColor, width: style.borderWidth, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index b4a2553814e21..997038f5320c3 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -1,10 +1,11 @@ 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'; -import 'package:flutter/material.dart'; class FlowyIconButton extends StatelessWidget { final double width; @@ -82,7 +83,6 @@ class FlowyIconButton extends StatelessWidget { preferBelow: preferBelow, message: tooltipMessage, richMessage: richTooltipText, - showDuration: Duration.zero, child: RawMaterialButton( clipBehavior: Clip.antiAlias, visualDensity: VisualDensity.compact, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index d0780b1c8972f..aa408f73e1df1 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -39,6 +39,7 @@ class FlowyTextField extends StatefulWidget { final bool readOnly; final Color? enableBorderColor; final BorderRadius? borderRadius; + final Function(PointerDownEvent)? onTapOutside; const FlowyTextField({ super.key, @@ -76,6 +77,7 @@ class FlowyTextField extends StatefulWidget { this.readOnly = false, this.enableBorderColor, this.borderRadius, + this.onTapOutside, }); @override @@ -161,6 +163,7 @@ class FlowyTextFieldState extends State { }, onSubmitted: _onSubmitted, onEditingComplete: widget.onEditingComplete, + onTapOutside: widget.onTapOutside, minLines: 1, maxLines: widget.maxLines, maxLength: widget.maxLength, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart index da3f804c8da74..61ed4b1f181f8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart @@ -8,7 +8,6 @@ class FlowyTooltip extends StatelessWidget { this.message, this.richMessage, this.preferBelow, - this.showDuration, this.margin, this.verticalOffset, this.child, @@ -17,7 +16,6 @@ class FlowyTooltip extends StatelessWidget { final String? message; final InlineSpan? richMessage; final bool? preferBelow; - final Duration? showDuration; final EdgeInsetsGeometry? margin; final Widget? child; final double? verticalOffset; diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 506f0bcaae365..48fc5af893394 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: a64a516 - resolved-ref: a64a5165e79bd2424e594b793843a7158e7d4fb4 + ref: "44989c5" + resolved-ref: "44989c568e71fbf41970ec390cbb62f0db99b6e5" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "3.2.0" @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: bidi - sha256: "1a7d0c696324b2089f72e7671fd1f1f64fef44c980f3cebc84e803967c597b63" + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" url: "https://pub.dev" source: hosted - version: "2.0.10" + version: "2.0.12" bitsdojo_window: dependency: "direct main" description: @@ -622,10 +622,10 @@ packages: dependency: transitive description: name: flex_seed_scheme - sha256: "6c595e545b0678e1fe17e8eec3d1fbca7237482da194fadc20ad8607dc7a7f3d" + sha256: cb5b7ec4ba525d9846d8992858a1c6cfc88f9466d96b8850e2a061aa5f682539 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.1" flowy_infra: dependency: "direct main" description: @@ -1292,13 +1292,13 @@ packages: source: hosted version: "1.0.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" mockito: dependency: transitive description: @@ -1735,18 +1735,18 @@ packages: dependency: "direct main" description: name: sentry - sha256: "0f787e27ff617e4f88f7074977240406a9c5509444bac64a4dfa5b3200fb5632" + sha256: "1af8308298977259430d118ab25be8e1dda626cdefa1e6ce869073d530d39271" url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: fbbb47d72ccca48be25bf3c2ced6ab6e872991af3a0ba78e54be8d138f2e053f + sha256: "18fe4d125c2d529bd6127200f0d2895768266a8c60b4fb50b2086fd97e1a4ab2" url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.0" share_plus: dependency: "direct main" description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 35e78c4f5da6e..003394ebd6628 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 0.6.8 +version: 0.6.9 environment: flutter: ">=3.22.0" @@ -152,8 +152,9 @@ dependencies: extended_text_field: ^15.0.0 extended_text_library: ^12.0.0 sentry_flutter: ^8.7.0 - sentry: ^8.7.0 metadata_fetch_plus: ^1.0.0 + sentry: ^8.8.0 + mime: ^1.0.6 dev_dependencies: flutter_lints: ^4.0.0 @@ -191,7 +192,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "a64a516" + ref: "44989c5" appflowy_editor_plugins: git: diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart index 752ff272daedf..9131446347f88 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart @@ -34,8 +34,9 @@ void main() { final editorBloc = FieldEditorBloc( viewId: context.gridView.id, - field: fieldInfo.field, + fieldInfo: fieldInfo, fieldController: context.fieldController, + isNew: false, ); await boardResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart index e29c6a4a7345e..01b5c0d5fc28a 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -78,14 +78,13 @@ class BoardTestContext { FieldEditorBloc makeFieldEditor({ required FieldInfo fieldInfo, - }) { - final editorBloc = FieldEditorBloc( - viewId: databaseController.viewId, - fieldController: fieldController, - field: fieldInfo.field, - ); - return editorBloc; - } + }) => + FieldEditorBloc( + viewId: databaseController.viewId, + fieldController: fieldController, + fieldInfo: fieldInfo, + isNew: false, + ); CellController makeCellControllerFromFieldId(String fieldId) { return makeCellController( diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart index 32b1d603d9ac9..64113fc55066a 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart @@ -10,7 +10,8 @@ Future createEditorBloc(AppFlowyGridTest gridTest) async { return FieldEditorBloc( viewId: context.gridView.id, fieldController: context.fieldController, - field: fieldInfo.field, + fieldInfo: fieldInfo, + isNew: false, ); } diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart index 66502b44cfdef..28c267f5702ea 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart @@ -102,7 +102,8 @@ Future createFieldEditor({ return FieldEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, - field: field, + fieldInfo: databaseController.fieldController.getField(field.id)!, + isNew: true, ); }, (err) => throw Exception(err), diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 23a023ab9fc84..274c9d6a1314b 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -837,7 +837,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "again", "anyhow", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "collab-entity", "collab-rt-entity", @@ -901,7 +901,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "futures-channel", "futures-util", @@ -975,7 +975,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -1000,7 +1000,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-trait", @@ -1029,7 +1029,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "bytes", @@ -1068,7 +1068,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-stream", @@ -1149,7 +1149,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -1174,7 +1174,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "async-trait", @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "collab", @@ -1446,7 +1446,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.6", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1571,7 +1571,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -2542,6 +2542,7 @@ dependencies = [ name = "flowy-storage" version = "0.1.0" dependencies = [ + "allo-isolate", "anyhow", "async-trait", "bytes", @@ -2553,12 +2554,15 @@ dependencies = [ "flowy-notification", "flowy-sqlite", "flowy-storage-pub", + "futures-util", "fxhash", + "lib-dispatch", "lib-infra", "mime_guess", "protobuf", "serde", "serde_json", + "strum_macros 0.25.2", "tokio", "tracing", "url", @@ -3117,7 +3121,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "futures-util", @@ -3134,7 +3138,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -3566,7 +3570,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -3857,6 +3861,7 @@ dependencies = [ "chrono", "futures", "futures-core", + "futures-util", "md5", "pin-project", "tempfile", @@ -6169,7 +6174,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -6187,6 +6192,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tracing", "uuid", ] diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 688f144a92bee..05c822cd962fd 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -53,7 +58,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "52782033948b7d243693ca159ea519d53458c8a6" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "886376e8bd95ef633aa4b3d7a8377f1003764748" } [dependencies] serde_json.workspace = true @@ -70,9 +75,7 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } @@ -116,13 +119,13 @@ custom-protocol = ["tauri/custom-protocol"] # 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 = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } # Working directory: frontend # To update the commit ID, run: diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts index 029da3b0c9646..7993f709b73c0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts @@ -3,12 +3,13 @@ import { MoveGroupRowPayloadPB, MoveRowPayloadPB, OrderObjectPositionTypePB, + RepeatedRowIdPB, RowIdPB, UpdateRowMetaChangesetPB, } from '@/services/backend'; import { DatabaseEventCreateRow, - DatabaseEventDeleteRow, + DatabaseEventDeleteRows, DatabaseEventDuplicateRow, DatabaseEventGetRowMeta, DatabaseEventMoveGroupRow, @@ -51,13 +52,12 @@ export async function duplicateRow(viewId: string, rowId: string, groupId?: stri } export async function deleteRow(viewId: string, rowId: string, groupId?: string): Promise { - const payload = RowIdPB.fromObject({ + const payload = RepeatedRowIdPB.fromObject({ view_id: viewId, - row_id: rowId, - group_id: groupId, + row_ids: [rowId], }); - const result = await DatabaseEventDeleteRow(payload); + const result = await DatabaseEventDeleteRows(payload); return result.unwrap(); } diff --git a/frontend/appflowy_tauri/src/services/backend/index.ts b/frontend/appflowy_tauri/src/services/backend/index.ts index 1cc17c1e1bff2..3e02ff7183a73 100644 --- a/frontend/appflowy_tauri/src/services/backend/index.ts +++ b/frontend/appflowy_tauri/src/services/backend/index.ts @@ -6,3 +6,4 @@ export * from "./models/flowy-error"; export * from "./models/flowy-config"; export * from "./models/flowy-date"; export * from "./models/flowy-search"; +export * from "./models/flowy-storage"; diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts index 43b696139fe6e..221116aa3ab1a 100644 --- a/frontend/appflowy_web_app/deploy/server.ts +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -128,7 +128,7 @@ const createServer = async (req: Request) => { try { if (metaData && metaData.view) { const view = metaData.view; - const emoji = view.icon.value; + const emoji = view.icon?.ty === 0 && view.icon?.value; const titleList = []; if (emoji) { diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 4e0c87a25951b..3c72bc1a26434 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -56,6 +56,7 @@ "jest": "^29.5.0", "js-base64": "^3.7.5", "katex": "^0.16.7", + "lightgallery": "^2.7.2", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", "notistack": "^3.0.1", @@ -73,6 +74,7 @@ "react-datepicker": "^4.23.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", + "react-helmet": "^6.1.0", "react-hook-form": "^7.52.2", "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.0", @@ -85,6 +87,7 @@ "react-virtualized-auto-sizer": "^1.0.20", "react-vtree": "^2.0.4", "react-window": "^1.8.10", + "react-zoom-pan-pinch": "^3.6.1", "react18-input-otp": "^1.1.2", "redux": "^4.2.1", "rxjs": "^7.8.0", @@ -127,6 +130,7 @@ "@types/react-custom-scrollbars": "^4.0.13", "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.2.22", + "@types/react-helmet": "^6.1.11", "@types/react-katex": "^3.0.0", "@types/react-measure": "^2.0.12", "@types/react-transition-group": "^4.4.6", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 321468236cc3f..d021b0ff7ef36 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -101,6 +101,9 @@ dependencies: katex: specifier: ^0.16.7 version: 0.16.10 + lightgallery: + specifier: ^2.7.2 + version: 2.7.2 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -152,6 +155,9 @@ dependencies: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@18.2.0) + react-helmet: + specifier: ^6.1.0 + version: 6.1.0(react@18.2.0) react-hook-form: specifier: ^7.52.2 version: 7.52.2(react@18.2.0) @@ -188,6 +194,9 @@ dependencies: react-window: specifier: ^1.8.10 version: 1.8.10(react-dom@18.2.0)(react@18.2.0) + react-zoom-pan-pinch: + specifier: ^3.6.1 + version: 3.6.1(react-dom@18.2.0)(react@18.2.0) react18-input-otp: specifier: ^1.1.2 version: 1.1.4(react-dom@18.2.0)(react@18.2.0) @@ -310,6 +319,9 @@ devDependencies: '@types/react-dom': specifier: ^18.2.22 version: 18.2.22 + '@types/react-helmet': + specifier: ^6.1.11 + version: 6.1.11 '@types/react-katex': specifier: ^3.0.0 version: 3.0.0 @@ -4210,6 +4222,12 @@ packages: dependencies: '@types/react': 18.2.66 + /@types/react-helmet@6.1.11: + resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} + dependencies: + '@types/react': 18.2.66 + dev: true + /@types/react-katex@3.0.0: resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==} dependencies: @@ -8227,6 +8245,11 @@ packages: isomorphic.js: 0.2.5 dev: false + /lightgallery@2.7.2: + resolution: {integrity: sha512-Ewdcg9UPDqV0HGZeD7wNE4uYejwH2u0fMo5VAr6GHzlPYlhItJvjhLTR0cL0V1HjhMsH39PAom9iv69ewitLWw==} + engines: {node: '>=6.0.0'} + dev: false + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -9530,6 +9553,18 @@ packages: /react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + /react-helmet@6.1.0(react@18.2.0): + resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==} + peerDependencies: + react: '>=16.3.0' + dependencies: + object-assign: 4.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-fast-compare: 3.2.2 + react-side-effect: 2.1.2(react@18.2.0) + dev: false + /react-hook-form@7.52.2(react@18.2.0): resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} engines: {node: '>=18.0.0'} @@ -9736,6 +9771,14 @@ packages: react: 18.2.0 dev: false + /react-side-effect@2.1.2(react@18.2.0): + resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /react-swipeable-views-core@0.14.0: resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==} engines: {node: '>=6.0.0'} @@ -9824,6 +9867,17 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-zoom-pan-pinch@3.6.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react18-input-otp@1.1.4(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-35xvmTeuPWIxd0Z0Opx4z3OoMaTmKN4ubirQCx1YMZiNoe+2h1hsOSUco4aKPlGXWZCtXrfOFieAh46vqiK9mA==} peerDependencies: diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index d8500665d9c68..7fdcdb07a7cd3 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "again", "anyhow", @@ -862,7 +862,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "collab-entity", "collab-rt-entity", @@ -875,7 +875,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "futures-channel", "futures-util", @@ -958,7 +958,7 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -983,7 +983,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-trait", @@ -1012,7 +1012,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -1032,7 +1032,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "bytes", @@ -1051,7 +1051,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "arc-swap", @@ -1094,7 +1094,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-stream", @@ -1132,7 +1132,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -1157,7 +1157,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "async-trait", @@ -1174,7 +1174,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac#47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "collab", @@ -1436,7 +1436,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa 1.0.10", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1561,7 +1561,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -2572,6 +2572,7 @@ dependencies = [ name = "flowy-storage" version = "0.1.0" dependencies = [ + "allo-isolate", "anyhow", "async-trait", "bytes", @@ -2583,12 +2584,15 @@ dependencies = [ "flowy-notification", "flowy-sqlite", "flowy-storage-pub", + "futures-util", "fxhash", + "lib-dispatch", "lib-infra", "mime_guess", "protobuf", "serde", "serde_json", + "strum_macros 0.25.3", "tokio", "tracing", "url", @@ -3184,7 +3188,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "futures-util", @@ -3201,7 +3205,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -3638,7 +3642,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -3934,6 +3938,7 @@ dependencies = [ "chrono", "futures", "futures-core", + "futures-util", "md5", "pin-project", "tempfile", @@ -6233,7 +6238,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=52782033948b7d243693ca159ea519d53458c8a6#52782033948b7d243693ca159ea519d53458c8a6" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -6251,6 +6256,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tracing", "uuid", ] diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 68a7ba82e154c..f34e2e3a7004d 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -52,7 +57,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "52782033948b7d243693ca159ea519d53458c8a6" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "886376e8bd95ef633aa4b3d7a8377f1003764748" } [dependencies] serde_json.workspace = true @@ -69,9 +74,7 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } @@ -89,9 +92,7 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } -flowy-ai = { path = "../../rust-lib/flowy-ai", features = [ - "tauri_ts", -] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } uuid = "1.5.0" tauri-plugin-deep-link = "0.1.2" @@ -116,13 +117,13 @@ custom-protocol = ["tauri/custom-protocol"] # 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 = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "47dbd6c8033f8fd2999cb8d11f2d60ede121a0ac" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } # Working directory: frontend # To update the commit ID, run: @@ -130,4 +131,3 @@ collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy- # ⚠️⚠️⚠️️ appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } - diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 81378c809989f..34a6c1094e98b 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -35,6 +35,7 @@ export enum BlockType { TableCell = 'table/cell', LinkPreview = 'link_preview', FileBlock = 'file', + GalleryBlock = 'multi_image', } export enum InlineBlockType { @@ -112,6 +113,19 @@ export interface ImageBlockData extends BlockData { height?: number; } +export enum GalleryLayout { + Carousel = 0, + Grid = 1, +} + +export interface GalleryBlockData extends BlockData { + images: { + type: ImageType, + url: string, + }[]; + layout: GalleryLayout; +} + export interface OutlineBlockData extends BlockData { depth?: number; } diff --git a/frontend/appflowy_web_app/src/application/publish/context.tsx b/frontend/appflowy_web_app/src/application/publish/context.tsx index 9e7773624b871..f75dda3b975f3 100644 --- a/frontend/appflowy_web_app/src/application/publish/context.tsx +++ b/frontend/appflowy_web_app/src/application/publish/context.tsx @@ -1,7 +1,10 @@ import { GetViewRowsMap, LoadView, LoadViewMeta } from '@/application/collab.type'; import { db } from '@/application/db'; import { ViewMeta } from '@/application/db/tables/view_metas'; -import { AFConfigContext } from '@/components/app/app.hooks'; +import { View } from '@/application/types'; +import { useService } from '@/components/app/app.hooks'; +import { notify } from '@/components/_shared/notify'; +import { findView } from '@/components/publish/header/utils'; import { useLiveQuery } from 'dexie-react-hooks'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -14,8 +17,8 @@ export interface PublishContextType { toView: (viewId: string) => Promise; loadViewMeta: LoadViewMeta; getViewRowsMap?: GetViewRowsMap; - loadView: LoadView; + outline?: View; } export const PublishContext = createContext(null); @@ -31,11 +34,9 @@ export const PublishProvider = ({ publishName: string; isTemplateThumb?: boolean; }) => { - const viewMeta = useLiveQuery(async () => { - const name = `${namespace}_${publishName}`; - return db.view_metas.get(name); - }, [namespace, publishName]); + const [outline, setOutline] = useState(); + const [subscribers, setSubscribers] = useState void>>(new Map()); useEffect(() => { @@ -43,6 +44,20 @@ export const PublishProvider = ({ setSubscribers(new Map()); }; }, []); + + const viewMeta = useLiveQuery(async () => { + const name = `${namespace}_${publishName}`; + + const view = await db.view_metas.get(name); + + if (!view) return; + + return { + ...view, + name: findView(outline?.children || [], view.view_id)?.name || view.name, + }; + }, [namespace, publishName, outline]); + useEffect(() => { db.view_metas.hook('creating', (primaryKey, obj) => { const subscriber = subscribers.get(primaryKey); @@ -72,7 +87,8 @@ export const PublishProvider = ({ const prevViewMeta = useRef(viewMeta); - const service = useContext(AFConfigContext)?.service; + const service = useService(); + const navigate = useNavigate(); const toView = useCallback( async (viewId: string) => { @@ -80,12 +96,16 @@ export const PublishProvider = ({ const res = await service?.getPublishInfo(viewId); if (!res) { - throw new Error('Not found'); + throw new Error('View has not been published yet'); } - const { namespace, publishName } = res; + const { namespace: viewNamespace, publishName } = res; - navigate(`/${namespace}/${publishName}`); + prevViewMeta.current = undefined; + navigate(`/${viewNamespace}/${publishName}`, { + replace: true, + }); + return; } catch (e) { return Promise.reject(e); } @@ -93,6 +113,21 @@ export const PublishProvider = ({ [navigate, service], ); + const loadOutline = useCallback(async () => { + if (!service || !namespace) return; + try { + const res = await service?.getPublishOutline(namespace); + + if (!res) { + throw new Error('Publish outline not found'); + } + + setOutline(res); + } catch (e) { + notify.error('Publish outline not found'); + } + }, [namespace, service]); + const loadViewMeta = useCallback( async (viewId: string, callback?: (meta: ViewMeta) => void) => { try { @@ -188,6 +223,10 @@ export const PublishProvider = ({ prevViewMeta.current = viewMeta; }, [viewMeta]); + useEffect(() => { + void loadOutline(); + }, [loadOutline]); + return ( {children} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts index 3ce200bd5d0de..4668af7d906d8 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -3,7 +3,7 @@ import axios, { AxiosInstance } from 'axios'; let axiosInstance: AxiosInstance | null = null; -export function initGrantService(baseURL: string) { +export function initGrantService (baseURL: string) { if (axiosInstance) { return; } @@ -21,7 +21,7 @@ export function initGrantService(baseURL: string) { }); } -export async function refreshToken(refresh_token: string) { +export async function refreshToken (refresh_token: string) { const response = await axiosInstance?.post<{ access_token: string; expires_at: number; @@ -39,7 +39,7 @@ export async function refreshToken(refresh_token: string) { return newToken; } -export async function signInWithMagicLink(email: string, authUrl: string) { +export async function signInWithMagicLink (email: string, authUrl: string) { const res = await axiosInstance?.post( '/magiclink', { @@ -52,19 +52,19 @@ export async function signInWithMagicLink(email: string, authUrl: string) { headers: { Redirect_to: authUrl, }, - } + }, ); return res?.data; } -export async function settings() { +export async function settings () { const res = await axiosInstance?.get('/settings'); return res?.data; } -export function signInGoogle(authUrl: string) { +export function signInGoogle (authUrl: string) { const provider = 'google'; const redirectTo = encodeURIComponent(authUrl); const accessType = 'offline'; @@ -75,7 +75,16 @@ export function signInGoogle(authUrl: string) { window.open(url, '_current'); } -export function signInGithub(authUrl: string) { +export function signInApple (authUrl: string) { + const provider = 'apple'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInGithub (authUrl: string) { const provider = 'github'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -84,7 +93,7 @@ export function signInGithub(authUrl: string) { window.open(url, '_current'); } -export function signInDiscord(authUrl: string) { +export function signInDiscord (authUrl: string) { const provider = 'discord'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts index b95a38257850d..cfeda5e915b72 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -11,7 +11,7 @@ import { TemplateCreator, TemplateCreatorFormValues, TemplateSummary, UploadTemplatePayload, } from '@/application/template.type'; -import { FolderView, User, Workspace } from '@/application/types'; +import { FolderView, User, View, Workspace } from '@/application/types'; import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; @@ -233,6 +233,23 @@ export async function getPublishInfoWithViewId (viewId: string) { return Promise.reject(data); } +export async function getPublishOutline (publishNamespace: string) { + const url = `/api/workspace/published-outline/${publishNamespace}`; + const response = await axiosInstance?.get<{ + code: number; + data?: View; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + export async function getPublishViewComments (viewId: string): Promise { const url = `/api/workspace/published-info/${viewId}/comment`; const response = await axiosInstance?.get<{ diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 91fb1bf4e3236..268c5170add4a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -164,6 +164,10 @@ export class AFClientService implements AFService { return data; } + async getPublishOutline (namespace: string) { + return APIService.getPublishOutline(namespace); + } + async loginAuth (url: string) { try { console.log('loginAuth', url); @@ -187,6 +191,11 @@ export class AFClientService implements AFService { return APIService.signInGoogle(AUTH_CALLBACK_URL); } + @withSignIn() + async signInApple (_: { redirectTo: string }) { + return APIService.signInApple(AUTH_CALLBACK_URL); + } + @withSignIn() async signInGithub (_: { redirectTo: string }) { return APIService.signInGithub(AUTH_CALLBACK_URL); @@ -212,7 +221,6 @@ export class AFClientService implements AFService { async getCurrentUser () { const data = await APIService.getCurrentUser(); - await APIService.getWorkspaces(); return data; } diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index a346800058f8b..435153e2c598a 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -9,7 +9,7 @@ import { UploadTemplatePayload, } from '@/application/template.type'; import * as Y from 'yjs'; -import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; +import { DuplicatePublishView, FolderView, User, View, Workspace } from '@/application/types'; export type AFService = PublishService; @@ -37,6 +37,8 @@ export interface PublishService { destroy: () => void; }>; + getPublishOutline (namespace: string): Promise; + getPublishViewGlobalComments: (viewId: string) => Promise; createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise; deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise; @@ -49,6 +51,7 @@ export interface PublishService { signInGoogle: (params: { redirectTo: string }) => Promise; signInGithub: (params: { redirectTo: string }) => Promise; signInDiscord: (params: { redirectTo: string }) => Promise; + signInApple: (params: { redirectTo: string }) => Promise; getWorkspaces: () => Promise; getWorkspaceFolder: (workspaceId: string) => Promise; diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts index 38a126a402df4..79bcb70850357 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts @@ -4,4 +4,5 @@ export * from "./models/flowy-folder"; export * from "./models/flowy-document"; export * from "./models/flowy-error"; export * from "./models/flowy-config"; -export * from "./models/flowy-date"; \ No newline at end of file +export * from "./models/flowy-date"; +export * from "./models/flowy-storage"; diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index e6691c473ed32..8ac0fd884428a 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -24,6 +24,10 @@ export class AFClientService implements AFService { return Promise.reject('Method not implemented'); } + async getPublishOutline (_namespace: string) { + return Promise.reject('Method not implemented'); + } + async getPublishViewMeta (_namespace: string, _publishName: string) { return Promise.reject('Method not implemented'); } @@ -48,6 +52,10 @@ export class AFClientService implements AFService { return Promise.reject('Method not implemented'); } + signInApple (_params: { redirectTo: string }): Promise { + return Promise.reject('Method not implemented'); + } + signInMagicLink (_params: { email: string; redirectTo: string }): Promise { return Promise.reject('Method not implemented'); } diff --git a/frontend/appflowy_web_app/src/application/types.ts b/frontend/appflowy_web_app/src/application/types.ts index 1549df084bace..48a44dd2933e8 100644 --- a/frontend/appflowy_web_app/src/application/types.ts +++ b/frontend/appflowy_web_app/src/application/types.ts @@ -1,4 +1,4 @@ -import { CollabType } from '@/application/collab.type'; +import { CollabType, ViewLayout } from '@/application/collab.type'; export interface Workspace { icon: string; @@ -38,3 +38,31 @@ export interface DuplicatePublishView { collabType: CollabType; viewId: string; } + +export enum ViewIconType { + Emoji = 0, + Icon = 1, +} + +export interface ViewIcon { + ty: ViewIconType; + value: string; +} + +export interface ViewExtra { + is_space: boolean; + space_created_at?: number; + space_icon?: string; + space_icon_color?: string; + space_permission?: number; +} + +export interface View { + view_id: string; + name: string; + icon: ViewIcon | null; + layout: ViewLayout; + extra: ViewExtra | null; + children: View[]; + is_published: boolean; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/appflowy.svg b/frontend/appflowy_web_app/src/assets/appflowy.svg index c282c112e1345..7ef34c9713b5c 100644 --- a/frontend/appflowy_web_app/src/assets/appflowy.svg +++ b/frontend/appflowy_web_app/src/assets/appflowy.svg @@ -1,25 +1,34 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/arrow_right.svg b/frontend/appflowy_web_app/src/assets/arrow_right.svg index 990748cab3198..268e69e5593f2 100644 --- a/frontend/appflowy_web_app/src/assets/arrow_right.svg +++ b/frontend/appflowy_web_app/src/assets/arrow_right.svg @@ -1,5 +1,5 @@ - + diff --git a/frontend/appflowy_web_app/src/assets/chevron_down.svg b/frontend/appflowy_web_app/src/assets/chevron_down.svg index fbf3c9aabd7d6..880c2dde18544 100644 --- a/frontend/appflowy_web_app/src/assets/chevron_down.svg +++ b/frontend/appflowy_web_app/src/assets/chevron_down.svg @@ -1,5 +1,5 @@ - + diff --git a/frontend/appflowy_web_app/src/assets/close.svg b/frontend/appflowy_web_app/src/assets/close.svg index 6eb7ce67e936e..c6807bc1af415 100644 --- a/frontend/appflowy_web_app/src/assets/close.svg +++ b/frontend/appflowy_web_app/src/assets/close.svg @@ -1,5 +1,5 @@ - + diff --git a/frontend/appflowy_web_app/src/assets/full_view.svg b/frontend/appflowy_web_app/src/assets/full_view.svg new file mode 100644 index 0000000000000..d4fe3090cb62f --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/full_view.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/login/apple.svg b/frontend/appflowy_web_app/src/assets/login/apple.svg new file mode 100644 index 0000000000000..9be1f9f0c9968 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/login/apple.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/minus.svg b/frontend/appflowy_web_app/src/assets/minus.svg new file mode 100644 index 0000000000000..8be3fe893d160 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/publish.svg b/frontend/appflowy_web_app/src/assets/publish.svg new file mode 100644 index 0000000000000..52b819d1d9724 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/publish.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/reload.svg b/frontend/appflowy_web_app/src/assets/reload.svg new file mode 100644 index 0000000000000..c8f2dcb3bfcd5 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/reload.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx b/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx new file mode 100644 index 0000000000000..e7bf84761cd8b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx @@ -0,0 +1,40 @@ +import { Divider } from '@mui/material'; +import React from 'react'; +import { ReactComponent as AppFlowyLogo } from '@/assets/appflowy.svg'; + +function AppFlowyPower ({ + divider, + width, +}: { + divider?: boolean; + width?: number; +}) { + return ( +
+ {divider && } + +
{ + window.open('https://appflowy.io', '_blank'); + }} + style={{ + width, + }} + className={ + 'flex w-full cursor-pointer gap-2 items-center justify-center py-4 text-sm text-text-title opacity-50' + } + > + Powered by + +
+
+ ); +} + +export default AppFlowyPower; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts index 6c47eaac9ed87..0dbc727a95bdd 100644 --- a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts @@ -1,3 +1,4 @@ +import { notify } from '@/components/_shared/notify'; import { loadEmojiData } from '@/utils/emoji'; import { EmojiMartData } from '@emoji-mart/data'; import { PopoverProps } from '@mui/material/Popover'; @@ -16,8 +17,10 @@ interface Emoji { native: string; } -export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) { +export function useLoadEmojiData ({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) { const [searchValue, setSearchValue] = useState(''); + const [loading, setLoading] = useState(false); + const [isEmpty, setIsEmpty] = useState(false); const [emojiCategories, setEmojiCategories] = useState([]); const [skin, setSkin] = useState(() => { return Number(localStorage.getItem('emoji-mart.skin')) || 0; @@ -30,40 +33,52 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str const searchEmojiData = useCallback( async (searchVal?: string) => { - const emojiData = await loadEmojiData(); - - const { emojis, categories } = emojiData as EmojiMartData; - - const filteredCategories = categories - .map((category) => { - const { id, emojis: categoryEmojis } = category; - - return { - id, - emojis: categoryEmojis - .filter((emojiId) => { - const emoji = emojis[emojiId]; - - if (!searchVal) return true; - return filterSearchValue(emoji, searchVal); - }) - .map((emojiId) => { - const emoji = emojis[emojiId]; - const { name, skins } = emoji; - - return { - id: emojiId, - name, - native: skins[skin] ? skins[skin].native : skins[0].native, - }; - }), - }; - }) - .filter((category) => category.emojis.length > 0); - - setEmojiCategories(filteredCategories); + setLoading(true); + setIsEmpty(false); + try { + const emojiData = await loadEmojiData(); + const { emojis, categories } = emojiData as EmojiMartData; + + const filteredCategories = categories + .map((category) => { + const { id, emojis: categoryEmojis } = category; + + return { + id, + emojis: categoryEmojis + .filter((emojiId) => { + const emoji = emojis[emojiId]; + + if (!searchVal) return true; + return filterSearchValue(emoji, searchVal); + }) + .map((emojiId) => { + const emoji = emojis[emojiId]; + const { name, skins } = emoji; + + return { + id: emojiId, + name, + native: skins[skin] ? skins[skin].native : skins[0].native, + }; + }), + }; + }) + .filter((category) => category.emojis.length > 0); + + if (filteredCategories.length === 0) { + setIsEmpty(true); + } + + setEmojiCategories(filteredCategories); + } catch (_e) { + notify.error('Failed to load emoji data'); + setIsEmpty(true); + } + + setLoading(false); }, - [skin] + [skin], ); useEffect(() => { @@ -80,7 +95,7 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str async (native: string) => { onEmojiSelect(native); }, - [onEmojiSelect] + [onEmojiSelect], ); return { @@ -90,10 +105,12 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str onSelect, onSkinChange, skin, + loading, + isEmpty, }; } -export function useSelectSkinPopoverProps(): PopoverProps & { +export function useSelectSkinPopoverProps (): PopoverProps & { onOpen: (event: React.MouseEvent) => void; onClose: () => void; } { @@ -118,12 +135,12 @@ export function useSelectSkinPopoverProps(): PopoverProps & { }; } -function filterSearchValue( +function filterSearchValue ( emoji: { name: string; keywords?: string[]; }, - searchValue: string + searchValue: string, ) { const { name, keywords } = emoji; const searchValueLowerCase = searchValue.toLowerCase(); @@ -134,7 +151,7 @@ function filterSearchValue( ); } -export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) { +export function getRowsWithCategories (emojiCategories: EmojiCategory[], rowSize: number) { const rows: { id: string; type: 'category' | 'emojis'; @@ -157,4 +174,4 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: }); }); return rows; -} +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx index d5d15fa9664d6..952a3a7b85461 100644 --- a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx @@ -1,8 +1,10 @@ +import CircularProgress from '@mui/material/CircularProgress'; import React from 'react'; import { useLoadEmojiData } from './EmojiPicker.hooks'; import EmojiPickerHeader from './EmojiPickerHeader'; import EmojiPickerCategories from './EmojiPickerCategories'; +import emptyImageSrc from '@/assets/images/empty.png'; interface Props { onEmojiSelect: (emoji: string) => void; @@ -11,8 +13,9 @@ interface Props { hideRemove?: boolean; } -export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { - const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props); +export function EmojiPicker ({ defaultEmoji, onEscape, ...props }: Props) { + const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect, loading, isEmpty } = + useLoadEmojiData(props); return (
@@ -24,14 +27,22 @@ export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { searchValue={searchValue} onSearchChange={setSearchValue} /> - + {loading ? ( +
+ +
+ ) : isEmpty ? ( + {'No + ) : ( + + )}
); } -export default EmojiPicker; +export default EmojiPicker; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx new file mode 100644 index 0000000000000..eb4a0c9af40aa --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx @@ -0,0 +1,184 @@ +import { notify } from '@/components/_shared/notify'; +import { copyTextToClipboard } from '@/utils/copy'; +import { IconButton, Portal, Tooltip } from '@mui/material'; +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/reload.svg'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; +import { ReactComponent as MinusIcon } from '@/assets/minus.svg'; +import { ReactComponent as LinkIcon } from '@/assets/link.svg'; +import { ReactComponent as DownloadIcon } from '@/assets/download.svg'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +export interface GalleryImage { + src: string; + thumb: string; + responsive: string; +} + +export interface GalleryPreviewProps { + images: GalleryImage[]; + open: boolean; + onClose: () => void; + previewIndex: number; +} + +const buttonClassName = 'p-1 hover:bg-transparent text-white hover:text-content-blue-400 p-0'; + +function GalleryPreview ({ + images, + open, + onClose, + previewIndex, +}: GalleryPreviewProps) { + const { t } = useTranslation(); + const [index, setIndex] = useState(previewIndex); + const handleToPrev = useCallback(() => { + setIndex((prev) => prev === 0 ? images.length - 1 : prev - 1); + }, [images.length]); + + const handleToNext = useCallback(() => { + setIndex((prev) => prev === images.length - 1 ? 0 : prev + 1); + }, [images.length]); + + const handleCopy = useCallback(async () => { + const image = images[index]; + + if (!image) { + return; + } + + await copyTextToClipboard(image.src); + notify.success(t('publish.copy.imageBlock')); + }, [images, index, t]); + + const handleDownload = useCallback(() => { + const image = images[index]; + + if (!image) { + return; + } + + window.open(image.src, '_blank'); + }, [images, index]); + + const handleKeydown = useCallback((e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + switch (true) { + case e.key === 'ArrowLeft': + case e.key === 'ArrowUp': + handleToPrev(); + break; + case e.key === 'ArrowRight': + case e.key === 'ArrowDown': + handleToNext(); + break; + case e.key === 'Escape': + onClose(); + break; + } + }, [handleToNext, handleToPrev, onClose]); + + useEffect(() => { + (document.activeElement as HTMLElement)?.blur(); + window.addEventListener('keydown', handleKeydown); + + return () => { + window.removeEventListener('keydown', handleKeydown); + }; + }, [handleKeydown]); + + if (!open) { + return null; + } + + return ( + +
+ + + {({ zoomIn, zoomOut, resetTransform }) => ( + +
e.stopPropagation()} + > + {images.length > 1 && +
+ + + + + + {index + 1}/{images.length} + + + + + +
} +
+ + zoomIn()} className={buttonClassName}> + + + + {/**/} + + zoomOut()} className={buttonClassName}> + + + + + resetTransform()} className={buttonClassName}> + + + +
+
+ + + + + + + + + + + +
+ + + + + +
+ e.stopPropagation(), + }} wrapperStyle={{ width: '100%', height: '100%' }} + > + {images[index].src} + +
+ )} +
+
+
+ ); +} + +export default memo(GalleryPreview); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts new file mode 100644 index 0000000000000..ca0290c05e8de --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const GalleryPreview = lazy(() => import('./GalleryPreview')); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss new file mode 100644 index 0000000000000..61d4aaa8037a0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss @@ -0,0 +1,13 @@ +.gallery-preview { + .lg-outer .lg-thumb-item { + @apply rounded-[8px]; + img { + @apply rounded-[6px]; + } + } + + .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { + border: 2px solid var(--content-blue-400); + padding: 2px; + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx b/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx new file mode 100644 index 0000000000000..bdf36f2627f5f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx @@ -0,0 +1,59 @@ +import { ViewIcon, ViewIconType } from '@/application/types'; +import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet'; + +function ViewHelmet ({ + name, + icon, +}: { + name?: string; + icon?: ViewIcon +}) { + + useEffect(() => { + const setFavicon = async () => { + try { + let url = '/appflowy.svg'; + + if (icon && icon.ty === ViewIconType.Emoji && icon.value) { + const emojiCode = icon?.value?.codePointAt(0)?.toString(16); // Convert emoji to hex code + const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u'; + + const response = await fetch(`${baseUrl}${emojiCode}.svg`); + const svgText = await response.text(); + const blob = new Blob([svgText], { type: 'image/svg+xml' }); + + url = URL.createObjectURL(blob); + } + + const link = document.querySelector('link[rel*=\'icon\']') as HTMLLinkElement || document.createElement('link'); + + link.type = 'image/svg+xml'; + link.rel = 'icon'; + link.href = url; + document.getElementsByTagName('head')[0].appendChild(link); + } catch (error) { + console.error('Error setting favicon:', error); + } + }; + + void setFavicon(); + + return () => { + const link = document.querySelector('link[rel*=\'icon\']'); + + if (link) { + document.getElementsByTagName('head')[0].removeChild(link); + } + }; + }, [icon]); + + if (!name) return null; + return ( + + {name} | AppFlowy + + ); +} + +export default ViewHelmet; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx index bb90b80c4186d..e67b8a388a607 100644 --- a/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx @@ -7,9 +7,13 @@ interface Props { open: boolean; onClose: () => void; placement?: PopperPlacementType; + PaperProps?: { + className?: string; + }; + } -export const RichTooltip = ({ placement = 'top', open, onClose, content, children }: Props) => { +export const RichTooltip = ({ placement = 'top', open, onClose, content, children, PaperProps }: Props) => { const [childNode, setChildNode] = React.useState(null); const [, setTransitioning] = React.useState(false); @@ -48,7 +52,8 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre > - + {content} diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx new file mode 100644 index 0000000000000..fa61d8e7de73a --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Skeleton, Box } from '@mui/material'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; + +export const BreadcrumbsSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default BreadcrumbsSkeleton; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/document/DocumentSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx similarity index 54% rename from frontend/appflowy_web_app/src/components/document/DocumentSkeleton.tsx rename to frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx index 916bf77045252..3f0951c6de286 100644 --- a/frontend/appflowy_web_app/src/components/document/DocumentSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx @@ -1,15 +1,15 @@ import Skeleton from '@mui/material/Skeleton'; import React from 'react'; -function DocumentSkeleton() { +function DocumentSkeleton () { return (
- +
); } diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx new file mode 100644 index 0000000000000..dcf0f19e8bd59 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx @@ -0,0 +1,47 @@ +import { Box, Skeleton } from '@mui/material'; +import './skeleton.scss'; + +export const DirectoryStructure = () => { + return ( + +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss b/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss new file mode 100644 index 0000000000000..37e86ff1db9b2 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss @@ -0,0 +1,14 @@ +.directory-item { + display: flex; + align-items: center; + margin-bottom: 8px; + +} + +.directory-item .MuiSkeleton-root { + margin-right: 8px; +} + +.nested { + margin-left: 24px; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx index 6e379e645886c..d48b394d8b823 100644 --- a/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx @@ -1,20 +1,23 @@ import { FC, useMemo } from 'react'; +import { ReactComponent as CircleIcon } from '@/assets/bulleted_list_icon_1.svg'; export interface TagProps { color?: string; label?: string; size?: 'small' | 'medium'; + badge?: string; } -export const Tag: FC = ({ color, size = 'small', label }) => { +export const Tag: FC = ({ color, size = 'small', label, badge }) => { const className = useMemo(() => { - const classList = ['rounded-md', 'font-medium', 'leading-[18px]']; + const classList = ['rounded-[8px]', 'font-medium', 'leading-[1.5em]', 'flex items-center gap-1 max-w-full']; if (color) classList.push(`text-text-title`); - if (size === 'small') classList.push('px-2', 'py-[2px]'); + if (size === 'small') classList.push('px-2', 'py-1'); if (size === 'medium') classList.push('px-3', 'py-1'); + if (badge) classList.push('pr-4'); return classList.join(' '); - }, [color, size]); + }, [color, size, badge]); return (
= ({ color, size = 'small', label }) => { }} className={className} > - {label} + {badge && + + } +
{label}
+
); }; diff --git a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx index e7cb3cce62ce3..d7895c0fe44a3 100644 --- a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx @@ -81,7 +81,6 @@ function AppConfig ({ children }: { children: React.ReactNode }) { enqueueSnackbar(message, { variant: 'success' }); }, error: (message: string) => { - console.log('error', message); enqueueSnackbar(message, { variant: 'error' }); }, warning: (message: string) => { diff --git a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx index e296637a0fbdc..8aa93ee5eb0b4 100644 --- a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx +++ b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx @@ -8,29 +8,18 @@ import { styled } from '@mui/material'; import { InfoSnackbar } from '../_shared/notify'; const StyledSnackbarProvider = styled(SnackbarProvider)` - &.notistack-MuiContent-default { - background-color: var(--fill-toolbar); - } + &.notistack-MuiContent-default { + background-color: var(--fill-toolbar); + } - &.notistack-MuiContent-info { - background-color: var(--function-info); - } + &.notistack-MuiContent-info { + background-color: var(--function-info); + } - &.notistack-MuiContent-success { - background-color: var(--function-success); - } - - &.notistack-MuiContent-error { - background-color: var(--function-error); - } - - &.notistack-MuiContent-warning { - background-color: var(--function-warning); - } `; -export default function withAppWrapper(Component: React.FC): React.FC { - return function AppWrapper(): JSX.Element { +export default function withAppWrapper (Component: React.FC): React.FC { + return function AppWrapper (): JSX.Element { return ( diff --git a/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx b/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx index 16106d24e24fc..fcb7ed7a0e22e 100644 --- a/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx +++ b/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx @@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as CloseIcon } from '@/assets/close.svg'; import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; import './template.scss'; +import { slugify } from '@/components/as-template/utils'; +import { ReactComponent as WebsiteIcon } from '@/assets/website.svg'; function AsTemplate ({ viewName, @@ -56,20 +58,10 @@ function AsTemplate ({ await service?.updateTemplate(template.view_id, formData); } else { await service?.createTemplate(formData); - await loadTemplate(); + } - notify.info({ - type: 'success', - title: t('template.uploadSuccess'), - message: t('template.uploadSuccessDescription'), - okText: t('template.viewTemplate'), - onOk: () => { - const url = import.meta.env.AF_BASE_URL?.includes('test') ? 'https://test.appflowy.io' : 'https://appflowy.io'; - - window.open(`${url}/templates/${selectedCategoryIds[0]}/${viewId}`, '_blank'); - }, - }); + await loadTemplate(); handleBack(); } catch (error) { // eslint-disable-next-line @@ -77,8 +69,7 @@ function AsTemplate ({ notify.error(error.toString()); } - }, [service, selectedCreatorId, selectedCategoryIds, viewId, isNewTemplate, isFeatured, viewUrl, template, t, handleBack, loadTemplate]); - + }, [service, selectedCreatorId, selectedCategoryIds, isNewTemplate, isFeatured, viewId, viewUrl, template, loadTemplate, handleBack]); const submitRef = React.useRef(null); useEffect(() => { @@ -120,6 +111,15 @@ function AsTemplate ({ > {t('button.cancel')} + {template && }
{template &&
diff --git a/frontend/appflowy_web_app/src/components/as-template/creator/CreatorAvatar.tsx b/frontend/appflowy_web_app/src/components/as-template/creator/CreatorAvatar.tsx index c68db0f58b0fd..769c7f526d159 100644 --- a/frontend/appflowy_web_app/src/components/as-template/creator/CreatorAvatar.tsx +++ b/frontend/appflowy_web_app/src/components/as-template/creator/CreatorAvatar.tsx @@ -8,7 +8,58 @@ import { ReactComponent as CloudUploadIcon } from '@/assets/cloud_add.svg'; import { useTranslation } from 'react-i18next'; -function CreatorAvatar ({ src, name, enableUpload, onChange, size }: { +const colorArray = [ + '#5287D8', + '#6E9DE3', + '#8BB3ED', + '#A7C9F7', + '#979EB6', + '#A2A8BF', + '#ACB2C8', + '#C1C7DA', + '#E8AF53', + '#E6C25A', + '#E6D26F', + '#E6E288', + '#589599', + '#68AD8E', + '#79C47F', + '#8CDB6A', + '#AA94DC', + '#C49EEB', + '#BAACEE', + '#D5C4FB', + '#F597D2', + '#FCB2E3', + '#FDC5E8', + '#F8D2E1', + '#D1D269', + '#C7C98D', + '#CED09B', + '#DAD9B6', + '#DDD2C6', + '#DDD6C7', + '#EADED3', + '#FED5C4', + '#72A7D8', + '#8FCAE3', + '#64B3DA', + '#52B2D4', + '#90A4FF', + '#A8BEF4', + '#AEBDFF', + '#C2CDFF', + '#86C1B7', + '#A6D8D0', + '#A7D7A8', + '#C8E4C9', + '#FF9494', + '#FFBDBD', + '#DCA8A8', + '#E3C4C4', +]; + +function CreatorAvatar({ src, name, enableUpload, onChange, size }: { src: string; name: string; enableUpload?: boolean; @@ -20,7 +71,7 @@ function CreatorAvatar ({ src, name, enableUpload, onChange, size }: { const [tab, setTab] = React.useState(0); const avatarProps = useMemo(() => { - return stringAvatar(name || ''); + return stringAvatar(name || '', colorArray); }, [name]); const [openModal, setOpenModal] = React.useState(false); @@ -46,15 +97,21 @@ function CreatorAvatar ({ src, name, enableUpload, onChange, size }: { e.stopPropagation(); }} > - {enableUpload && showUpload && ( - +