diff --git a/.github/workflows/android_ci.yaml.bak b/.github/workflows/android_ci.yaml.bak index 4e159e845b..81e132cbf8 100644 --- a/.github/workflows/android_ci.yaml.bak +++ b/.github/workflows/android_ci.yaml.bak @@ -18,7 +18,7 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.22.3" + FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.81.0" CARGO_MAKE_VERSION: "0.37.18" CLOUD_VERSION: 0.6.54-amd64 diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 42daeca881..1fc1b0e052 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -25,7 +25,7 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.22.2" + FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.81.0" CARGO_MAKE_VERSION: "0.37.18" CLOUD_VERSION: 0.6.54-amd64 @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -74,7 +74,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ windows-latest ] + os: [windows-latest] include: - os: windows-latest flutter_profile: development-windows-x86 @@ -101,7 +101,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ macos-latest ] + os: [macos-latest] include: - os: macos-latest flutter_profile: development-mac-x86_64 @@ -123,12 +123,12 @@ jobs: flutter_profile: ${{ matrix.flutter_profile }} unit_test: - needs: [ prepare-linux ] + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -217,11 +217,11 @@ jobs: shell: bash cloud_integration_test: - needs: [ prepare-linux ] + needs: [prepare-linux] strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -340,13 +340,13 @@ jobs: shell: bash integration_test: - needs: [ prepare-linux ] + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] - test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] + os: [ubuntu-latest] + test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9] include: - os: ubuntu-latest target: "x86_64-unknown-linux-gnu" diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index d9bee0242a..e13863f4a7 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -18,7 +18,7 @@ on: - "!frontend/appflowy_web_app/**" env: - FLUTTER_VERSION: "3.22.3" + FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.81.0" concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91e6b99565..a4582ffa74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - "*" env: - FLUTTER_VERSION: "3.22.0" + FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.81.0" jobs: @@ -232,10 +232,10 @@ jobs: matrix: job: - { - targets: "aarch64-apple-darwin,x86_64-apple-darwin", - os: macos-latest, - extra-build-args: "", - } + targets: "aarch64-apple-darwin,x86_64-apple-darwin", + os: macos-latest, + extra-build-args: "", + } steps: - name: Checkout source code uses: actions/checkout@v4 @@ -336,12 +336,12 @@ jobs: matrix: job: - { - arch: x86_64, - target: x86_64-unknown-linux-gnu, - os: ubuntu-20.04, - extra-build-args: "", - flutter_profile: production-linux-x86_64, - } + arch: x86_64, + target: x86_64-unknown-linux-gnu, + os: ubuntu-22.04, + extra-build-args: "", + flutter_profile: production-linux-x86_64, + } steps: - name: Checkout source code uses: actions/checkout@v4 diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml index 916f19d9fc..53a5f66748 100644 --- a/.github/workflows/rust_coverage.yml +++ b/.github/workflows/rust_coverage.yml @@ -10,7 +10,7 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.22.0" + FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.81.0" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce72e8997..a5e7e268a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,83 @@ # Release Notes +## Version 0.8.9 - 16/04/2025 +### Desktop +#### New Features +- Supported pasting a link as a mention, providing a more condensed visualization of linked content +- Supported converting between link formats (e.g. transforming a mention into a bookmark) +- Improved the link editing experience with enhanced UX +- Added OTP (One-Time Password) support for sign-in authentication +- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet +#### Bug Fixes +- Fixed an issue where properties were not displaying in the row detail page +- Fixed a bug where Undo didn't work in the row detail page +- Fixed an issue where blocks didn't grow when the grid got bigger +- Fixed several bugs related to AI writers +### Mobile +#### New Features +- Added sign-in with OTP (One-Time Password) +#### Bug Fixes +- Fixed an issue where the slash menu sometimes failed to display +- Updated the mention page block to handle page selection with more context. + +## Version 0.8.8 - 01/04/2025 +### New Features +- Added support for selecting AI models in AI writer +- Revamped link menu in toolbar +- Added support for using ":" to add emojis in documents +- Passed the history of past AI prompts and responses to AI writer +### Bug Fixes +- Improved AI writer scrolling user experience +- Fixed issue where checklist items would disappear during reordering +- Fixed numbered lists generated by AI to maintain the same index as the input + +## Version 0.8.7 - 18/03/2025 +### New Features +- Made local AI free and integrated with Ollama +- Supported nested lists within callout and quote blocks +- Revamped the document's floating toolbar and added Turn Into +- Enabled custom icons in callout blocks +### Bug Fixes +- Fixed occasional incorrect positioning of the slash menu +- Improved AI Chat and AI Writers with various bug fixes +- Adjusted the columns block to match the width of the editor +- Fixed a potential segfault caused by infinite recursion in the trash view +- Resolved an issue where the first added cover might be invisible +- Fixed adding cover images via Unsplash + +## Version 0.8.6 - 06/03/2025 +### Bug Fixes +- Fix the incorrect title positioning when adjusting the document width setting +- Enhance the user experience of the icon color picker for smoother interactions +- Add missing icons to the database to ensure completeness and consistency +- Resolve the issue with links not functioning correctly on Linux systems +- Improve the outline feature to work seamlessly within columns +- Center the bulleted list icon within columns for better visual alignment +- Enable dragging blocks under tables in the second column to enhance flexibility +- Disable the AI writer feature within tables to prevent conflicts and improve usability +- Automatically enable the header row when converting content from Markdown to ensure proper formatting +- Use the "Undo" function to revert the auto-formatting + +## Version 0.8.5 - 04/03/2025 +### New Features +- Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu +- AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more +- Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen +### Bug Fixes +- Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document +- Fixed a bug preventing the relation field in databases from opening +- Fixed an issue where links in documents were unclickable on Linux + +## Version 0.8.4 - 18/02/2025 +### New Features +- Switch AI mode on mobile +- Support locking page +- Support uploading svg file as icon +- Support the slash, at, and plus menus on mobile +### Bug Fixes +- Gallery not rendering in row page +- Save image should not copy the image (mobile) +- Support exporting more content to markdown + ## Version 0.8.2 - 23/01/2025 ### New Features - Customized database view icons diff --git a/README.md b/README.md index b9606b8844..565908e756 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- AppFlowy.IO
+ AppFlowy
⭐️ The Open Source Alternative To Notion ⭐️

@@ -18,18 +18,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo

- Website • + WebsiteForumDiscordRedditTwitter

-

AppFlowy Kanban Board for To-dos

-

AppFlowy Databases for Tasks and Projects

-

AppFlowy Sites for Beautiful documentation

-

AppFlowy AI

-

AppFlowy Templates

+

AppFlowy Kanban Board for To-dos

+

AppFlowy Databases for Tasks and Projects

+

AppFlowy Sites for Beautiful documentation

+

AppFlowy AI

+

AppFlowy Templates



@@ -48,7 +48,7 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo - [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone - [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is not supported -- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy) +- [Self-hosting AppFlowy](https://appflowy.com/docs/self-host-appflowy-overview) - [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source) ## Built With @@ -78,7 +78,7 @@ report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labe ## **Releases** -Please see the [changelog](https://www.appflowy.io/whatsnew) for more details about a given release. +Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release. ## Contributing @@ -89,9 +89,7 @@ for details. If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains -the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with -us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt! -Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter. +the community, **Congratulations!** You are now an official contributor to AppFlowy. ## Translations 🌎🗺 @@ -152,8 +150,8 @@ more information. ## Acknowledgments -Special thanks to these amazing projects which help power AppFlowy.IO: +Special thanks to these amazing projects which help power AppFlowy: - [cargo-make](https://github.com/sagiegurari/cargo-make) - [contrib.rocks](https://contrib.rocks) -- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui) \ No newline at end of file +- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui) diff --git a/codemagic.yaml b/codemagic.yaml index b8934d8be8..9ba2a1a562 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -4,7 +4,7 @@ workflows: instance_type: mac_mini_m2 max_build_duration: 30 environment: - flutter: 3.22.3 + flutter: 3.27.4 xcode: latest cocoapods: default diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 95cfe761a1..41fdffb1af 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.8.2" +APPFLOWY_VERSION = "0.8.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/analysis_options.yaml b/frontend/appflowy_flutter/analysis_options.yaml index 8da401ef26..4579b2d8c5 100644 --- a/frontend/appflowy_flutter/analysis_options.yaml +++ b/frontend/appflowy_flutter/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" + - "packages/**/*.dart" linter: rules: diff --git a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml index 74d5c5e494..f746eeb610 100644 --- a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml +++ b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,8 @@ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> + diff --git a/frontend/appflowy_flutter/assets/fonts/.gitkeep b/frontend/appflowy_flutter/assets/fonts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf b/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf deleted file mode 100644 index 8f03a5c8f9..0000000000 Binary files a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf and /dev/null differ diff --git a/frontend/appflowy_flutter/assets/test/images/sample.svg b/frontend/appflowy_flutter/assets/test/images/sample.svg new file mode 100644 index 0000000000..7dcd6907d8 --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/images/sample.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/translations/mr-IN.json b/frontend/appflowy_flutter/assets/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/appflowy_flutter/assets/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} diff --git a/frontend/appflowy_flutter/distribute_options.yaml b/frontend/appflowy_flutter/distribute_options.yaml new file mode 100644 index 0000000000..60f603a938 --- /dev/null +++ b/frontend/appflowy_flutter/distribute_options.yaml @@ -0,0 +1,12 @@ +output: dist/ +releases: + - name: dev + jobs: + - name: release-dev-linux-deb + package: + platform: linux + target: deb + - name: release-dev-linux-rpm + package: + platform: linux + target: rpm diff --git a/frontend/appflowy_flutter/dsa_pub.pem b/frontend/appflowy_flutter/dsa_pub.pem new file mode 100644 index 0000000000..6a9d213b8a --- /dev/null +++ b/frontend/appflowy_flutter/dsa_pub.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT +rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG +4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw ++sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV +KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5 +b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z +QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW +YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG +G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu +6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA +6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp +q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd +0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/ +4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb +K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7 +hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO +s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz +Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4 +uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV +Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn +ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB ++fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN +C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r +vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx +k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y +GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/ +eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG +hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM +EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8 +iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI +7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb +w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf +1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P +Y29SB4jvwqls268rP0cWqy4WXwlVwuc= +-----END PUBLIC KEY----- diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart index 15da47f0f1..6a012ac763 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart @@ -23,24 +23,24 @@ void main() { final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); // Is expanded by default - expect(collapseFinder, findsOneWidget); - expect(expandFinder, findsNothing); - - // Collapse hidden groups - await tester.tap(collapseFinder); - await tester.pumpAndSettle(); - - // Is collapsed expect(collapseFinder, findsNothing); expect(expandFinder, findsOneWidget); - // Expand hidden groups + // Collapse hidden groups await tester.tap(expandFinder); await tester.pumpAndSettle(); - // Is expanded + // Is collapsed expect(collapseFinder, findsOneWidget); expect(expandFinder, findsNothing); + + // Expand hidden groups + await tester.tap(collapseFinder); + await tester.pumpAndSettle(); + + // Is expanded + expect(collapseFinder, findsNothing); + expect(expandFinder, findsOneWidget); }); testWidgets('hide first group, and show it again', (tester) async { @@ -48,6 +48,9 @@ void main() { await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); + await tester.tapButton(expandFinder); + // Tap the options of the first group final optionsFinder = find .descendant( diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart index bc994eba99..a8c05d5f80 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart @@ -1,5 +1,6 @@ import 'data_migration/data_migration_test_runner.dart' as data_migration_test_runner; +import 'database/database_test_runner.dart' as database_test_runner; import 'document/document_test_runner.dart' as document_test_runner; import 'set_env.dart' as preset_af_cloud_env_test; import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test; @@ -28,4 +29,7 @@ Future main() async { sidebar_move_page_test.main(); sidebar_rename_untitled_test.main(); sidebar_icon_test.main(); + + // database + database_test_runner.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart index 06c27093f9..e34ac02aab 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart @@ -15,7 +15,6 @@ void main() { cloudType: AuthenticatorType.appflowyCloudSelfHost, ); - await tester.tapContinousAnotherWay(); await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); @@ -31,12 +30,6 @@ void main() { await tester.enterUserName('local_user'); // Scroll to sign-in - await tester.scrollUntilVisible( - find.byType(AccountSignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - await tester.tapButton(find.byType(AccountSignInOutButton)); // sign up with Google diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart new file mode 100644 index 0000000000..5561d40033 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide UploadImageMenu, ResizableImage; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/database_test_op.dart'; +import '../../../shared/mock/mock_file_picker.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // copy link to block + group('database image:', () { + testWidgets('insert image', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open the first row detail page and upload an image + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Grid, + pageName: 'database image', + ); + await tester.openFirstRowDetailPage(); + + // insert an image block + { + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + } + + // upload an image + { + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final file = File(imagePath) + ..writeAsBytesSync(image.buffer.asUint8List()); + + mockPickFilePaths( + paths: [imagePath], + ); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + expect(find.byType(ResizableImage), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + + // remove the temp file + file.deleteSync(); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart new file mode 100644 index 0000000000..4d1a623f07 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart @@ -0,0 +1,9 @@ +import 'package:integration_test/integration_test.dart'; + +import 'database_image_test.dart' as database_image_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + database_image_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart index 32737a23d1..f163608ccb 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart @@ -33,7 +33,7 @@ void main() { await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_aiWriter.tr(), ); - expect(find.byType(AIWriterBlockComponent), findsOneWidget); + expect(find.byType(AiWriterBlockComponent), findsOneWidget); // switch to another page await tester.openPage(Constants.gettingStartedPageName); @@ -41,7 +41,7 @@ void main() { await tester.openPage(pageName); // expect the ai writer block is not in the document - expect(find.byType(AIWriterBlockComponent), findsNothing); + expect(find.byType(AiWriterBlockComponent), findsNothing); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart index 0289fbe176..1bc9bd8f92 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart @@ -57,7 +57,7 @@ void main() { // move the checkbox to the child of the block at path [9] await tester.editor.dragBlock( [10], - const Offset(80, -30), + const Offset(120, -20), ); // wait for the move animation to complete diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart index 0b649fd6d5..fd65c29927 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart @@ -57,12 +57,6 @@ void main() { await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); - // Scroll to sign-in - await tester.scrollUntilVisible( - find.byType(AccountSignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeGoogleLoginButton(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart index 3271070c74..0b77a0167b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart @@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -44,12 +44,12 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 200)); // Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna) - expect(find.byType(SearchResultTile), findsNWidgets(2)); + expect(find.byType(SearchResultCell), findsNWidgets(2)); // The score should be higher for "ViewOna" thus it should be shown first final secondDocumentWidget = tester - .widget(find.byType(SearchResultTile).first) as SearchResultTile; - expect(secondDocumentWidget.result.data, secondDocument); + .widget(find.byType(SearchResultCell).first) as SearchResultCell; + expect(secondDocumentWidget.item.displayName, secondDocument); // Change search to "ViewOne" await tester.enterText(searchFieldFinder, firstDocument); @@ -57,9 +57,9 @@ void main() { // The score should be higher for "ViewOne" thus it should be shown first final firstDocumentWidget = tester.widget( - find.byType(SearchResultTile).first, - ) as SearchResultTile; - expect(firstDocumentWidget.result.data, firstDocument); + find.byType(SearchResultCell).first, + ) as SearchResultCell; + expect(firstDocumentWidget.item.displayName, firstDocument); }); testWidgets('Displaying icons in search results', (tester) async { @@ -89,11 +89,11 @@ void main() { ); await tester.enterText(searchFieldFinder, 'Page-$randomValue'); await tester.pumpAndSettle(const Duration(milliseconds: 200)); - expect(find.byType(SearchResultTile), findsNWidgets(2)); + expect(find.byType(SearchResultCell), findsNWidgets(2)); /// check results final svgs = find.descendant( - of: find.byType(SearchResultTile), + of: find.byType(SearchResultCell), matching: find.byType(FlowySvg), ); expect(svgs, findsNWidgets(2)); diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart index 277ae8f21e..b9495ae0e7 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -27,11 +27,12 @@ void main() { expect(find.byType(RecentViewsList), findsOneWidget); // Expect three recent history items - expect(find.byType(RecentViewTile), findsNWidgets(3)); + expect(find.byType(SearchRecentViewCell), findsNWidgets(3)); // Expect the first item to be the last viewed document final firstDocumentWidget = - tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; + tester.widget(find.byType(SearchRecentViewCell).first) + as SearchRecentViewCell; expect(firstDocumentWidget.view.name, secondDocument); }); }); diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart index 9b9434d3d7..a71110f1e0 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart @@ -15,6 +15,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + // create a database and add a linked database view await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); @@ -29,6 +30,11 @@ void main() { await tester.tapHidePropertyButton(); tester.noFieldWithName('New field 1'); + // create another field, New field 1 to be hidden still + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + tester.noFieldWithName('New field 1'); + // go back to inline database view, expect field to be shown await tester.tapTabBarLinkedViewByViewName('Untitled'); tester.findFieldWithName('New field 1'); @@ -60,5 +66,40 @@ void main() { await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1"); }); + + testWidgets('field cell width', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a database and add a linked database view + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); + + // create a field + await tester.scrollToRight(find.byType(GridPage)); + await tester.tapNewPropertyButton(); + await tester.renameField('New field 1'); + await tester.dismissFieldEditor(); + + // check the width of the field + expect(tester.getFieldWidth('New field 1'), 150); + + // change the width of the field + await tester.changeFieldWidth('New field 1', 200); + expect(tester.getFieldWidth('New field 1'), 205); + + // create another field, New field 1 to be same width + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + expect(tester.getFieldWidth('New field 1'), 205); + + // go back to inline database view, expect New field 1 to be 150px + await tester.tapTabBarLinkedViewByViewName('Untitled'); + expect(tester.getFieldWidth('New field 1'), 150); + + // go back to linked database view, expect New field 1 to be 205px + await tester.tapTabBarLinkedViewByViewName('Grid'); + expect(tester.getFieldWidth('New field 1'), 205); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart index e35c9cc9d8..71656c1ea6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart @@ -1,5 +1,10 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -73,5 +78,37 @@ void main() { await tester.pumpAndSettle(); }); + + testWidgets('insert grid in column', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create page and show slash menu + await tester.createNewPageWithNameUnderParent(name: 'test page'); + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + /// create a column + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_twoColumns.tr(), + ); + final actionList = find.byType(BlockActionList); + expect(actionList, findsNWidgets(2)); + final position = tester.getCenter(actionList.last); + + /// tap the second child of column + await tester.tapAt(position.copyWith(dx: position.dx + 50)); + + /// create a grid + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_grid.tr(), + ); + + final grid = find.byType(GridPageContent); + expect(grid, findsOneWidget); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart index d95d907881..1a8a3fcda8 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart @@ -27,8 +27,9 @@ void main() { await tester.pumpAndSettle(); // click the align center - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s); - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m); // expect to see the align center final editorState = tester.editor.getCurrentEditorState(); @@ -36,13 +37,15 @@ void main() { expect(first.attributes[blockComponentAlign], 'center'); // click the align right - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s); - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m); expect(first.attributes[blockComponentAlign], 'right'); // click the align left - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s); - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m); expect(first.attributes[blockComponentAlign], 'left'); }); @@ -75,7 +78,7 @@ void main() { [ LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyC, ], tester: tester, withKeyUp: true, diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index aa4f8252d5..d1e34edcb5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -1,10 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -320,8 +322,14 @@ void main() { (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); + expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); @@ -333,19 +341,20 @@ void main() { await tester.hoverOnWidget( find.byType(CustomLinkPreviewWidget), onHover: () async { - final convertToLinkButton = find.byWidgetPredicate((widget) { - return widget is MenuBlockButton && - widget.tooltip == - LocaleKeys.document_plugins_urlPreview_convertToLink.tr(); - }); + /// show menu + final menu = find.byType(CustomLinkPreviewMenu); + expect(menu, findsOneWidget); + await tester.tapButton(menu); + + final convertToLinkButton = find.text( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(), + ); expect(convertToLinkButton, findsOneWidget); - await tester.tap(convertToLinkButton); - await tester.pumpAndSettle(); + await tester.tapButton(convertToLinkButton); }, ); - await tester.pumpAndSettle(); - final editorState = tester.editor.getCurrentEditorState(); final textNode = editorState.getNodeAtPath([0])!; expect(textNode.type, ParagraphBlockKeys.type); @@ -363,14 +372,19 @@ void main() { (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); + expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); }); - await tester.editor.tapLineOfEditorAt(0); await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: @@ -458,7 +472,7 @@ void main() { }); testWidgets('paste the image url', (tester) async { - const plainText = 'https://appflowy.io/1.jpg'; + const plainText = 'http://example.com/1.jpg'; final image = await rootBundle.load('assets/test/images/sample.jpeg'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), @@ -469,16 +483,6 @@ void main() { }); }); - testWidgets('paste image url without extension', (tester) async { - const plainText = - 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; - await tester.pasteContent(plainText: plainText, (editorState) { - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotEmpty); - }); - }); - const testMarkdownText = ''' # I'm h1 ## I'm h2 @@ -521,7 +525,7 @@ void main() { extension on WidgetTester { Future pasteContent( - void Function(EditorState editorState) test, { + FutureOr Function(EditorState editorState) test, { Future Function(EditorState editorState)? beforeTest, String? plainText, String? html, @@ -558,6 +562,6 @@ extension on WidgetTester { ); await pumpAndSettle(const Duration(milliseconds: 1000)); - test(editor.getCurrentEditorState()); + await test(editor.getCurrentEditorState()); } } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart index 43320509ce..c2e00a4b48 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart @@ -13,6 +13,8 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + final finder = find.text(gettingStarted, findRichText: true); + await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2)); // create a new document const pageName = 'Test Document'; diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart new file mode 100644 index 0000000000..39f8bfd4f6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart @@ -0,0 +1,453 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const avaliableLink = 'https://appflowy.io/', + unavailableLink = 'www.thereIsNoting.com'; + + Future preparePage(WidgetTester tester, {String? pageName}) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: pageName); + await tester.editor.tapLineOfEditorAt(0); + } + + Future pasteLink(WidgetTester tester, String link) async { + await getIt() + .setData(ClipboardServiceData(plainText: link)); + + /// paste the link + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + Future pasteAs( + WidgetTester tester, + String link, + PasteMenuType type, { + Duration waitTime = const Duration(milliseconds: 500), + }) async { + await pasteLink(tester, link); + final convertToMentionButton = find.text(type.title); + await tester.tapButton(convertToMentionButton); + await tester.pumpAndSettle(waitTime); + } + + void checkUrl(Node node, String link) { + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': link, + 'attributes': {'href': link}, + } + ]); + } + + void checkMention(Node node, String link) { + final delta = node.delta!; + final insert = (delta.first as TextInsert).text; + final attributes = delta.first.attributes; + expect(insert, MentionBlockKeys.mentionChar); + final mention = + attributes?[MentionBlockKeys.mention] as Map; + expect(mention[MentionBlockKeys.type], MentionType.externalLink.name); + expect(mention[MentionBlockKeys.url], avaliableLink); + } + + void checkBookmark(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + void checkEmbed(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + group('Paste as URL', () { + Future pasteAndTurnInto( + WidgetTester tester, + String link, + String title, + ) async { + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + + /// hover link and turn into mention + await tester.hoverOnWidget( + find.byType(LinkHoverTrigger), + onHover: () async { + final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(turnintoButton); + final convertToButton = find.text(title); + await tester.tapButton(convertToButton); + await tester.pumpAndSettle(Duration(seconds: 1)); + }, + ); + } + + testWidgets('paste a link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link and turn into mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toMention.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link and turn into bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toBookmark.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link and turn into embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toEmbed.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + }); + + group('Paste as Mention', () { + Future pasteAsMention(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.mention); + + String getMentionLink(Node node) { + final insert = node.delta?.first as TextInsert?; + final mention = insert?.attributes?[MentionBlockKeys.mention] + as Map?; + return mention?[MentionBlockKeys.url] ?? ''; + } + + Future hoverMentionAndClick( + WidgetTester tester, + String command, + ) async { + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final errorPreview = find.byType(MentionLinkErrorPreview); + expect(errorPreview, findsOneWidget); + final convertButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(convertButton); + final menuButton = find.text(command); + await tester.tapButton(menuButton); + }, + ); + } + + testWidgets('paste a link as mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste as mention and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final preview = find.byType(MentionLinkPreview); + if (!preview.hasFound) { + final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(copyButton); + } else { + final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(moreOptionButton); + final copyButton = + find.text(MentionLinktMenuCommand.copyLink.title); + await tester.tapButton(copyButton); + } + }, + ); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste as error mention and turninto url', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toURL.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste as error mention and turninto embed', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toEmbed.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste as error mention and turninto bookmark', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toBookmark.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste as error mention and remove link', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.removeLink.title, + ); + node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + + group('Paste as Bookmark', () { + Future pasteAsBookmark(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.bookmark); + + Future hoverAndClick( + WidgetTester tester, + LinkPreviewMenuCommand command, + ) async { + final bookmark = find.byType(CustomLinkPreviewBlockComponent); + expect(bookmark, findsOneWidget); + await tester.hoverOnWidget( + bookmark, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to embed', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste a link as bookmark and replace link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.replace); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + await tester.enterText(find.byType(TextFormField), unavailableLink); + await tester.tapButton(find.text(LocaleKeys.button_replace.tr())); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, unavailableLink); + }); + + testWidgets('paste a link as bookmark and remove link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink); + final node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + group('Paste as Embed', () { + Future pasteAsEmbed(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.embed); + + Future hoverAndConvert( + WidgetTester tester, + LinkEmbedConvertCommand command, + ) async { + final embed = find.byType(LinkEmbedBlockComponent); + expect(embed, findsOneWidget); + await tester.hoverOnWidget( + embed, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to bookmark', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart index cbc634cf02..6ec12287a8 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart @@ -76,13 +76,12 @@ void main() { LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, - LocaleKeys.document_slashMenu_name_bulletedList.tr(): + LocaleKeys.editor_bulletedListShortForm.tr(): BulletedListBlockKeys.type, - LocaleKeys.document_slashMenu_name_numberedList.tr(): + LocaleKeys.editor_numberedListShortForm.tr(): NumberedListBlockKeys.type, LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, - LocaleKeys.document_slashMenu_name_todoList.tr(): - TodoListBlockKeys.type, + LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type, LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, }; @@ -117,13 +116,12 @@ void main() { LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, - LocaleKeys.document_slashMenu_name_bulletedList.tr(): + LocaleKeys.editor_bulletedListShortForm.tr(): BulletedListBlockKeys.type, - LocaleKeys.document_slashMenu_name_numberedList.tr(): + LocaleKeys.editor_numberedListShortForm.tr(): NumberedListBlockKeys.type, LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, - LocaleKeys.document_slashMenu_name_todoList.tr(): - TodoListBlockKeys.type, + LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type, LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, }; diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart index bd0fd18c50..de1cb880a5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -47,5 +48,41 @@ void main() { expect(editorState.selection!.start.offset, 0); }); + + testWidgets('select and delete text', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create a new document + await tester.createNewPageWithNameUnderParent(); + + /// input text + final editor = tester.editor; + final editorState = editor.getCurrentEditorState(); + + const inputText = 'Test for text selection and deletion'; + final texts = inputText.split(' '); + await editor.tapLineOfEditorAt(0); + await tester.ime.insertText(inputText); + + /// selecte and delete + int index = 0; + while (texts.isNotEmpty) { + final text = texts.removeAt(0); + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: index), + end: Position(path: [0], offset: index + text.length), + ), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + index++; + } + + /// excpete the text value is correct + final node = editorState.getNodeAtPath([0])!; + final nodeText = node.delta?.toPlainText() ?? ''; + expect(nodeText, ' ' * (index - 1)); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart index a05545753e..bc0671834b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart' as document_with_multi_image_block_test; import 'document_with_simple_table_test.dart' as document_with_simple_table_test; +import 'document_link_preview_test.dart' as document_link_preview_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -28,4 +29,5 @@ void main() { document_find_menu_test.main(); document_toolbar_test.main(); document_with_simple_table_test.main(); + document_link_preview_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart index c3a086626f..f455cd479d 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -1,5 +1,19 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -8,24 +22,33 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + Future selectText(WidgetTester tester, String text) async { + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ), + ); + } + + Future prepareForToolbar(WidgetTester tester, String text) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(text); + await selectText(tester, text); + } + group('document toolbar:', () { testWidgets('font family', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(); - - await tester.editor.tapLineOfEditorAt(0); - const text = 'font family'; - await tester.ime.insertText(text); - await tester.editor.updateSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: text.length, - ), - ); + await prepareForToolbar(tester, 'font family'); + // tap more options button + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m); // tap the font family button final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey); await tester.tapButton(fontFamilyButton); @@ -46,5 +69,302 @@ void main() { abel, ); }); + + testWidgets('heading 1~3', (tester) async { + const text = 'heading'; + await prepareForToolbar(tester, text); + + Future testChangeHeading( + FlowySvgData svg, + String title, + int level, + ) async { + /// tap suggestions item + final suggestionsButton = find.byKey(kSuggestionsItemKey); + await tester.tapButton(suggestionsButton); + + /// tap item + await tester.ensureVisible(find.byFlowySvg(svg)); + await tester.tapButton(find.byFlowySvg(svg)); + + /// check the type of node is [HeadingBlockKeys.type] + await selectText(tester, text); + final editorState = tester.editor.getCurrentEditorState(); + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!, + nodeLevel = node.attributes[HeadingBlockKeys.level]!; + expect(node.type, HeadingBlockKeys.type); + expect(nodeLevel, level); + + /// show toolbar again + await selectText(tester, text); + + /// the text of suggestions item should be changed + expect( + find.descendant(of: suggestionsButton, matching: find.text(title)), + findsOneWidget, + ); + } + + await testChangeHeading( + FlowySvgs.type_h1_m, + LocaleKeys.document_toolbar_h1.tr(), + 1, + ); + + await testChangeHeading( + FlowySvgs.type_h2_m, + LocaleKeys.document_toolbar_h2.tr(), + 2, + ); + await testChangeHeading( + FlowySvgs.type_h3_m, + LocaleKeys.document_toolbar_h3.tr(), + 3, + ); + }); + + testWidgets('toggle 1~3', (tester) async { + const text = 'toggle'; + await prepareForToolbar(tester, text); + + Future testChangeToggle( + FlowySvgData svg, + String title, + int? level, + ) async { + /// tap suggestions item + final suggestionsButton = find.byKey(kSuggestionsItemKey); + await tester.tapButton(suggestionsButton); + + /// tap item + await tester.ensureVisible(find.byFlowySvg(svg)); + await tester.tapButton(find.byFlowySvg(svg)); + + /// check the type of node is [HeadingBlockKeys.type] + await selectText(tester, text); + final editorState = tester.editor.getCurrentEditorState(); + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!, + nodeLevel = node.attributes[ToggleListBlockKeys.level]; + expect(node.type, ToggleListBlockKeys.type); + expect(nodeLevel, level); + + /// show toolbar again + await selectText(tester, text); + + /// the text of suggestions item should be changed + expect( + find.descendant(of: suggestionsButton, matching: find.text(title)), + findsOneWidget, + ); + } + + await testChangeToggle( + FlowySvgs.type_toggle_list_m, + LocaleKeys.editor_toggleListShortForm.tr(), + null, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h1_m, + LocaleKeys.editor_toggleHeading1ShortForm.tr(), + 1, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h2_m, + LocaleKeys.editor_toggleHeading2ShortForm.tr(), + 2, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h3_m, + LocaleKeys.editor_toggleHeading3ShortForm.tr(), + 3, + ); + }); + + testWidgets('toolbar will not rebuild after click item', (tester) async { + const text = 'Test rebuilding'; + await prepareForToolbar(tester, text); + Finder toolbar = find.byType(DesktopFloatingToolbar); + Element toolbarElement = toolbar.evaluate().first; + final elementHashcode = toolbarElement.hashCode; + final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m), + underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m), + italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m); + + /// tap format buttons + await tester.tapButton(boldButton); + await tester.tapButton(underlineButton); + await tester.tapButton(italicButton); + toolbar = find.byType(DesktopFloatingToolbar); + toolbarElement = toolbar.evaluate().first; + + /// check if the toolbar is not rebuilt + expect(elementHashcode, toolbarElement.hashCode); + final editorState = tester.editor.getCurrentEditorState(); + + /// check text formats + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline), + true, + ); + }); + }); + + group('document toolbar: link', () { + String? getLinkFromNode(Node node) { + for (final insert in node.delta!) { + final link = insert.attributes?.href; + if (link != null) return link; + } + return null; + } + + bool isPageLink(Node node) { + for (final insert in node.delta!) { + final isPage = insert.attributes?.isPage; + if (isPage == true) return true; + } + return false; + } + + String getNodeText(Node node) { + for (final insert in node.delta!) { + if (insert is TextInsert) return insert.text; + } + return ''; + } + + testWidgets('insert link and remove link', (tester) async { + const text = 'insert link', link = 'https://test.appflowy.cloud'; + await prepareForToolbar(tester, text); + + final toolbar = find.byType(DesktopFloatingToolbar); + expect(toolbar, findsOneWidget); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + final createLinkMenu = find.byType(LinkCreateMenu); + expect(createLinkMenu, findsOneWidget); + + /// test esc to close + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + expect(toolbar, findsNothing); + + /// show toolbar again + await tester.editor.tapLineOfEditorAt(0); + await selectText(tester, text); + await tester.tapButton(linkButton); + + /// insert link + final textField = find.descendant( + of: createLinkMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + Node node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), link); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + final hoverMenu = find.byType(LinkHoverMenu); + expect(hoverMenu, findsOneWidget); + + /// copy link + final copyButton = find.descendant( + of: hoverMenu, + matching: find.byFlowySvg(FlowySvgs.toolbar_link_m), + ); + await tester.tapButton(copyButton); + final clipboardContent = await getIt().getData(); + final plainText = clipboardContent.plainText; + expect(plainText, link); + + /// remove link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); + node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), null); + }); + + testWidgets('insert link and edit link', (tester) async { + const text = 'edit link', + link = 'https://test.appflowy.cloud', + afterText = '$text after'; + await prepareForToolbar(tester, text); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + + /// search for page and select it + final textField = find.descendant( + of: find.byType(LinkCreateMenu), + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, gettingStarted); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + Node node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), true); + expect(getLinkFromNode(node) == link, false); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + + /// click edit button to show LinkEditMenu + final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); + await tester.tapButton(editButton); + final linkEditMenu = find.byType(LinkEditMenu); + expect(linkEditMenu, findsOneWidget); + + /// change the link text + final titleField = find.descendant( + of: linkEditMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(titleField, afterText); + await tester.pumpAndSettle(); + await tester.tapButton( + find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)), + ); + final linkField = find.ancestor( + of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()), + matching: find.byType(TextFormField), + ); + await tester.enterText(linkField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + /// apply the change + final applyButton = + find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr()); + await tester.tapButton(applyButton); + + node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), false); + expect(getLinkFromNode(node), link); + expect(getNodeText(node), afterText); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart index 45f688bd58..67e0149cd1 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart @@ -1,9 +1,11 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -32,9 +34,15 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); + // tap the more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), + ); + await tester.tapButton(moreOptionButton); + // tap the inline math equation button - final inlineMathEquationButton = find.findFlowyTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), + final inlineMathEquationButton = find.text( + LocaleKeys.document_toolbar_equation.tr(), ); await tester.tapButton(inlineMathEquationButton); @@ -77,10 +85,15 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); - // tap the inline math equation button - var inlineMathEquationButton = find.findFlowyTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), + // tap the more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), ); + await tester.tapButton(moreOptionButton); + + // tap the inline math equation button + final inlineMathEquationButton = + find.byFlowySvg(FlowySvgs.type_formula_m); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block @@ -92,17 +105,7 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: 1), ); - // expect to the see the inline math equation button is highlighted - inlineMathEquationButton = find.descendant( - of: find.findFlowyTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), - ), - matching: find.byType(SVGIconItemWidget), - ); - expect( - tester.widget(inlineMathEquationButton).isHighlight, - isTrue, - ); + await tester.tapButton(moreOptionButton); // cancel the format await tester.tapButton(inlineMathEquationButton); @@ -133,10 +136,15 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); - // tap the inline math equation button - final inlineMathEquationButton = find.findFlowyTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), + // tap the more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), ); + await tester.tapButton(moreOptionButton); + + // tap the inline math equation button + final inlineMathEquationButton = + find.byFlowySvg(FlowySvgs.type_formula_m); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block @@ -163,5 +171,55 @@ void main() { lessThan(5), ); }); + + testWidgets('insert inline math equation by shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'insert inline math equation by shortcut', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a inline page + const formula = 'E = MC ^ 2'; + await tester.ime.insertText(formula); + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: formula.length), + ); + + // mock key event + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyE, + isShiftPressed: true, + isControlPressed: true, + ); + + // expect to see the math equation block + final inlineMathEquation = find.byType(InlineMathEquation); + expect(inlineMathEquation, findsOneWidget); + + await tester.editor.tapLineOfEditorAt(0); + const text = 'Hello World'; + await tester.ime.insertText(text); + + final inlineText = find.textContaining(text, findRichText: true); + expect(inlineText, findsOneWidget); + + // the text should be in the same line with the math equation + final inlineMathEquationPosition = tester.getRect(inlineMathEquation); + final textPosition = tester.getRect(inlineText); + // allow 5px difference + expect( + (textPosition.top - inlineMathEquationPosition.top).abs(), + lessThan(5), + ); + expect( + (textPosition.bottom - inlineMathEquationPosition.bottom).abs(), + lessThan(5), + ); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart index 68ad7db7e5..d8b0784a39 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart @@ -48,7 +48,7 @@ void main() { await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_photoGallery.tr(), - offset: 80, + offset: 100, ); expect(find.byType(MultiImageBlockComponent), findsOneWidget); expect(find.byType(MultiImagePlaceholder), findsOneWidget); @@ -146,7 +146,7 @@ void main() { await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_photoGallery.tr(), - offset: 80, + offset: 100, ); expect(find.byType(MultiImageBlockComponent), findsOneWidget); expect(find.byType(MultiImagePlaceholder), findsOneWidget); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart index 8eb47fc15f..c4aa289855 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -85,16 +86,10 @@ void main() { ), ); - await tester.tapButton(find.byType(HeadingPopup)); - await tester.pumpAndSettle(); - - expect( - find.byType(HeadingButton), - findsNWidgets(3), - ); + await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m)); // tap the H1 button - await tester.tapButton(find.byType(HeadingButton).at(0)); + await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0)); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart index dd40470ab9..2236f03960 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; @@ -7,11 +5,9 @@ import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flutter/services.dart'; +import 'package:flowy_svg/flowy_svg.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/base.dart'; import '../../shared/common_operations.dart'; @@ -215,17 +211,12 @@ void main() { } }); - testWidgets('Update page custom icon in title bar', (tester) async { + testWidgets('Update page custom image icon in title bar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// prepare local image - final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); - final tempDirectory = await getTemporaryDirectory(); - final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); - final imageFile = File(localImagePath) - ..writeAsBytesSync(imagePath.buffer.asUint8List()); - final iconData = EmojiIconData.custom(imageFile.path); + final iconData = await tester.prepareImageIcon(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { @@ -259,4 +250,97 @@ void main() { ); } }); + + testWidgets('Update page custom svg icon in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// prepare local image + final iconData = await tester.prepareSvgIcon(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its icon + await tester.updatePageIconInTitleBarByName( + name: value.name, + layout: value, + icon: iconData, + ); + + tester.expectViewHasIcon( + value.name, + value, + iconData, + ); + + tester.expectViewTitleHasIcon( + value.name, + value, + iconData, + ); + } + }); + + testWidgets('Update page custom svg icon in title bar by pasting a link', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// prepare local image + const testIconLink = + 'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg'; + + /// create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + /// update its icon + await tester.updatePageIconInTitleBarByPasteALink( + name: value.name, + layout: value, + iconLink: testIconLink, + ); + + /// check if there is a svg in page + final pageName = tester.findPageName( + value.name, + layout: value, + ); + final imageInPage = find.descendant( + of: pageName, + matching: find.byType(SvgPicture), + ); + expect(imageInPage, findsOneWidget); + + /// check if there is a svg in title + final imageInTitle = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.byWidgetPredicate((w) { + if (w is! SvgPicture) return false; + final loader = w.bytesLoader; + if (loader is! SvgFileLoader) return false; + return loader.file.path.endsWith('.svg'); + }), + ); + expect(imageInTitle, findsOneWidget); + } + }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart index 554a6eecbf..1a4e57078f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:appflowy/plugins/emoji/emoji_handler.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -39,4 +41,110 @@ void main() { expect(find.byType(EmojiSelectionMenu), findsOneWidget); }); }); + + group('insert emoji by colon', () { + Future createNewDocumentAndShowEmojiList( + WidgetTester tester, { + String? search, + }) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(); + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(':${search ?? 'a'}'); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + testWidgets('insert with click', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// click first emoji item + await tester.tapButton(emojiButtons.first); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with arrow and enter', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + + /// tap arrow down and arrow up + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); + + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with searching', (tester) async { + await createNewDocumentAndShowEmojiList(tester, search: 's'); + + /// search for `smiling eyes`, IME is not working, use keyboard input + final searchText = [ + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyG, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyY, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyS, + ]; + + for (final key in searchText) { + await tester.simulateKeyEvent(key); + } + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(firstNode.delta!.toPlainText().contains('😄'), true); + }); + + testWidgets('start searching with sapce', (tester) async { + await createNewDocumentAndShowEmojiList(tester, search: ' '); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsNothing); + }); + }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart index 9a4fe30815..8c3c29ab77 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart @@ -1,12 +1,16 @@ +import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; +import '../document/document_with_database_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -18,7 +22,7 @@ void main() { // mock the file picker final path = await mockSaveFilePath( - p.join(context.applicationDataDirectory, 'test.md'), + p.join(context.applicationDataDirectory, 'test.zip'), ); // click the share button and select markdown await tester.tapShareButton(); @@ -28,10 +32,14 @@ void main() { tester.expectToExportSuccess(); final file = File(path); - final isExist = file.existsSync(); - expect(isExist, true); - final markdown = file.readAsStringSync(); - expect(markdown, expectedMarkdown); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.md')) { + final markdown = utf8.decode(entry.content); + expect(markdown, expectedMarkdown); + } + } }); testWidgets( @@ -57,7 +65,7 @@ void main() { final path = await mockSaveFilePath( p.join( context.applicationDataDirectory, - '${shareButtonState.view.name}.md', + '${shareButtonState.view.name}.zip', ), ); @@ -69,10 +77,44 @@ void main() { tester.expectToExportSuccess(); final file = File(path); - final isExist = file.existsSync(); - expect(isExist, true); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.md')) { + final markdown = utf8.decode(entry.content); + expect(markdown, expectedMarkdown); + } + } }, ); + + testWidgets('share the markdown with database', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await insertLinkedDatabase(tester, ViewLayoutPB.Grid); + + // mock the file picker + final path = await mockSaveFilePath( + p.join(context.applicationDataDirectory, 'test.zip'), + ); + // click the share button and select markdown + await tester.tapShareButton(); + await tester.tapMarkdownButton(); + + // expect to see the success dialog + tester.expectToExportSuccess(); + + final file = File(path); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + bool hasCsvFile = false; + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.csv')) { + hasCsvFile = true; + } + } + expect(hasCsvFile, true); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart index f7d94e8b4a..836cfe4ccd 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart @@ -13,7 +13,6 @@ void main() { hotkeys_test.main(); emoji_shortcut_test.main(); hotkeys_test.main(); - emoji_shortcut_test.main(); share_markdown_test.main(); import_files_test.main(); zoom_in_out_test.main(); diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart new file mode 100644 index 0000000000..2b348d3a2e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test At Menu'; + + group('at menu', () { + testWidgets('show at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); + + testWidgets('create subpage with at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile(title); + await tester.editor.tapLineOfEditorAt(0); + const subpageName = 'Subpage'; + await tester.ime.insertText('[[$subpageName'); + await tester.pumpAndSettle(); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tapButton(actionWidgets.first); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0]); + assert(firstNode != null); + expect(firstNode!.delta?.toPlainText().contains('['), false); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart index e19896b310..90d5ca6d0d 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart @@ -1,8 +1,11 @@ import 'package:integration_test/integration_test.dart'; +import 'at_menu_test.dart' as at_menu; +import 'at_menu_test.dart' as at_menu_test; import 'page_style_test.dart' as page_style_test; import 'plus_menu_test.dart' as plus_menu_test; import 'simple_table_test.dart' as simple_table_test; +import 'slash_menu_test.dart' as slash_menu; import 'title_test.dart' as title_test; import 'toolbar_test.dart' as toolbar_test; @@ -13,6 +16,9 @@ void main() { title_test.main(); page_style_test.main(); plus_menu_test.main(); + at_menu_test.main(); simple_table_test.main(); toolbar_test.main(); + slash_menu.main(); + at_menu.main(); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart index 1b94c65436..da7c7e92e7 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart @@ -1,15 +1,12 @@ -import 'dart:io'; - +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; -import 'package:flutter/services.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; -import 'package:path_provider/path_provider.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; @@ -18,16 +15,11 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document title:', () { - testWidgets('update page custom icon in title bar', (tester) async { + testWidgets('update page custom image icon in title bar', (tester) async { await tester.launchInAnonymousMode(); /// prepare local image - final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); - final tempDirectory = await getTemporaryDirectory(); - final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); - final imageFile = File(localImagePath) - ..writeAsBytesSync(imagePath.buffer.asUint8List()); - final iconData = EmojiIconData.custom(imageFile.path); + final iconData = await tester.prepareImageIcon(); /// create an empty page await tester @@ -50,16 +42,63 @@ void main() { /// check result final documentPage = find.byType(MobileDocumentScreen); - final rawEmojiIconWidget = find + final rawEmojiIconFinder = find .descendant( of: documentPage, matching: find.byType(RawEmojiIconWidget), ) - .evaluate() - .first - .widget as RawEmojiIconWidget; + .last; + final rawEmojiIconWidget = + rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; final iconDataInWidget = rawEmojiIconWidget.emoji; expect(iconDataInWidget.type, FlowyIconType.custom); + final imageFinder = + find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image)); + expect(imageFinder, findsOneWidget); + }); + + testWidgets('update page custom svg icon in title bar', (tester) async { + await tester.launchInAnonymousMode(); + + /// prepare local image + final iconData = await tester.prepareSvgIcon(); + + /// create an empty page + await tester + .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey)); + + /// show Page style page + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + final pageStyleIcon = find.byType(PageStyleIcon); + final iconInPageStyleIcon = find.descendant( + of: pageStyleIcon, + matching: find.byType(RawEmojiIconWidget), + ); + expect(iconInPageStyleIcon, findsNothing); + + /// show icon picker + await tester.tapButton(pageStyleIcon); + + /// upload custom icon + await tester.pickImage(iconData); + + /// check result + final documentPage = find.byType(MobileDocumentScreen); + final rawEmojiIconFinder = find + .descendant( + of: documentPage, + matching: find.byType(RawEmojiIconWidget), + ) + .last; + final rawEmojiIconWidget = + rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; + final iconDataInWidget = rawEmojiIconWidget.emoji; + expect(iconDataInWidget.type, FlowyIconType.custom); + final svgFinder = find.descendant( + of: rawEmojiIconFinder, + matching: find.byType(SvgPicture), + ); + expect(svgFinder, findsOneWidget); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart index bdd84f9098..b54c543f7e 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -85,5 +88,32 @@ void main() { equals(Selection.collapsed(Position(path: [2]))), ); }); + + const title = 'Test Plus Menu'; + testWidgets('show plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart new file mode 100644 index 0000000000..11031d2b71 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test Slash Menu'; + + group('slash menu', () { + testWidgets('show slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + final menuWidget = find.byType(MobileSelectionMenuWidget); + expect(menuWidget, findsOneWidget); + final items = + (menuWidget.evaluate().first.widget as MobileSelectionMenuWidget) + .items; + int i = 0; + for (final item in items) { + final localItem = mobileItems[i]; + expect(item.name, localItem.name); + i++; + } + }); + + testWidgets('search by slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + const searchText = 'Heading'; + await tester.ime.insertText(searchText); + final itemWidgets = find.byType(MobileSelectionMenuItemWidget); + int number = 0; + for (final item in mobileItems) { + if (item is MobileSelectionMenuItem) { + for (final childItem in item.children) { + if (childItem.name + .toLowerCase() + .contains(searchText.toLowerCase())) { + number++; + } + } + } else { + if (item.name.toLowerCase().contains(searchText.toLowerCase())) { + number++; + } + } + } + expect(itemWidgets, findsNWidgets(number)); + }); + + testWidgets('tap to show submenu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile(title); + await tester.editor.tapLineOfEditorAt(0); + final listview = find.descendant( + of: find.byType(MobileSelectionMenuWidget), + matching: find.byType(ListView), + ); + for (final item in mobileItems) { + if (item is! MobileSelectionMenuItem) continue; + await tester.editor.showSlashMenu(); + await tester.scrollUntilVisible( + find.text(item.name), + 50, + scrollable: listview, + duration: const Duration(milliseconds: 250), + ); + await tester.tap(find.text(item.name)); + final childrenLength = ((listview.evaluate().first.widget as ListView) + .childrenDelegate as SliverChildListDelegate) + .children + .length; + expect(childrenLength, item.children.length); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 76d2d82b4a..d7a505d152 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -50,6 +50,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'emoji.dart'; @@ -65,12 +67,10 @@ extension CommonOperations on WidgetTester { } else { // cloud version final anonymousButton = find.byType(SignInAnonymousButtonV2); - await tapButton(anonymousButton); + await tapButton(anonymousButton, warnIfMissed: true); } - if (Platform.isWindows) { - await pumpAndSettle(const Duration(milliseconds: 200)); - } + await pumpAndSettle(const Duration(milliseconds: 200)); } Future tapContinousAnotherWay() async { @@ -615,7 +615,7 @@ extension CommonOperations on WidgetTester { ); final distanceY = getCenter(to).dy - getCenter(from).dx; await drag(from, Offset(0, distanceY)); - await pumpAndSettle(); + await pumpAndSettle(const Duration(seconds: 1)); } // tap the button with [FlowySvgData] @@ -677,6 +677,25 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } + Future updatePageIconInTitleBarByPasteALink({ + required String name, + required ViewLayoutPB layout, + required String iconLink, + }) async { + await openPage( + name, + layout: layout, + ); + final title = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(name), + ); + await tapButton(title); + await tapButton(find.byType(EmojiPickerButton)); + await pasteImageLinkAsIcon(iconLink); + await pumpAndSettle(); + } + Future openNotificationHub({int tabIndex = 0}) async { final finder = find.descendant( of: find.byType(NotificationButton), @@ -935,6 +954,45 @@ extension CommonOperations on WidgetTester { ), ); } + + Future prepareImageIcon() async { + final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final imageFile = File(localImagePath) + ..writeAsBytesSync(imagePath.buffer.asUint8List()); + return EmojiIconData.custom(imageFile.path); + } + + Future prepareSvgIcon() async { + final imagePath = await rootBundle.load('assets/test/images/sample.svg'); + final tempDirectory = await getTemporaryDirectory(); + final localImagePath = p.join(tempDirectory.path, 'sample.svg'); + final imageFile = File(localImagePath) + ..writeAsBytesSync(imagePath.buffer.asUint8List()); + return EmojiIconData.custom(imageFile.path); + } + + /// create new page and show slash menu + Future createPageAndShowSlashMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showSlashMenu(); + } + + /// create new page and show at menu + Future createPageAndShowAtMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showAtMenu(); + } + + /// create new page and show plus menu + Future createPageAndShowPlusMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showPlusMenu(); + } } extension SettingsFinder on CommonFinders { 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 676c96f042..970965f294 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -1,17 +1,9 @@ import 'dart:io'; -import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; -import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; -import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; -import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; -import 'package:flutter/gestures.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/calculations/calculation_type_ext.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; @@ -27,10 +19,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart'; @@ -44,6 +37,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; @@ -76,6 +70,8 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; @@ -90,6 +86,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text_input.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; // Non-exported member of the table_calendar library @@ -943,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(const Duration(milliseconds: 200)); } + Future changeFieldWidth(String fieldName, double width) async { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + await hoverOnWidget( + field, + onHover: () async { + final dragHandle = find.descendant( + of: field, + matching: find.byType(DragToExpandLine), + ); + await drag(dragHandle, Offset(width - getSize(field).width, 0)); + await pumpAndSettle(const Duration(milliseconds: 200)); + }, + ); + } + + double getFieldWidth(String fieldName) { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + + return getSize(field).width; + } + Future findDateEditor(dynamic matcher) async { final finder = find.byType(DateCellEditor); expect(finder, matcher); @@ -1464,6 +1488,7 @@ extension AppFlowyDatabaseTest on WidgetTester { ); await tapButton(button); + await tapButtonWithName(LocaleKeys.button_delete.tr()); } Future dragDropRescheduleCalendarEvent() async { @@ -1571,7 +1596,7 @@ extension AppFlowyDatabaseTest on WidgetTester { of: textField, matching: find.byWidgetPredicate( (widget) => - widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m, + widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s, ), ), ); diff --git a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart index 491ac9432c..398a3f9657 100644 --- a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart @@ -307,9 +307,11 @@ class EditorOperations { Future openTurnIntoMenu(Path path) async { await hoverAndClickOptionMenuButton(path); await tester.tapButton( - find.findTextInFlowyText( - LocaleKeys.document_plugins_optionAction_turnInto.tr(), - ), + find + .findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_turnInto.tr(), + ) + .first, ); await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu)); } diff --git a/frontend/appflowy_flutter/integration_test/shared/emoji.dart b/frontend/appflowy_flutter/integration_test/shared/emoji.dart index 0f45098b23..cccd00a3f6 100644 --- a/frontend/appflowy_flutter/integration_test/shared/emoji.dart +++ b/frontend/appflowy_flutter/integration_test/shared/emoji.dart @@ -1,20 +1,24 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_uploader.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; -import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'base.dart'; +import 'common_operations.dart'; extension EmojiTestExtension on WidgetTester { Future tapEmoji(String emoji) async { @@ -90,7 +94,7 @@ extension EmojiTestExtension on WidgetTester { final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget; dropTargetWidget.onDragDone?.call( DropDoneDetails( - files: [XFile(icon.emoji)], + files: [DropItemFile(icon.emoji)], localPosition: Offset.zero, globalPosition: Offset.zero, ), @@ -104,4 +108,37 @@ extension EmojiTestExtension on WidgetTester { ); await tapButton(confirmButton); } + + Future pasteImageLinkAsIcon(String link) async { + final pickTab = find.byType(PickerTab); + expect(pickTab, findsOneWidget); + await pumpAndSettle(); + + /// switch to custom tab + final iconTab = find.descendant( + of: pickTab, + matching: find.text(PickerTabType.custom.tr), + ); + expect(iconTab, findsOneWidget); + await tapButton(iconTab); + + // mock the clipboard + await getIt() + .setData(ClipboardServiceData(plainText: link)); + + // paste the link + await simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await pumpAndSettle(const Duration(seconds: 5)); + + /// confirm to upload + final confirmButton = find.descendant( + of: find.byType(IconUploader), + matching: find.byType(PrimaryRoundedButton), + ); + await tapButton(confirmButton); + } } diff --git a/frontend/appflowy_flutter/integration_test/shared/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart index 7e9fe4bc38..3b9ef0d75c 100644 --- a/frontend/appflowy_flutter/integration_test/shared/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/expectation.dart @@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; @@ -252,16 +253,19 @@ extension Expectation on WidgetTester { ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.custom) { + final isSvg = data.emoji.endsWith('.svg'); if (isURL(data.emoji)) { final image = find.descendant( of: pageName, - matching: find.byType(FlowyNetworkImage), + matching: isSvg + ? find.byType(FlowyNetworkSvg) + : find.byType(FlowyNetworkImage), ); expect(image, findsOneWidget); } else { final image = find.descendant( of: pageName, - matching: find.byType(Image), + matching: isSvg ? find.byType(SvgPicture) : find.byType(Image), ); expect(image, findsOneWidget); } @@ -290,16 +294,26 @@ extension Expectation on WidgetTester { ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.custom) { + final isSvg = data.emoji.endsWith('.svg'); if (isURL(data.emoji)) { final image = find.descendant( of: find.byType(ViewTitleBar), - matching: find.byType(FlowyNetworkImage), + matching: isSvg + ? find.byType(FlowyNetworkSvg) + : find.byType(FlowyNetworkImage), ); expect(image, findsOneWidget); } else { final image = find.descendant( of: find.byType(ViewTitleBar), - matching: find.byType(Image), + matching: isSvg + ? find.byWidgetPredicate((w) { + if (w is! SvgPicture) return false; + final loader = w.bytesLoader; + if (loader is! SvgFileLoader) return false; + return loader.file.path.endsWith('.svg'); + }) + : find.byType(Image), ); expect(image, findsOneWidget); } diff --git a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart deleted file mode 100644 index ec1891dea8..0000000000 --- a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:appflowy/ai/service/error.dart'; -import 'package:appflowy/ai/service/openai_client.dart'; -import 'package:appflowy/ai/service/text_completion.dart'; -import 'package:http/http.dart' as http; -import 'package:mocktail/mocktail.dart'; - -class MyMockClient extends Mock implements http.Client { - @override - Future send(http.BaseRequest request) async { - final requestType = request.method; - final requestUri = request.url; - - if (requestType == 'POST' && - requestUri == OpenAIRequestType.textCompletion.uri) { - final responseHeaders = { - 'content-type': 'text/event-stream', - }; - final responseBody = Stream.fromIterable([ - utf8.encode( - '{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}', - ), - utf8.encode('\n'), - utf8.encode('[DONE]'), - ]); - - // Return a mocked response with the expected data - return http.StreamedResponse(responseBody, 200, headers: responseHeaders); - } - - // Return an error response for any other request - return http.StreamedResponse(const Stream.empty(), 404); - } -} - -class MockOpenAIRepository extends HttpOpenAIRepository { - MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient()); - - @override - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - bool useAction = false, - }) async { - final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); - final response = await client.send(request); - - String previousSyntax = ''; - if (response.statusCode == 200) { - await for (final chunk in response.stream - .transform(const Utf8Decoder()) - .transform(const LineSplitter())) { - await onStart(); - final data = chunk.trim().split('data: '); - if (data[0] != '[DONE]') { - final response = TextCompletionResponse.fromJson( - json.decode(data[0]), - ); - if (response.choices.isNotEmpty) { - final text = response.choices.first.text; - if (text == previousSyntax && text == '\n') { - continue; - } - await onProcess(response); - previousSyntax = response.choices.first.text; - } - } else { - await onEnd(); - } - } - } - } -} diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index aade7bb4c9..bfc5efedde 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -79,7 +79,7 @@ extension AppFlowySettings on WidgetTester { // Enable editing username final editUsernameFinder = find.descendant( of: find.byType(AccountUserProfile), - matching: find.byFlowySvg(FlowySvgs.edit_s), + matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m), ); await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index e4e87805cd..4b7ed5d639 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - app_links (0.0.1): + - app_links (0.0.2): - Flutter - appflowy_backend (0.0.1): - Flutter @@ -66,6 +66,8 @@ PODS: - permission_handler_apple (9.3.0): - Flutter - ReachabilitySwift (5.0.0) + - saver_gallery (0.0.1): + - Flutter - SDWebImage (5.14.2): - SDWebImage/Core (= 5.14.2) - SDWebImage/Core (5.14.2) @@ -79,7 +81,7 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - super_native_extensions (0.0.1): @@ -90,6 +92,7 @@ PODS: - Flutter - webview_flutter_wkwebview (0.0.1): - Flutter + - FlutterMacOS DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) @@ -108,13 +111,14 @@ DEPENDENCIES: - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - saver_gallery (from `.symlinks/plugins/saver_gallery/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: trunk: @@ -159,23 +163,25 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + saver_gallery: + :path: ".symlinks/plugins/saver_gallery/ios" sentry_flutter: :path: ".symlinks/plugins/sentry_flutter/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite: - :path: ".symlinks/plugins/sqflite/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" super_native_extensions: :path: ".symlinks/plugins/super_native_extensions/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: c5161ac5ab5383ad046884568b4b91cb52df5d91 + app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 @@ -186,7 +192,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: d5929033778cc4991a187e4e1a85396fa4f59b3a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 @@ -194,17 +200,18 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - webview_flutter_wkwebview: 45a041c7831641076618876de3ba75c712860c6b + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca diff --git a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift index 70693e4a8c..b636303481 100644 --- a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift +++ b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index d1afb2a9db..5d6a52bd2e 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -1,75 +1,78 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - - CFBundleName - AppFlowy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLName - - CFBundleURLSchemes - - appflowy-flutter - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - FLTEnableImpeller - - LSRequiresIPhoneOS - - NSAppTransportSecurity - NSAllowsArbitraryLoads - + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + + CFBundleName + AppFlowy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FLTEnableImpeller + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryUsageDescription + AppFlowy needs access to your photos to let you add images to your documents + NSPhotoLibraryAddUsageDescription + AppFlowy needs access to your photos to let you add images to your photo library + UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + AppFlowy needs access to your camera to let you add images to your documents from + camera + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportsDocumentBrowser + + UIViewControllerBasedStatusBarAppearance + - NSPhotoLibraryUsageDescription - AppFlowy needs access to your photos to let you add images to your documents - UIApplicationSupportsIndirectInputEvents - - NSCameraUsageDescription - AppFlowy needs access to your camera to let you add images to your documents from camera - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportsDocumentBrowser - - UIViewControllerBasedStatusBarAppearance - - - + \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/ai/ai.dart b/frontend/appflowy_flutter/lib/ai/ai.dart index a6fa49ef68..9bfeeb4e00 100644 --- a/frontend/appflowy_flutter/lib/ai/ai.dart +++ b/frontend/appflowy_flutter/lib/ai/ai.dart @@ -1,6 +1,12 @@ +export 'service/ai_entities.dart'; +export 'service/ai_prompt_input_bloc.dart'; +export 'service/appflowy_ai_service.dart'; +export 'service/error.dart'; +export 'service/ai_model_state_notifier.dart'; +export 'service/select_model_bloc.dart'; export 'widgets/loading_indicator.dart'; export 'widgets/prompt_input/action_buttons.dart'; -export 'widgets/prompt_input/desktop_input_text_field.dart'; +export 'widgets/prompt_input/desktop_prompt_text_field.dart'; export 'widgets/prompt_input/file_attachment_list.dart'; export 'widgets/prompt_input/layout_define.dart'; export 'widgets/prompt_input/mention_page_bottom_sheet.dart'; @@ -9,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart'; export 'widgets/prompt_input/predefined_format_buttons.dart'; export 'widgets/prompt_input/select_sources_bottom_sheet.dart'; export 'widgets/prompt_input/select_sources_menu.dart'; +export 'widgets/prompt_input/select_model_menu.dart'; export 'widgets/prompt_input/send_button.dart'; diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_client.dart b/frontend/appflowy_flutter/lib/ai/service/ai_client.dart deleted file mode 100644 index a5c40c0569..0000000000 --- a/frontend/appflowy_flutter/lib/ai/service/ai_client.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.dart'; - -import 'error.dart'; -import 'text_completion.dart'; - -abstract class AIRepository { - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - bool useAction = false, - }); - - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }); - - Future, AIError>> generateImage({ - required String prompt, - int n = 1, - }); -} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart new file mode 100644 index 0000000000..b08fadb7f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:equatable/equatable.dart'; + +class AIStreamEventPrefix { + static const data = 'data:'; + static const error = 'error:'; + static const metadata = 'metadata:'; + static const start = 'start:'; + static const finish = 'finish:'; + static const comment = 'comment:'; + static const aiResponseLimit = 'AI_RESPONSE_LIMIT'; + static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT'; + static const aiMaxRequired = 'AI_MAX_REQUIRED:'; + static const localAINotReady = 'LOCAL_AI_NOT_READY'; + static const localAIDisabled = 'LOCAL_AI_DISABLED'; +} + +enum AiType { + cloud, + local; + + bool get isCloud => this == cloud; + bool get isLocal => this == local; +} + +class PredefinedFormat extends Equatable { + const PredefinedFormat({ + required this.imageFormat, + required this.textFormat, + }); + + final ImageFormat imageFormat; + final TextFormat? textFormat; + + PredefinedFormatPB toPB() { + return PredefinedFormatPB( + imageFormat: switch (imageFormat) { + ImageFormat.text => ResponseImageFormatPB.TextOnly, + ImageFormat.image => ResponseImageFormatPB.ImageOnly, + ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage, + }, + textFormat: switch (textFormat) { + TextFormat.paragraph => ResponseTextFormatPB.Paragraph, + TextFormat.bulletList => ResponseTextFormatPB.BulletedList, + TextFormat.numberedList => ResponseTextFormatPB.NumberedList, + TextFormat.table => ResponseTextFormatPB.Table, + _ => null, + }, + ); + } + + @override + List get props => [imageFormat, textFormat]; +} + +enum ImageFormat { + text, + image, + textAndImage; + + bool get hasText => this == text || this == textAndImage; + + FlowySvgData get icon { + return switch (this) { + ImageFormat.text => FlowySvgs.ai_text_s, + ImageFormat.image => FlowySvgs.ai_image_s, + ImageFormat.textAndImage => FlowySvgs.ai_text_image_s, + }; + } + + String get i18n { + return switch (this) { + ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(), + ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(), + ImageFormat.textAndImage => + LocaleKeys.chat_changeFormat_textAndImage.tr(), + }; + } +} + +enum TextFormat { + paragraph, + bulletList, + numberedList, + table; + + FlowySvgData get icon { + return switch (this) { + TextFormat.paragraph => FlowySvgs.ai_paragraph_s, + TextFormat.bulletList => FlowySvgs.ai_list_s, + TextFormat.numberedList => FlowySvgs.ai_number_list_s, + TextFormat.table => FlowySvgs.ai_table_s, + }; + } + + String get i18n { + return switch (this) { + TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(), + TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(), + TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(), + TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(), + }; + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart new file mode 100644 index 0000000000..0bcc41da9b --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -0,0 +1,181 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:universal_platform/universal_platform.dart'; + +typedef OnModelStateChangedCallback = void Function(AiType, bool, String); +typedef OnAvailableModelsChangedCallback = void Function( + List, + AIModelPB?, +); + +class AIModelStateNotifier { + AIModelStateNotifier({required this.objectId}) + : _localAIListener = + UniversalPlatform.isDesktop ? LocalAIStateListener() : null, + _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) { + _startListening(); + _init(); + } + + final String objectId; + final LocalAIStateListener? _localAIListener; + final AIModelSwitchListener _aiModelSwitchListener; + LocalAIPB? _localAIState; + AvailableModelsPB? _availableModels; + + // callbacks + final List _stateChangedCallbacks = []; + final List + _availableModelsChangedCallbacks = []; + + void _startListening() { + if (UniversalPlatform.isDesktop) { + _localAIListener?.start( + stateCallback: (state) async { + _localAIState = state; + _notifyStateChanged(); + + if (state.state == RunningStatePB.Running || + state.state == RunningStatePB.Stopped) { + await _loadAvailableModels(); + _notifyAvailableModelsChanged(); + } + }, + ); + } + + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) async { + final updatedModels = _availableModels?.deepCopy() + ?..selectedModel = model; + _availableModels = updatedModels; + _notifyAvailableModelsChanged(); + + if (model.isLocal && UniversalPlatform.isDesktop) { + await _loadLocalAiState(); + } + _notifyStateChanged(); + }, + ); + } + + void _init() async { + await Future.wait([_loadLocalAiState(), _loadAvailableModels()]); + _notifyStateChanged(); + _notifyAvailableModelsChanged(); + } + + void addListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.add(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.add(onAvailableModelsChanged); + } + } + + void removeListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.remove(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.remove(onAvailableModelsChanged); + } + } + + Future dispose() async { + _stateChangedCallbacks.clear(); + _availableModelsChangedCallbacks.clear(); + await _localAIListener?.stop(); + await _aiModelSwitchListener.stop(); + } + + (AiType, String, bool) getState() { + if (UniversalPlatform.isMobile) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final availableModels = _availableModels; + final localAiState = _localAIState; + + if (availableModels == null) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + if (localAiState == null) { + Log.warn("Cannot get local AI state"); + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + if (!availableModels.selectedModel.isLocal) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final editable = localAiState.state == RunningStatePB.Running; + final hintText = editable + ? LocaleKeys.chat_inputLocalAIMessageHint.tr() + : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); + + return (AiType.local, hintText, editable); + } + + (List, AIModelPB?) getAvailableModels() { + final availableModels = _availableModels; + if (availableModels == null) { + return ([], null); + } + return (availableModels.models, availableModels.selectedModel); + } + + void _notifyAvailableModelsChanged() { + final (models, selectedModel) = getAvailableModels(); + for (final callback in _availableModelsChangedCallbacks) { + callback(models, selectedModel); + } + } + + void _notifyStateChanged() { + final (type, hintText, isEditable) = getState(); + for (final callback in _stateChangedCallbacks) { + callback(type, isEditable, hintText); + } + } + + Future _loadAvailableModels() { + final payload = AvailableModelsQueryPB(source: objectId); + return AIEventGetAvailableModels(payload).send().fold( + (models) => _availableModels = models, + (err) => Log.error("Failed to get available models: $err"), + ); + } + + Future _loadLocalAiState() { + return AIEventGetLocalAIState().send().fold( + (localAIState) => _localAIState = localAIState, + (error) => Log.error("Failed to get local AI state: $error"), + ); + } +} + +extension AiModelExtension on AIModelPB { + bool get isDefault { + return name == "Auto"; + } + + String get i18n { + return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart similarity index 54% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart rename to frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart index fdbcbb4cc0..95854ab047 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart +++ b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart @@ -1,31 +1,31 @@ import 'dart:async'; +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'ai_entities.dart'; + part 'ai_prompt_input_bloc.freezed.dart'; class AIPromptInputBloc extends Bloc { - AIPromptInputBloc() - : _listener = LocalLLMListener(), - super(AIPromptInputState.initial()) { + AIPromptInputBloc({ + required String objectId, + required PredefinedFormat? predefinedFormat, + }) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), + super(AIPromptInputState.initial(predefinedFormat)) { _dispatch(); _startListening(); _init(); } - final LocalLLMListener _listener; + final AIModelStateNotifier aiModelStateNotifier; @override Future close() async { - await _listener.stop(); + await aiModelStateNotifier.dispose(); return super.close(); } @@ -33,38 +33,37 @@ class AIPromptInputBloc extends Bloc { on( (event, emit) { event.when( - updateChatState: (LocalAIChatPB chatState) { - // Only user enable chat with file and the plugin is already running - final supportChatWithFile = chatState.fileEnabled && - chatState.pluginState.state == RunningStatePB.Running; - - final aiType = chatState.pluginState.state == RunningStatePB.Running - ? AIType.localAI - : AIType.appflowyAI; + updateAIState: (aiType, editable, hintText) { emit( state.copyWith( aiType: aiType, - supportChatWithFile: supportChatWithFile, - chatState: chatState, + editable: editable, + hintText: hintText, ), ); }, - updatePluginState: (LocalAIPluginStatePB chatState) { - final fileEnabled = state.chatState?.fileEnabled ?? false; - final supportChatWithFile = - fileEnabled && chatState.state == RunningStatePB.Running; - - final aiType = chatState.state == RunningStatePB.Running - ? AIType.localAI - : AIType.appflowyAI; - + toggleShowPredefinedFormat: () { + final showPredefinedFormats = !state.showPredefinedFormats; + final predefinedFormat = + showPredefinedFormats && state.predefinedFormat == null + ? PredefinedFormat( + imageFormat: ImageFormat.text, + textFormat: TextFormat.paragraph, + ) + : null; emit( state.copyWith( - supportChatWithFile: supportChatWithFile, - aiType: aiType, + showPredefinedFormats: showPredefinedFormats, + predefinedFormat: predefinedFormat, ), ); }, + updatePredefinedFormat: (format) { + if (!state.showPredefinedFormats) { + return; + } + emit(state.copyWith(predefinedFormat: format)); + }, attachFile: (filePath, fileName) { final newFile = ChatFile.fromFilePath(filePath); if (newFile != null) { @@ -105,29 +104,16 @@ class AIPromptInputBloc extends Bloc { } void _startListening() { - _listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(AIPromptInputEvent.updatePluginState(pluginState)); - } - }, - chatStateCallback: (chatState) { - if (!isClosed) { - add(AIPromptInputEvent.updateChatState(chatState)); - } + aiModelStateNotifier.addListener( + onStateChanged: (aiType, editable, hintText) { + add(AIPromptInputEvent.updateAIState(aiType, editable, hintText)); }, ); } void _init() { - AIEventGetLocalAIChatState().send().fold( - (chatState) { - if (!isClosed) { - add(AIPromptInputEvent.updateChatState(chatState)); - } - }, - Log.error, - ); + final (aiType, hintText, isEditable) = aiModelStateNotifier.getState(); + add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText)); } Map consumeMetadata() { @@ -146,12 +132,17 @@ class AIPromptInputBloc extends Bloc { @freezed class AIPromptInputEvent with _$AIPromptInputEvent { - const factory AIPromptInputEvent.updateChatState( - LocalAIChatPB chatState, - ) = _UpdateChatState; - const factory AIPromptInputEvent.updatePluginState( - LocalAIPluginStatePB chatState, - ) = _UpdatePluginState; + const factory AIPromptInputEvent.updateAIState( + AiType aiType, + bool editable, + String hintText, + ) = _UpdateAIState; + + const factory AIPromptInputEvent.toggleShowPredefinedFormat() = + _ToggleShowPredefinedFormat; + const factory AIPromptInputEvent.updatePredefinedFormat( + PredefinedFormat format, + ) = _UpdatePredefinedFormat; const factory AIPromptInputEvent.attachFile( String filePath, String fileName, @@ -165,25 +156,25 @@ class AIPromptInputEvent with _$AIPromptInputEvent { @freezed class AIPromptInputState with _$AIPromptInputState { const factory AIPromptInputState({ - required AIType aiType, + required AiType aiType, required bool supportChatWithFile, - required LocalAIChatPB? chatState, + required bool showPredefinedFormats, + required PredefinedFormat? predefinedFormat, required List attachedFiles, required List mentionedPages, + required bool editable, + required String hintText, }) = _AIPromptInputState; - factory AIPromptInputState.initial() => const AIPromptInputState( - aiType: AIType.appflowyAI, + factory AIPromptInputState.initial(PredefinedFormat? format) => + AIPromptInputState( + aiType: AiType.cloud, supportChatWithFile: false, - chatState: null, + showPredefinedFormats: format != null, + predefinedFormat: format, attachedFiles: [], mentionedPages: [], + editable: true, + hintText: '', ); } - -enum AIType { - appflowyAI, - localAI; - - bool get isLocalAI => this == localAI; -} diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart index 560d6d3ef1..39487652f8 100644 --- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -3,184 +3,202 @@ import 'dart:ffi'; import 'dart:isolate'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; +import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart' as fixnum; -import 'ai_client.dart'; +import 'ai_entities.dart'; import 'error.dart'; -import 'text_completion.dart'; -enum AskAIAction { - summarize, - fixSpelling, - improveWriting, - makeItLonger; +enum LocalAIStreamingState { + notReady, + disabled, +} - String get toInstruction => switch (this) { - summarize => 'Tl;dr', - fixSpelling => 'Correct this to standard English:', - improveWriting => 'Rewrite this in your own words:', - makeItLonger => 'Make this text longer:', - }; - - String prompt(String input) => switch (this) { - summarize => '$input\n\n$toInstruction', - _ => "$toInstruction\n\n$input", - }; - - static AskAIAction from(int index) => switch (index) { - 0 => summarize, - 1 => fixSpelling, - 2 => improveWriting, - 3 => makeItLonger, - _ => fixSpelling - }; - - String get name => switch (this) { - summarize => LocaleKeys.document_plugins_smartEditSummarize.tr(), - fixSpelling => LocaleKeys.document_plugins_smartEditFixSpelling.tr(), - improveWriting => - LocaleKeys.document_plugins_smartEditImproveWriting.tr(), - makeItLonger => LocaleKeys.document_plugins_smartEditMakeLonger.tr(), - }; +abstract class AIRepository { + Future streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }); } class AppFlowyAIService implements AIRepository { @override - Future, AIError>> generateImage({ - required String prompt, - int n = 1, - }) { - throw UnimplementedError(); - } - - @override - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - bool useAction = false, - }) { - throw UnimplementedError(); - } - - @override - Future streamCompletion({ + Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], required CompletionTypePB completionType, required Future Function() onStart, - required Future Function(String text) onProcess, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, }) async { - final stream = CompletionStream( - onStart, - onProcess, - onEnd, - onError, + final stream = AppFlowyCompletionStream( + onStart: onStart, + processMessage: processMessage, + processAssistMessage: processAssistMessage, + processError: onError, + onLocalAIStreamingStateChange: onLocalAIStreamingStateChange, + onEnd: onEnd, ); - final List ragIds = []; - if (objectId != null) { - ragIds.add(objectId); - } + + final records = history.map((record) => record.toPB()).toList(); final payload = CompleteTextPB( text: text, completionType: completionType, + format: format?.toPB(), streamPort: fixnum.Int64(stream.nativePort), - objectId: objectId ?? "", - ragIds: ragIds, + objectId: objectId ?? '', + ragIds: [ + if (objectId != null) objectId, + ...sourceIds, + ].unique(), + history: records, ); - // ignore: unawaited_futures - AIEventCompleteText(payload).send(); - return stream; - } -} - -CompletionTypePB completionTypeFromInt(AskAIAction action) { - switch (action) { - case AskAIAction.summarize: - return CompletionTypePB.MakeShorter; - case AskAIAction.fixSpelling: - return CompletionTypePB.SpellingAndGrammar; - case AskAIAction.improveWriting: - return CompletionTypePB.ImproveWriting; - case AskAIAction.makeItLonger: - return CompletionTypePB.MakeLonger; - } -} - -class CompletionStream { - CompletionStream( - Future Function() onStart, - Future Function(String text) onProcess, - Future Function() onEnd, - void Function(AIError error) onError, - ) { - _port.handler = _controller.add; - _subscription = _controller.stream.listen( - (event) async { - if (event == "AI_RESPONSE_LIMIT") { - onError( - AIError( - message: LocaleKeys.sideBar_aiResponseLimit.tr(), - code: AIErrorCode.aiResponseLimitExceeded, - ), - ); - } - - if (event == "AI_IMAGE_RESPONSE_LIMIT") { - onError( - AIError( - message: LocaleKeys.sideBar_aiImageResponseLimit.tr(), - code: AIErrorCode.aiImageResponseLimitExceeded, - ), - ); - } - - if (event.startsWith("start:")) { - await onStart(); - } - - if (event.startsWith("data:")) { - await onProcess(event.substring(5)); - } - - if (event.startsWith("finish:")) { - await onEnd(); - } - - if (event.startsWith("error:")) { - onError(AIError(message: event.substring(6))); - } + return AIEventCompleteText(payload).send().fold( + (task) => (task.taskId, stream), + (error) { + Log.error(error); + return null; }, ); } +} + +abstract class CompletionStream { + CompletionStream({ + required this.onStart, + required this.processMessage, + required this.processAssistMessage, + required this.processError, + required this.onLocalAIStreamingStateChange, + required this.onEnd, + }); + + final Future Function() onStart; + final Future Function(String text) processMessage; + final Future Function(String text) processAssistMessage; + final void Function(AIError error) processError; + final void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange; + final Future Function() onEnd; +} + +class AppFlowyCompletionStream extends CompletionStream { + AppFlowyCompletionStream({ + required super.onStart, + required super.processMessage, + required super.processAssistMessage, + required super.processError, + required super.onEnd, + required super.onLocalAIStreamingStateChange, + }) { + _startListening(); + } final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; int get nativePort => _port.sendPort.nativePort; + void _startListening() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) async { + await _handleEvent(event); + }, + ); + } + Future dispose() async { await _controller.close(); await _subscription.cancel(); _port.close(); } - StreamSubscription listen( - void Function(String event)? onData, - ) { - return _controller.stream.listen(onData); + Future _handleEvent(String event) async { + // Check simple matches first + if (event == AIStreamEventPrefix.aiResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_textLimitReachedDescription.tr(), + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + return; + } + + if (event == AIStreamEventPrefix.aiImageResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_imageLimitReachedDescription.tr(), + code: AIErrorCode.aiImageResponseLimitExceeded, + ), + ); + return; + } + + // Otherwise, parse out prefix:content + if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.aiMaxRequired.length), + code: AIErrorCode.other, + ), + ); + } else if (event.startsWith(AIStreamEventPrefix.start)) { + await onStart(); + } else if (event.startsWith(AIStreamEventPrefix.data)) { + await processMessage( + event.substring(AIStreamEventPrefix.data.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.comment)) { + await processAssistMessage( + event.substring(AIStreamEventPrefix.comment.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.finish)) { + await onEnd(); + } else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.disabled, + ); + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.notReady, + ); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.error.length), + code: AIErrorCode.other, + ), + ); + } else { + Log.debug('Unknown AI event: $event'); + } } } diff --git a/frontend/appflowy_flutter/lib/ai/service/error.dart b/frontend/appflowy_flutter/lib/ai/service/error.dart index 866a383e0b..0c98e83172 100644 --- a/frontend/appflowy_flutter/lib/ai/service/error.dart +++ b/frontend/appflowy_flutter/lib/ai/service/error.dart @@ -7,7 +7,7 @@ part 'error.g.dart'; class AIError with _$AIError { const factory AIError({ required String message, - @Default(AIErrorCode.other) AIErrorCode code, + required AIErrorCode code, }) = _AIError; factory AIError.fromJson(Map json) => diff --git a/frontend/appflowy_flutter/lib/ai/service/openai_client.dart b/frontend/appflowy_flutter/lib/ai/service/openai_client.dart deleted file mode 100644 index f29024a914..0000000000 --- a/frontend/appflowy_flutter/lib/ai/service/openai_client.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:http/http.dart' as http; - -import 'ai_client.dart'; -import 'error.dart'; -import 'text_completion.dart'; - -enum OpenAIRequestType { - textCompletion, - textEdit, - imageGenerations; - - Uri get uri { - switch (this) { - case OpenAIRequestType.textCompletion: - return Uri.parse('https://api.openai.com/v1/completions'); - case OpenAIRequestType.textEdit: - return Uri.parse('https://api.openai.com/v1/chat/completions'); - case OpenAIRequestType.imageGenerations: - return Uri.parse('https://api.openai.com/v1/images/generations'); - } - } -} - -class HttpOpenAIRepository implements AIRepository { - const HttpOpenAIRepository({ - required this.client, - required this.apiKey, - }); - - final http.Client client; - final String apiKey; - - Map get headers => { - 'Authorization': 'Bearer $apiKey', - 'Content-Type': 'application/json', - }; - - @override - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - bool useAction = false, - }) async { - final parameters = { - 'model': 'gpt-3.5-turbo-instruct', - 'prompt': prompt, - 'suffix': suffix, - 'max_tokens': maxTokens, - 'temperature': temperature, - 'stream': true, - }; - - final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); - request.headers.addAll(headers); - request.body = jsonEncode(parameters); - - final response = await client.send(request); - - // NEED TO REFACTOR. - // WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE? - // AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE? - int syntax = 0; - var previousSyntax = ''; - if (response.statusCode == 200) { - await for (final chunk in response.stream - .transform(const Utf8Decoder()) - .transform(const LineSplitter())) { - syntax += 1; - if (!useAction) { - if (syntax == 3) { - await onStart(); - continue; - } else if (syntax < 3) { - continue; - } - } else { - if (syntax == 2) { - await onStart(); - continue; - } else if (syntax < 2) { - continue; - } - } - final data = chunk.trim().split('data: '); - if (data.length > 1) { - if (data[1] != '[DONE]') { - final response = TextCompletionResponse.fromJson( - json.decode(data[1]), - ); - if (response.choices.isNotEmpty) { - final text = response.choices.first.text; - if (text == previousSyntax && text == '\n') { - continue; - } - await onProcess(response); - previousSyntax = response.choices.first.text; - } - } else { - await onEnd(); - } - } - } - } else { - final body = await response.stream.bytesToString(); - onError( - AIError.fromJson(json.decode(body)['error']), - ); - } - return; - } - - @override - Future, AIError>> generateImage({ - required String prompt, - int n = 1, - }) async { - final parameters = { - 'prompt': prompt, - 'n': n, - 'size': '512x512', - }; - - try { - final response = await client.post( - OpenAIRequestType.imageGenerations.uri, - headers: headers, - body: json.encode(parameters), - ); - - if (response.statusCode == 200) { - final data = json.decode( - utf8.decode(response.bodyBytes), - )['data'] as List; - final urls = data - .map((e) => e.values) - .expand((e) => e) - .map((e) => e.toString()) - .toList(); - return FlowyResult.success(urls); - } else { - return FlowyResult.failure( - AIError.fromJson(json.decode(response.body)['error']), - ); - } - } catch (error) { - return FlowyResult.failure(AIError(message: error.toString())); - } - } - - @override - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }) { - throw UnimplementedError(); - } -} diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart new file mode 100644 index 0000000000..7ad52b9ec4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'select_model_bloc.freezed.dart'; + +class SelectModelBloc extends Bloc { + SelectModelBloc({ + required AIModelStateNotifier aiModelStateNotifier, + }) : _aiModelStateNotifier = aiModelStateNotifier, + super(SelectModelState.initial(aiModelStateNotifier)) { + on( + (event, emit) { + event.when( + selectModel: (model) { + AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: _aiModelStateNotifier.objectId, + selectedModel: model, + ), + ).send(); + + emit(state.copyWith(selectedModel: model)); + }, + didLoadModels: (models, selectedModel) { + emit( + SelectModelState( + models: models, + selectedModel: selectedModel, + ), + ); + }, + ); + }, + ); + + _aiModelStateNotifier.addListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); + } + + final AIModelStateNotifier _aiModelStateNotifier; + + @override + Future close() async { + _aiModelStateNotifier.removeListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); + await super.close(); + } + + void _onAvailableModelsChanged( + List models, + AIModelPB? selectedModel, + ) { + if (!isClosed) { + add(SelectModelEvent.didLoadModels(models, selectedModel)); + } + } +} + +@freezed +class SelectModelEvent with _$SelectModelEvent { + const factory SelectModelEvent.selectModel( + AIModelPB model, + ) = _SelectModel; + + const factory SelectModelEvent.didLoadModels( + List models, + AIModelPB? selectedModel, + ) = _DidLoadModels; +} + +@freezed +class SelectModelState with _$SelectModelState { + const factory SelectModelState({ + required List models, + required AIModelPB? selectedModel, + }) = _SelectModelState; + + factory SelectModelState.initial(AIModelStateNotifier notifier) { + final (models, selectedModel) = notifier.getAvailableModels(); + return SelectModelState( + models: models, + selectedModel: selectedModel, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/text_completion.dart b/frontend/appflowy_flutter/lib/ai/service/text_completion.dart deleted file mode 100644 index 4c22325588..0000000000 --- a/frontend/appflowy_flutter/lib/ai/service/text_completion.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'text_completion.freezed.dart'; -part 'text_completion.g.dart'; - -@freezed -class TextCompletionChoice with _$TextCompletionChoice { - factory TextCompletionChoice({ - required String text, - required int index, - // ignore: invalid_annotation_target - @JsonKey(name: 'finish_reason') String? finishReason, - }) = _TextCompletionChoice; - - factory TextCompletionChoice.fromJson(Map json) => - _$TextCompletionChoiceFromJson(json); -} - -@freezed -class TextCompletionResponse with _$TextCompletionResponse { - const factory TextCompletionResponse({ - required List choices, - }) = _TextCompletionResponse; - - factory TextCompletionResponse.fromJson(Map json) => - _$TextCompletionResponseFromJson(json); -} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart index f1c5cf0cfb..3a9c96b255 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart @@ -16,8 +16,7 @@ class AILoadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); - return Padding( - padding: const EdgeInsets.only(top: 8.0), + return SelectionContainer.disabled( child: SizedBox( height: 20, child: SeparatedRow( diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart deleted file mode 100644 index ed95da1509..0000000000 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; -import 'package:extended_text_field/extended_text_field.dart'; -import 'package:flutter/material.dart'; - -import 'mentioned_page_text_span.dart'; - -class PromptInputTextField extends StatelessWidget { - const PromptInputTextField({ - super.key, - required this.cubit, - required this.textController, - required this.textFieldFocusNode, - required this.contentPadding, - this.hintText = "", - }); - - final ChatInputControlCubit cubit; - final TextEditingController textController; - final FocusNode textFieldFocusNode; - final EdgeInsetsGeometry contentPadding; - final String hintText; - - @override - Widget build(BuildContext context) { - return ExtendedTextField( - controller: textController, - focusNode: textFieldFocusNode, - decoration: InputDecoration( - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: contentPadding, - hintText: hintText, - hintStyle: AIChatUILayout.inputHintTextStyle(context), - isCollapsed: true, - isDense: true, - ), - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - minLines: 1, - maxLines: null, - style: Theme.of(context).textTheme.bodyMedium, - specialTextSpanBuilder: PromptInputTextSpanBuilder( - inputControlCubit: cubit, - specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart similarity index 57% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart rename to frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart index 375fb79e12..a2676f2c15 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -1,53 +1,54 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../layout_define.dart'; - -class DesktopChatInput extends StatefulWidget { - const DesktopChatInput({ +class DesktopPromptInput extends StatefulWidget { + const DesktopPromptInput({ super.key, - required this.chatId, required this.isStreaming, + required this.textController, required this.onStopStreaming, required this.onSubmitted, + required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, + this.hideDecoration = false, + this.hideFormats = false, + this.extraBottomActionButton, }); - final String chatId; final bool isStreaming; + final TextEditingController textController; final void Function() onStopStreaming; final void Function(String, PredefinedFormat?, Map) onSubmitted; + final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; + final bool hideDecoration; + final bool hideFormats; + final Widget? extraBottomActionButton; @override - State createState() => _DesktopChatInputState(); + State createState() => _DesktopPromptInputState(); } -class _DesktopChatInputState extends State { +class _DesktopPromptInputState extends State { final textFieldKey = GlobalKey(); final layerLink = LayerLink(); final overlayController = OverlayPortalController(); final inputControlCubit = ChatInputControlCubit(); final focusNode = FocusNode(); - final textController = TextEditingController(); - bool showPredefinedFormatSection = true; - PredefinedFormat predefinedFormat = const PredefinedFormat( - imageFormat: ImageFormat.text, - textFormat: TextFormat.bulletList, - ); late SendButtonState sendButtonState; bool isComposing = false; @@ -55,16 +56,19 @@ class _DesktopChatInputState extends State { void initState() { super.initState(); - textController.addListener(handleTextControllerChanged); - - // refresh border color on focus change and hide menu when lost focus - focusNode.addListener( - () => setState(() { - if (!focusNode.hasFocus) { - cancelMentionPage(); - } - }), - ); + widget.textController.addListener(handleTextControllerChanged); + focusNode + ..addListener( + () { + if (!widget.hideDecoration) { + setState(() {}); // refresh border color + } + if (!focusNode.hasFocus) { + cancelMentionPage(); // hide menu when lost focus + } + }, + ) + ..onKeyEvent = handleKeyEvent; updateSendButtonState(); @@ -82,7 +86,7 @@ class _DesktopChatInputState extends State { @override void dispose() { focusNode.dispose(); - textController.dispose(); + widget.textController.removeListener(handleTextControllerChanged); inputControlCubit.close(); super.dispose(); } @@ -107,20 +111,12 @@ class _DesktopChatInputState extends State { overlayChildBuilder: (context) { return PromptInputMentionPageMenu( anchor: PromptInputAnchor(textFieldKey, layerLink), - textController: textController, + textController: widget.textController, onPageSelected: handlePageSelected, ); }, child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: focusNode.hasFocus - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - width: focusNode.hasFocus ? 1.5 : 1.0, - ), - borderRadius: const BorderRadius.all(Radius.circular(12.0)), - ), + decoration: decoration(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -132,7 +128,6 @@ class _DesktopChatInputState extends State { ), child: TextFieldTapRegion( child: PromptInputFile( - chatId: widget.chatId, onDeleted: (file) => context .read() .add(AIPromptInputEvent.removeFile(file)), @@ -140,53 +135,65 @@ class _DesktopChatInputState extends State { ), ), const VSpace(4.0), - Stack( - children: [ - Container( - constraints: getTextFieldConstraints(), - child: inputTextField(), - ), - if (showPredefinedFormatSection) - Positioned.fill( - bottom: null, - child: TextFieldTapRegion( - child: Padding( - padding: - const EdgeInsetsDirectional.only(start: 8.0), - child: ChangeFormatBar( - predefinedFormat: predefinedFormat, - spacing: 4.0, - onSelectPredefinedFormat: (format) { - setState(() => predefinedFormat = format); - }, + BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + ConstrainedBox( + constraints: getTextFieldConstraints( + state.showPredefinedFormats && !widget.hideFormats, + ), + child: inputTextField(), + ), + if (state.showPredefinedFormats && !widget.hideFormats) + Positioned.fill( + bottom: null, + child: TextFieldTapRegion( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: 8.0, + ), + child: ChangeFormatBar( + showImageFormats: state.aiType.isCloud, + predefinedFormat: state.predefinedFormat, + spacing: 4.0, + onSelectPredefinedFormat: (format) => + context.read().add( + AIPromptInputEvent + .updatePredefinedFormat(format), + ), + ), + ), + ), + ), + Positioned.fill( + top: null, + child: TextFieldTapRegion( + child: _PromptBottomActions( + showPredefinedFormatBar: + state.showPredefinedFormats, + showPredefinedFormatButton: !widget.hideFormats, + onTogglePredefinedFormatSection: () => + context.read().add( + AIPromptInputEvent + .toggleShowPredefinedFormat(), + ), + onStartMention: startMentionPageFromButton, + sendButtonState: sendButtonState, + onSendPressed: handleSend, + onStopStreaming: widget.onStopStreaming, + selectedSourcesNotifier: + widget.selectedSourcesNotifier, + onUpdateSelectedSources: + widget.onUpdateSelectedSources, + extraBottomActionButton: + widget.extraBottomActionButton, ), ), ), - ), - Positioned.fill( - top: null, - child: TextFieldTapRegion( - child: _PromptBottomActions( - textController: textController, - overlayController: overlayController, - focusNode: focusNode, - showPredefinedFormats: showPredefinedFormatSection, - predefinedFormat: predefinedFormat, - onTogglePredefinedFormatSection: () { - setState(() { - showPredefinedFormatSection = - !showPredefinedFormatSection; - }); - }, - sendButtonState: sendButtonState, - onSendPressed: handleSend, - onStopStreaming: widget.onStopStreaming, - onUpdateSelectedSources: - widget.onUpdateSelectedSources, - ), - ), - ), - ], + ], + ); + }, ), ], ), @@ -196,6 +203,40 @@ class _DesktopChatInputState extends State { ); } + BoxDecoration decoration(BuildContext context) { + if (widget.hideDecoration) { + return BoxDecoration(); + } + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: focusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: focusNode.hasFocus ? 1.5 : 1.0, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ); + } + + void startMentionPageFromButton() { + if (overlayController.isShowing) { + return; + } + if (!focusNode.hasFocus) { + focusNode.requestFocus(); + } + widget.textController.text += '@'; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context + .read() + .startSearching(widget.textController.value); + overlayController.show(); + } + }); + } + void cancelMentionPage() { if (overlayController.isShowing) { inputControlCubit.reset(); @@ -206,7 +247,7 @@ class _DesktopChatInputState extends State { void updateSendButtonState() { if (widget.isStreaming) { sendButtonState = SendButtonState.streaming; - } else if (textController.text.trim().isEmpty) { + } else if (widget.textController.text.trim().isEmpty) { sendButtonState = SendButtonState.disabled; } else { sendButtonState = SendButtonState.enabled; @@ -218,9 +259,9 @@ class _DesktopChatInputState extends State { return; } final trimmedText = inputControlCubit.formatIntputText( - textController.text.trim(), + widget.textController.text.trim(), ); - textController.clear(); + widget.textController.clear(); if (trimmedText.isEmpty) { return; } @@ -228,9 +269,13 @@ class _DesktopChatInputState extends State { // get the attached files and mentioned pages final metadata = context.read().consumeMetadata(); + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + widget.onSubmitted( trimmedText, - showPredefinedFormatSection ? predefinedFormat : null, + showPredefinedFormats ? predefinedFormat : null, metadata, ); } @@ -239,17 +284,17 @@ class _DesktopChatInputState extends State { setState(() { // update whether send button is clickable updateSendButtonState(); - isComposing = !textController.value.composing.isCollapsed; + isComposing = !widget.textController.value.composing.isCollapsed; }); if (isComposing) { return; } - // handle text and selection changes ONLY when mentioning a page - // disable mention return; + + // handle text and selection changes ONLY when mentioning a page // ignore: dead_code if (!overlayController.isShowing || inputControlCubit.filterStartPosition == -1) { @@ -257,6 +302,7 @@ class _DesktopChatInputState extends State { } // handle cases where mention a page is cancelled + final textController = widget.textController; final textSelection = textController.value.selection; final isSelectingMultipleCharacters = !textSelection.isCollapsed; final isCaretBeforeStartOfRange = @@ -303,22 +349,27 @@ class _DesktopChatInputState extends State { } KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { - if (event.character == '@') { - WidgetsBinding.instance.addPostFrameCallback((_) { - inputControlCubit.startSearching(textController.value); - overlayController.show(); - }); + // if (event.character == '@') { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // inputControlCubit.startSearching(widget.textController.value); + // overlayController.show(); + // }); + // } + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + node.unfocus(); + return KeyEventResult.handled; } return KeyEventResult.ignored; } void handlePageSelected(ViewPB view) { - final newText = textController.text.replaceRange( + final newText = widget.textController.text.replaceRange( inputControlCubit.filterStartPosition, inputControlCubit.filterEndPosition, view.id, ); - textController.value = TextEditingValue( + widget.textController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: inputControlCubit.filterStartPosition + view.id.length, @@ -330,18 +381,6 @@ class _DesktopChatInputState extends State { overlayController.hide(); } - BoxConstraints getTextFieldConstraints() { - double minHeight = DesktopAIPromptSizes.textFieldMinHeight + - DesktopAIPromptSizes.actionBarSendButtonSize + - DesktopAIChatSizes.inputActionBarMargin.vertical; - double maxHeight = 300; - if (showPredefinedFormatSection) { - minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; - maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; - } - return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight); - } - Widget inputTextField() { return Shortcuts( shortcuts: buildShortcuts(), @@ -351,17 +390,27 @@ class _DesktopChatInputState extends State { link: layerLink, child: BlocBuilder( builder: (context, state) { - return PromptInputTextField( + Widget textField = PromptInputTextField( key: textFieldKey, + editable: state.editable, cubit: inputControlCubit, - textController: textController, + textController: widget.textController, textFieldFocusNode: focusNode, - contentPadding: calculateContentPadding(), - hintText: switch (state.aiType) { - AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), - AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() - }, + contentPadding: + calculateContentPadding(state.showPredefinedFormats), + hintText: state.hintText, ); + + if (!state.editable) { + textField = FlowyTooltip( + message: LocaleKeys + .settings_aiPage_keys_localAINotReadyTextFieldPrompt + .tr(), + child: textField, + ); + } + + return textField; }, ), ), @@ -369,8 +418,20 @@ class _DesktopChatInputState extends State { ); } - EdgeInsetsGeometry calculateContentPadding() { - final top = showPredefinedFormatSection + BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) { + double minHeight = DesktopAIPromptSizes.textFieldMinHeight + + DesktopAIPromptSizes.actionBarSendButtonSize + + DesktopAIChatSizes.inputActionBarMargin.vertical; + double maxHeight = 300; + if (showPredefinedFormats) { + minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; + maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; + } + return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight); + } + + EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) { + final top = showPredefinedFormats ? DesktopAIPromptSizes.predefinedFormatButtonHeight : 0.0; final bottom = DesktopAIPromptSizes.actionBarSendButtonSize + @@ -451,30 +512,89 @@ class _FocusNextItemIntent extends Intent { const _FocusNextItemIntent(); } -class _PromptBottomActions extends StatelessWidget { - const _PromptBottomActions({ +class PromptInputTextField extends StatelessWidget { + const PromptInputTextField({ + super.key, + required this.editable, + required this.cubit, required this.textController, - required this.overlayController, - required this.focusNode, - required this.sendButtonState, - required this.predefinedFormat, - required this.onTogglePredefinedFormatSection, - required this.showPredefinedFormats, - required this.onSendPressed, - required this.onStopStreaming, - required this.onUpdateSelectedSources, + required this.textFieldFocusNode, + required this.contentPadding, + this.hintText = "", }); + final ChatInputControlCubit cubit; final TextEditingController textController; - final OverlayPortalController overlayController; - final FocusNode focusNode; - final bool showPredefinedFormats; - final PredefinedFormat predefinedFormat; + final FocusNode textFieldFocusNode; + final EdgeInsetsGeometry contentPadding; + final bool editable; + final String hintText; + + @override + Widget build(BuildContext context) { + return ExtendedTextField( + controller: textController, + focusNode: textFieldFocusNode, + readOnly: !editable, + enabled: editable, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: contentPadding, + hintText: hintText, + hintStyle: inputHintTextStyle(context), + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: PromptInputTextSpanBuilder( + inputControlCubit: cubit, + specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + TextStyle? inputHintTextStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).isLightMode + ? const Color(0xFFBDC2C8) + : const Color(0xFF3C3E51), + ); + } +} + +class _PromptBottomActions extends StatelessWidget { + const _PromptBottomActions({ + required this.sendButtonState, + required this.showPredefinedFormatBar, + required this.showPredefinedFormatButton, + required this.onTogglePredefinedFormatSection, + required this.onStartMention, + required this.onSendPressed, + required this.onStopStreaming, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + this.extraBottomActionButton, + }); + + final bool showPredefinedFormatBar; + final bool showPredefinedFormatButton; final void Function() onTogglePredefinedFormatSection; + final void Function() onStartMention; final SendButtonState sendButtonState; final void Function() onSendPressed; final void Function() onStopStreaming; + final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; + final Widget? extraBottomActionButton; @override Widget build(BuildContext context) { @@ -483,18 +603,27 @@ class _PromptBottomActions extends StatelessWidget { margin: DesktopAIChatSizes.inputActionBarMargin, child: BlocBuilder( builder: (context, state) { - if (state.chatState == null) { - return Align( - alignment: AlignmentDirectional.centerEnd, - child: _sendButton(), - ); - } return Row( children: [ - _predefinedFormatButton(), + if (showPredefinedFormatButton) ...[ + _predefinedFormatButton(), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + SelectModelMenu( + aiModelStateNotifier: + context.read().aiModelStateNotifier, + ), const Spacer(), - if (state.aiType == AIType.appflowyAI) ...[ - _selectSourcesButton(context), + if (state.aiType.isCloud) ...[ + _selectSourcesButton(), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + if (extraBottomActionButton != null) ...[ + extraBottomActionButton!, const HSpace( DesktopAIChatSizes.inputActionBarButtonSpacing, ), @@ -519,15 +648,15 @@ class _PromptBottomActions extends StatelessWidget { Widget _predefinedFormatButton() { return PromptInputDesktopToggleFormatButton( - showFormatBar: showPredefinedFormats, - predefinedFormat: predefinedFormat, + showFormatBar: showPredefinedFormatBar, onTap: onTogglePredefinedFormatSection, ); } - Widget _selectSourcesButton(BuildContext context) { + Widget _selectSourcesButton() { return PromptInputDesktopSelectSourcesButton( onUpdateSelectedSources: onUpdateSelectedSources, + selectedSourcesNotifier: selectedSourcesNotifier, ); } @@ -535,21 +664,7 @@ class _PromptBottomActions extends StatelessWidget { // return PromptInputMentionButton( // iconSize: DesktopAIPromptSizes.actionBarIconSize, // buttonSize: DesktopAIPromptSizes.actionBarButtonSize, - // onTap: () { - // if (overlayController.isShowing) { - // return; - // } - // if (!focusNode.hasFocus) { - // focusNode.requestFocus(); - // } - // textController.text += '@'; - // Future.delayed(Duration.zero, () { - // context - // .read() - // .startSearching(textController.value); - // overlayController.show(); - // }); - // }, + // onTap: onStartMention, // ); // } diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart index fddde4dc69..cd68205506 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; +import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -13,11 +13,9 @@ import 'layout_define.dart'; class PromptInputFile extends StatelessWidget { const PromptInputFile({ super.key, - required this.chatId, required this.onDeleted, }); - final String chatId; final void Function(ChatFile) onDeleted; @override @@ -37,7 +35,6 @@ class PromptInputFile extends StatelessWidget { ), itemCount: files.length, itemBuilder: (context, index) => ChatFilePreview( - chatId: chatId, file: files[index], onDeleted: () => onDeleted(files[index]), ), @@ -49,13 +46,11 @@ class PromptInputFile extends StatelessWidget { class ChatFilePreview extends StatefulWidget { const ChatFilePreview({ - required this.chatId, required this.file, required this.onDeleted, super.key, }); - final String chatId; final ChatFile file; final VoidCallback onDeleted; diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart index 69fecad613..ae2dbe5f26 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart @@ -49,7 +49,9 @@ class _PromptInputMentionPageMenuState void initState() { super.initState(); Future.delayed(Duration.zero, () { - context.read().refreshViews(); + if (mounted) { + context.read().refreshViews(); + } }); } diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart index fe3b07cc13..403b978905 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart @@ -1,24 +1,22 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; +import '../../service/ai_entities.dart'; import 'layout_define.dart'; class PromptInputDesktopToggleFormatButton extends StatelessWidget { const PromptInputDesktopToggleFormatButton({ super.key, required this.showFormatBar, - required this.predefinedFormat, required this.onTap, }); final bool showFormatBar; - final PredefinedFormat predefinedFormat; final VoidCallback onTap; @override @@ -50,26 +48,31 @@ class ChangeFormatBar extends StatelessWidget { required this.predefinedFormat, required this.spacing, required this.onSelectPredefinedFormat, + this.showImageFormats = true, }); final PredefinedFormat? predefinedFormat; final double spacing; final void Function(PredefinedFormat) onSelectPredefinedFormat; + final bool showImageFormats; @override Widget build(BuildContext context) { + final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true; return SizedBox( height: DesktopAIPromptSizes.predefinedFormatButtonHeight, child: SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => HSpace(spacing), children: [ - _buildFormatButton(context, ImageFormat.text), - _buildFormatButton(context, ImageFormat.textAndImage), - _buildFormatButton(context, ImageFormat.image), - if (predefinedFormat?.imageFormat.hasText ?? true) ...[ - _buildDivider(), - _buildTextFormatButton(context, TextFormat.auto), + if (showImageFormats) ...[ + _buildFormatButton(context, ImageFormat.text), + _buildFormatButton(context, ImageFormat.textAndImage), + _buildFormatButton(context, ImageFormat.image), + ], + if (showImageFormats && showTextFormats) _buildDivider(), + if (showTextFormats) ...[ + _buildTextFormatButton(context, TextFormat.paragraph), _buildTextFormatButton(context, TextFormat.bulletList), _buildTextFormatButton(context, TextFormat.numberedList), _buildTextFormatButton(context, TextFormat.table), @@ -88,7 +91,8 @@ class ChangeFormatBar extends StatelessWidget { return; } if (format.hasText) { - final textFormat = predefinedFormat?.textFormat ?? TextFormat.auto; + final textFormat = + predefinedFormat?.textFormat ?? TextFormat.paragraph; onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: textFormat), ); @@ -100,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget { }, child: FlowyTooltip( message: format.i18n, + preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( @@ -146,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget { }, child: FlowyTooltip( message: format.i18n, + preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart new file mode 100644 index 0000000000..a611d84310 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -0,0 +1,264 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SelectModelMenu extends StatefulWidget { + const SelectModelMenu({ + super.key, + required this.aiModelStateNotifier, + }); + + final AIModelStateNotifier aiModelStateNotifier; + + @override + State createState() => _SelectModelMenuState(); +} + +class _SelectModelMenuState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SelectModelBloc( + aiModelStateNotifier: widget.aiModelStateNotifier, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + offset: Offset(-12.0, 0.0), + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + direction: PopoverDirection.topWithLeftAligned, + margin: EdgeInsets.zero, + controller: popoverController, + popupBuilder: (popoverContext) { + return SelectModelPopoverContent( + models: state.models, + selectedModel: state.selectedModel, + onSelectModel: (model) { + if (model != state.selectedModel) { + context + .read() + .add(SelectModelEvent.selectModel(model)); + } + popoverController.close(); + }, + ); + }, + child: _CurrentModelButton( + model: state.selectedModel, + onTap: () { + if (state.selectedModel != null) { + popoverController.show(); + } + }, + ), + ); + }, + ), + ); + } +} + +class SelectModelPopoverContent extends StatelessWidget { + const SelectModelPopoverContent({ + super.key, + required this.models, + required this.selectedModel, + this.onSelectModel, + }); + + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB)? onSelectModel; + + @override + Widget build(BuildContext context) { + if (models.isEmpty) { + return const SizedBox.shrink(); + } + + // separate models into local and cloud models + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (localModels.isNotEmpty) ...[ + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_localModel.tr(), + ), + const VSpace(4.0), + ], + ...localModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[ + const VSpace(8.0), + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_cloudModel.tr(), + ), + const VSpace(4.0), + ], + ...cloudModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + ], + ), + ); + } +} + +class _ModelSectionHeader extends StatelessWidget { + const _ModelSectionHeader({ + required this.title, + }); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: FlowyText( + title, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w500, + ), + ); + } +} + +class _ModelItem extends StatelessWidget { + const _ModelItem({ + required this.model, + required this.isSelected, + required this.onTap, + }); + + final AIModelPB model; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 32), + child: FlowyButton( + onTap: onTap, + margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + model.i18n, + figmaLineHeight: 20, + overflow: TextOverflow.ellipsis, + ), + if (model.desc.isNotEmpty) + FlowyText( + model.desc, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ], + ), + rightIcon: isSelected + ? FlowySvg( + FlowySvgs.check_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + ); + } +} + +class _CurrentModelButton extends StatelessWidget { + const _CurrentModelButton({ + required this.model, + required this.onTap, + }); + + final AIModelPB? model; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: AnimatedSize( + duration: const Duration(milliseconds: 50), + curve: Curves.easeInOut, + alignment: AlignmentDirectional.centerStart, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.all(4.0), + child: Row( + children: [ + Padding( + // TODO: remove this after change icon to 20px + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: Size.square(16), + ), + ), + if (model != null && !model!.isDefault) + Padding( + padding: EdgeInsetsDirectional.only(end: 2.0), + child: FlowyText( + model!.i18n, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart index b69a2210f2..1f1b2ddf4c 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart @@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; @@ -19,9 +18,11 @@ import 'select_sources_menu.dart'; class PromptInputMobileSelectSourcesButton extends StatefulWidget { const PromptInputMobileSelectSourcesButton({ super.key, + required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); + final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override @@ -36,15 +37,15 @@ class _PromptInputMobileSelectSourcesButtonState @override void initState() { super.initState(); + widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); WidgetsBinding.instance.addPostFrameCallback((_) { - cubit.updateSelectedSources( - context.read().state.selectedSourceIds, - ); + onSelectedSourcesChanged(); }); } @override void dispose() { + widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); cubit.close(); super.dispose(); } @@ -70,56 +71,49 @@ class _PromptInputMobileSelectSourcesButtonState ], child: BlocBuilder( builder: (context, state) { - return BlocListener( - listener: (context, state) { - cubit - ..updateSelectedSources(state.selectedSourceIds) - ..updateSelectedStatus(); - }, - child: FlowyButton( - margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6), - expandText: false, - text: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlowySvg( - FlowySvgs.ai_page_s, - color: Theme.of(context).iconTheme.color, - size: const Size.square(20.0), - ), - FlowySvg( - FlowySvgs.ai_source_drop_down_s, - color: Theme.of(context).hintColor, - size: const Size.square(10), - ), - ], - ), - onTap: () async { - context - .read() - .refreshSources(state.spaces, state.currentSpace); - await showMobileBottomSheet( - context, - backgroundColor: Theme.of(context).colorScheme.surface, - maxChildSize: 0.98, - enableDraggableScrollable: true, - scrollableWidgetBuilder: (_, scrollController) { - return Expanded( - child: BlocProvider.value( - value: cubit, - child: _MobileSelectSourcesSheetBody( - scrollController: scrollController, - ), - ), - ); - }, - builder: (context) => const SizedBox.shrink(), - ); - if (context.mounted) { - widget.onUpdateSelectedSources(cubit.selectedSourceIds); - } - }, + return FlowyButton( + margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6), + expandText: false, + text: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_page_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(20.0), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(10), + ), + ], ), + onTap: () async { + context + .read() + .refreshSources(state.spaces, state.currentSpace); + await showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + maxChildSize: 0.98, + enableDraggableScrollable: true, + scrollableWidgetBuilder: (_, scrollController) { + return Expanded( + child: BlocProvider.value( + value: cubit, + child: _MobileSelectSourcesSheetBody( + scrollController: scrollController, + ), + ), + ); + }, + builder: (context) => const SizedBox.shrink(), + ); + if (context.mounted) { + widget.onUpdateSelectedSources(cubit.selectedSourceIds); + } + }, ); }, ), @@ -127,6 +121,12 @@ class _PromptInputMobileSelectSourcesButtonState }, ); } + + void onSelectedSourcesChanged() { + cubit + ..updateSelectedSources(widget.selectedSourcesNotifier.value) + ..updateSelectedStatus(); + } } class _MobileSelectSourcesSheetBody extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart index df521256d8..51357e6a0b 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart @@ -2,8 +2,8 @@ import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -24,9 +24,11 @@ import 'mention_page_menu.dart'; class PromptInputDesktopSelectSourcesButton extends StatefulWidget { const PromptInputDesktopSelectSourcesButton({ super.key, + required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); + final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override @@ -42,15 +44,15 @@ class _PromptInputDesktopSelectSourcesButtonState @override void initState() { super.initState(); + widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); WidgetsBinding.instance.addPostFrameCallback((_) { - cubit.updateSelectedSources( - context.read().state.selectedSourceIds, - ); + onSelectedSourcesChanged(); }); } @override void dispose() { + widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); cubit.close(); super.dispose(); } @@ -76,51 +78,53 @@ class _PromptInputDesktopSelectSourcesButtonState ], child: BlocBuilder( builder: (context, state) { - return BlocListener( - listener: (context, state) { - cubit - ..updateSelectedSources(state.selectedSourceIds) - ..updateSelectedStatus(); + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(320, 380)), + offset: const Offset(0.0, -10.0), + direction: PopoverDirection.topWithCenterAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () { + context + .read() + .refreshSources(state.spaces, state.currentSpace); }, - child: AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(320, 380)), - offset: const Offset(0.0, -10.0), - direction: PopoverDirection.topWithCenterAligned, - margin: EdgeInsets.zero, - controller: popoverController, - onOpen: () { - context - .read() - .refreshSources(state.spaces, state.currentSpace); - }, - onClose: () { - widget.onUpdateSelectedSources(cubit.selectedSourceIds); - context - .read() - .refreshSources(state.spaces, state.currentSpace); - }, - popupBuilder: (_) { - return BlocProvider.value( - value: context.read(), - child: const _PopoverContent(), - ); - }, - child: _IndicatorButton( - onTap: () => popoverController.show(), - ), + onClose: () { + widget.onUpdateSelectedSources(cubit.selectedSourceIds); + context + .read() + .refreshSources(state.spaces, state.currentSpace); + }, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const _PopoverContent(), + ); + }, + child: _IndicatorButton( + selectedSourcesNotifier: widget.selectedSourcesNotifier, + onTap: () => popoverController.show(), ), ); }, ), ); } + + void onSelectedSourcesChanged() { + cubit + ..updateSelectedSources(widget.selectedSourcesNotifier.value) + ..updateSelectedStatus(); + } } class _IndicatorButton extends StatelessWidget { const _IndicatorButton({ + required this.selectedSourcesNotifier, required this.onTap, }); + final ValueNotifier> selectedSourcesNotifier; final VoidCallback onTap; @override @@ -141,14 +145,22 @@ class _IndicatorButton extends StatelessWidget { children: [ FlowySvg( FlowySvgs.ai_page_s, - color: Theme.of(context).iconTheme.color, + color: Theme.of(context).hintColor, ), const HSpace(2.0), - BlocBuilder( - builder: (context, state) { + ValueListenableBuilder( + valueListenable: selectedSourcesNotifier, + builder: (context, selectedSourceIds, _) { + final documentId = + context.read()?.documentId; + final label = documentId != null && + selectedSourceIds.length == 1 && + selectedSourceIds[0] == documentId + ? LocaleKeys.chat_currentPage.tr() + : selectedSourceIds.length.toString(); return FlowyText( - state.selectedSourceIds.length.toString(), - fontSize: 14, + label, + fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, ); @@ -158,7 +170,7 @@ class _IndicatorButton extends StatelessWidget { FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, - size: const Size.square(10), + size: const Size.square(8), ), ], ), diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart index 20def4ca5e..cca6e65f63 100644 --- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart @@ -1,4 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -23,6 +25,23 @@ class PromptInputSendButton extends StatelessWidget { Widget build(BuildContext context) { return FlowyIconButton( width: _buttonSize, + richTooltipText: switch (state) { + SendButtonState.streaming => TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.chat_stopTooltip.tr()} ', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: 'ESC', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ), + _ => null, + }, icon: switch (state) { SendButtonState.enabled => FlowySvg( FlowySvgs.ai_send_filled_s, diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index b9f59d6434..aefd5e5d36 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -115,4 +115,9 @@ class KVKeys { /// /// The value is a json string of [RecentIcons] static const String recentIcons = 'kRecentIcons'; + + /// The key for saving compact mode ids for node or databse view + /// + /// The value is a json list of id + static const String compactModeIds = 'compactModeIds'; } diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index 2b0bc7b345..0502e79604 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:open_filex/open_filex.dart'; import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); @@ -38,17 +39,24 @@ Future afLaunchUri( ); } + // on Linux, add http scheme to the url if it is not present + if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) { + uri = Uri.parse('https://$url'); + } + // try to launch the uri directly - bool result; - try { - result = await launcher.launchUrl( - uri, - mode: mode, - webOnlyWindowName: webOnlyWindowName, - ); - } on PlatformException catch (e) { - Log.error('Failed to open uri: $e'); - return false; + bool result = await launcher.canLaunchUrl(uri); + if (result) { + try { + result = await launcher.launchUrl( + uri, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + return false; + } } // if the uri is not a valid url, try to launch it with http scheme @@ -127,7 +135,6 @@ Future _afLaunchLocalUri( }; if (context != null && context.mounted) { showToastNotification( - context, message: message, type: result.type == ResultType.done ? ToastificationType.success diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart index 8f4d195eb7..15f3ada42e 100644 --- a/frontend/appflowy_flutter/lib/env/cloud_env.dart +++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart @@ -100,6 +100,10 @@ bool get isAuthEnabled { return false; } +bool get isLocalAuthEnabled { + return currentCloudType().isLocal; +} + /// Determines if AppFlowy Cloud is enabled. bool get isAppFlowyCloudEnabled { return currentCloudType().isAppFlowyCloudEnabled; @@ -180,7 +184,7 @@ Future useLocalServer() async { await _setAuthenticatorType(AuthenticatorType.local); } -/// Use getIt() to get the shared environment. +// Use getIt() to get the shared environment. class AppFlowyCloudSharedEnv { AppFlowyCloudSharedEnv({ required AuthenticatorType authenticatorType, @@ -247,6 +251,7 @@ Future configurationFromUri( // In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports. // This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server // in the development environment. + // If you modify following code, please update the corresponding documentation in the appflowy billing. if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) { return AppFlowyCloudConfiguration( base_url: "$baseUrl:8000", diff --git a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart index 1cc846339b..157be012b1 100644 --- a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart +++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart @@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0; const double _kDefaultHorizontalPadding = 12.0; +typedef CompareFunction = bool Function(T? left, T? right); + // Navigation shortcuts to move the selected menu items up or down. final Map _kMenuTraversalShortcuts = { @@ -86,6 +88,7 @@ class AFDropdownMenu extends StatefulWidget { this.requestFocusOnTap, this.expandedInsets, this.searchCallback, + this.selectOptionCompare, required this.dropdownMenuEntries, }); @@ -267,6 +270,11 @@ class AFDropdownMenu extends StatefulWidget { /// which contains the contents of the text input field. final SearchCallback? searchCallback; + /// Defines the compare function for the menu items. + /// + /// Defaults to null. If this is null, the menu items will be sorted by the label. + final CompareFunction? selectOptionCompare; + @override State> createState() => _AFDropdownMenuState(); } @@ -301,7 +309,16 @@ class _AFDropdownMenuState extends State> { filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); final int index = filteredEntries.indexWhere( - (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + (DropdownMenuEntry entry) { + if (widget.selectOptionCompare != null) { + return widget.selectOptionCompare!( + entry.value, + widget.initialSelection, + ); + } else { + return entry.value == widget.initialSelection; + } + }, ); if (index != -1) { _textEditingController.value = TextEditingValue( @@ -502,11 +519,11 @@ class _AFDropdownMenuState extends State> { // Simulate the focused state because the text field should always be focused // during traversal. If the menu item has a custom foreground color, the "focused" - // color will also change to foregroundColor.withOpacity(0.12). + // color will also change to foregroundColor.withValues(alpha: 0.12). effectiveStyle = entry.enabled && i == focusedIndex ? effectiveStyle.copyWith( backgroundColor: WidgetStatePropertyAll( - focusedBackgroundColor.withOpacity(0.12), + focusedBackgroundColor.withValues(alpha: 0.12), ), ) : effectiveStyle; diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart index 1480cc02e9..0527316860 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart @@ -19,14 +19,13 @@ class UserProfileBloc extends Bloc { Future _initialize(Emitter emit) async { emit(const UserProfileState.loading()); - - final workspaceOrFailure = + final latestOrFailure = await FolderEventGetCurrentWorkspaceSetting().send(); final userOrFailure = await getIt().getUser(); - final workspaceSetting = workspaceOrFailure.fold( - (workspaceSettingPB) => workspaceSettingPB, + final latest = latestOrFailure.fold( + (latestPB) => latestPB, (error) => null, ); @@ -35,13 +34,13 @@ class UserProfileBloc extends Bloc { (error) => null, ); - if (workspaceSetting == null || userProfile == null) { + if (latest == null || userProfile == null) { return emit(const UserProfileState.workspaceFailure()); } emit( UserProfileState.success( - workspaceSettings: workspaceSetting, + workspaceSettings: latest, userProfile: userProfile, ), ); @@ -59,7 +58,7 @@ class UserProfileState with _$UserProfileState { const factory UserProfileState.loading() = _Loading; const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; const factory UserProfileState.success({ - required WorkspaceSettingPB workspaceSettings, + required WorkspaceLatestPB workspaceSettings, required UserProfilePB userProfile, }) = _Success; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 0535ec76b8..318b06394a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; @@ -7,6 +8,7 @@ import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; @@ -18,6 +20,9 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -91,7 +96,7 @@ class _MobileViewPageState extends State { final body = _buildBody(context, state); if (view == null) { - return _buildApp(context, null, body); + return SizedBox.shrink(); } return MultiBlocProvider( @@ -122,6 +127,11 @@ class _MobileViewPageState extends State { create: (_) => DocumentPageStyleBloc(view: view) ..add(const DocumentPageStyleEvent.initial()), ), + if (view.layout.isDocumentView || view.layout.isDatabaseView) + BlocProvider( + create: (_) => ViewLockStatusBloc(view: view) + ..add(const ViewLockStatusEvent.initial()), + ), ], child: Builder( builder: (context) { @@ -152,6 +162,7 @@ class _MobileViewPageState extends State { title: title, appBarOpacity: _appBarOpacity, actions: actions, + view: view, ) : FlowyAppBar(title: title, actions: actions); final body = isDocument @@ -222,6 +233,8 @@ class _MobileViewPageState extends State { final isImmersiveMode = context.read().state.isImmersiveMode; + final isLocked = + context.read()?.state.isLocked ?? false; final actions = []; if (FeatureFlag.syncDocument.isOn) { @@ -240,7 +253,7 @@ class _MobileViewPageState extends State { } } - if (view.layout.isDocumentView) { + if (view.layout.isDocumentView && !isLocked) { actions.addAll([ MobileViewPageLayoutButton( view: view, @@ -270,25 +283,137 @@ class _MobileViewPageState extends State { Widget _buildTitle(BuildContext context, ViewPB? view) { final icon = view?.icon; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null && icon.value.isNotEmpty) ...[ - RawEmojiIconWidget( - emoji: icon.toEmojiIconData(), - emojiSize: 15, + return ValueListenableBuilder( + valueListenable: _appBarOpacity, + builder: (_, value, child) { + if (value < 0.99) { + return Padding( + padding: const EdgeInsets.only(left: 6.0), + child: _buildLockStatus(context, view), + ); + } + + final name = + widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? ''; + + return Opacity( + opacity: value, + child: Row( + children: [ + if (icon != null && icon.value.isNotEmpty) ...[ + RawEmojiIconWidget( + emoji: icon.toEmojiIconData(), + emojiSize: 15, + ), + const HSpace(4), + ], + Flexible( + child: FlowyText.medium( + name, + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, + ), + ), + const HSpace(4.0), + _buildLockStatusIcon(context, view), + ], ), - const HSpace(4), - ], - Expanded( - child: FlowyText.medium( - widget.fixedTitle ?? view?.name ?? widget.title ?? '', - fontSize: 15.0, - overflow: TextOverflow.ellipsis, - figmaLineHeight: 18.0, - ), - ), - ], + ); + }, + ); + } + + Widget _buildLockStatus(BuildContext context, ViewPB? view) { + if (view == null || view.layout == ViewLayoutPB.Chat) { + return const SizedBox.shrink(); + } + + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + + EditorNotification.exitEditing().post(); + } + }, + builder: (context, state) { + if (state.isLocked) { + return LockedPageStatus(); + } else if (!state.isLocked && state.lockCounter > 0) { + return ReLockedPageStatus(); + } + return const SizedBox.shrink(); + }, + ); + } + + Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) { + if (view == null || view.layout == ViewLayoutPB.Chat) { + return const SizedBox.shrink(); + } + + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + } + }, + builder: (context, state) { + if (state.isLocked) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.read().add( + const ViewLockStatusEvent.unlock(), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + right: 8, + bottom: 4.0, + ), + child: FlowySvg( + FlowySvgs.lock_page_fill_s, + blendMode: null, + ), + ), + ); + } else if (!state.isLocked && state.lockCounter > 0) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.read().add( + const ViewLockStatusEvent.lock(), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + right: 8, + bottom: 4.0, + ), + child: FlowySvg( + FlowySvgs.unlock_page_s, + color: Color(0xFF8F959E), + blendMode: null, + ), + ), + ); + } + return const SizedBox.shrink(); + }, ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart index b01f72c371..a91fbf577b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart @@ -12,6 +12,7 @@ import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -28,12 +29,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget required this.appBarOpacity, required this.title, required this.actions, + required this.view, }); final ValueListenable appBarOpacity; final Widget title; final List actions; - + final ViewPB? view; @override final Size preferredSize; @@ -43,9 +45,9 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget valueListenable: appBarOpacity, builder: (_, opacity, __) => FlowyAppBar( backgroundColor: - AppBarTheme.of(context).backgroundColor?.withOpacity(opacity), + AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity), showDivider: false, - title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title), + title: _buildTitle(context, opacity: opacity), leadingWidth: 44, leading: Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0), @@ -56,6 +58,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget ); } + Widget _buildTitle( + BuildContext context, { + required double opacity, + }) { + return title; + } + Widget _buildAppBarBackButton(BuildContext context) { return AppBarButton( padding: EdgeInsets.zero, @@ -102,6 +111,12 @@ class MobileViewPageMoreButton extends StatelessWidget { BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: view) + ..add( + ViewLockStatusEvent.initial(), + ), + ), ], child: MobileViewPageMoreBottomSheet(view: view), ), @@ -224,7 +239,7 @@ class _ImmersiveAppBarButton extends StatelessWidget { child = DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(dimension / 2.0), - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), ), child: child, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index 25541ed7d4..be134e0a92 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -14,6 +14,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -43,7 +44,8 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { }, child: ViewPageBottomSheet( view: view, - onAction: (action) async => _onAction(context, action), + onAction: (action, {arguments}) async => + _onAction(context, action, arguments), onRename: (name) { _onRename(context, name); context.pop(); @@ -56,6 +58,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { Future _onAction( BuildContext context, MobileViewBottomSheetBodyAction action, + Map? arguments, ) async { switch (action) { case MobileViewBottomSheetBodyAction.duplicate: @@ -63,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { break; case MobileViewBottomSheetBodyAction.delete: context.read().add(const ViewEvent.delete()); - context.pop(); + Navigator.of(context).pop(); break; case MobileViewBottomSheetBodyAction.addToFavorites: _addFavorite(context); @@ -107,12 +110,32 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { break; case MobileViewBottomSheetBodyAction.updatePathName: _updatePathName(context); + case MobileViewBottomSheetBodyAction.lockPage: + final isLocked = + arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ?? + false; + await _lockPage(context, isLocked: isLocked); + // context.pop(); + break; case MobileViewBottomSheetBodyAction.rename: // no need to implement, rename is handled by the onRename callback. throw UnimplementedError(); } } + Future _lockPage( + BuildContext context, { + required bool isLocked, + }) async { + if (isLocked) { + context.read().add(const ViewLockStatusEvent.lock()); + } else { + context + .read() + .add(const ViewLockStatusEvent.unlock()); + } + } + Future _publish(BuildContext context) async { final id = context.read().view.id; final lastPublishName = context.read().state.pathName; @@ -138,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { context.pop(); showToastNotification( - context, message: LocaleKeys.button_duplicateSuccessfully.tr(), ); } @@ -147,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { _toggleFavorite(context); showToastNotification( - context, message: LocaleKeys.button_favoriteSuccessfully.tr(), ); } @@ -156,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { _toggleFavorite(context); showToastNotification( - context, message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); } @@ -179,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { ), ); showToastNotification( - context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } @@ -211,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { ), ); showToastNotification( - context, message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } else { showToastNotification( - context, message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), type: ToastificationType.error, ); @@ -300,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), @@ -312,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, @@ -326,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { state.updatePathNameResult!.onSuccess( (value) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart index c1129af79d..86021ea938 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart @@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State { Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); showToastNotification( - context, message: LocaleKeys.button_duplicateSuccessfully.tr(), ); break; @@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State { .read() .add(FavoriteEvent.toggle(widget.view)); showToastNotification( - context, message: !widget.view.isFavorite ? LocaleKeys.button_favoriteSuccessfully.tr() : LocaleKeys.button_unfavoriteSuccessfully.tr(), @@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State { Navigator.pop(context); showToastNotification( - context, message: LocaleKeys.sideBar_removeSuccess.tr(), ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart index a078521aec..0ca60fe40b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart @@ -1,8 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; enum MobileViewItemBottomSheetBodyAction { rename, @@ -40,6 +42,8 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { BuildContext context, MobileViewItemBottomSheetBodyAction action, ) { + final isLocked = + context.read()?.state.isLocked ?? false; switch (action) { case MobileViewItemBottomSheetBodyAction.rename: return FlowyOptionTile.text( @@ -49,6 +53,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { FlowySvgs.view_item_rename_s, size: Size.square(18), ), + enable: !isLocked, showTopBorder: false, showBottomBorder: false, onTap: () => onAction( @@ -94,6 +99,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), + enable: !isLocked, showTopBorder: false, showBottomBorder: false, onTap: () => onAction( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 2e08d0f368..9706777df0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -4,9 +4,12 @@ import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -25,11 +28,27 @@ enum MobileViewBottomSheetBodyAction { visitSite, copyShareLink, updatePathName, + lockPage; + + static const disableInLockedView = [ + undo, + redo, + rename, + delete, + ]; +} + +class MobileViewBottomSheetBodyActionArguments { + static const isLockedKey = 'is_locked'; } typedef MobileViewBottomSheetBodyActionCallback = void Function( MobileViewBottomSheetBodyAction action, -); + // for the [MobileViewBottomSheetBodyAction.lockPage] action, + // it will pass the [isLocked] value to the callback. + { + Map? arguments, +}); class ViewPageBottomSheet extends StatefulWidget { const ViewPageBottomSheet({ @@ -56,7 +75,7 @@ class _ViewPageBottomSheetState extends State { case MobileBottomSheetType.view: return MobileViewBottomSheetBody( view: widget.view, - onAction: (action) { + onAction: (action, {arguments}) { switch (action) { case MobileViewBottomSheetBodyAction.rename: setState(() { @@ -64,7 +83,7 @@ class _ViewPageBottomSheetState extends State { }); break; default: - widget.onAction(action); + widget.onAction(action, arguments: arguments); } }, ); @@ -93,6 +112,8 @@ class MobileViewBottomSheetBody extends StatelessWidget { @override Widget build(BuildContext context) { final isFavorite = view.isFavorite; + final isLocked = + context.watch()?.state.isLocked ?? false; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -100,6 +121,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { text: LocaleKeys.button_rename.tr(), icon: FlowySvgs.view_item_rename_s, iconSize: const Size.square(18), + enable: !isLocked, onTap: () => onAction( MobileViewBottomSheetBodyAction.rename, ), @@ -118,6 +140,28 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ), _divider(), + if (view.layout.isDatabaseView || view.layout.isDocumentView) ...[ + MobileQuickActionButton( + text: LocaleKeys.disclosureAction_lockPage.tr(), + icon: FlowySvgs.lock_page_s, + iconSize: const Size.square(18), + rightIconBuilder: (context) => _LockPageRightIconBuilder( + onAction: onAction, + ), + onTap: () { + final isLocked = + context.read()?.state.isLocked ?? false; + onAction( + MobileViewBottomSheetBodyAction.lockPage, + arguments: { + MobileViewBottomSheetBodyActionArguments.isLockedKey: + !isLocked, + }, + ); + }, + ), + _divider(), + ], MobileQuickActionButton( text: LocaleKeys.button_duplicate.tr(), icon: FlowySvgs.duplicate_s, @@ -138,12 +182,14 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), _divider(), ..._buildPublishActions(context), + MobileQuickActionButton( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, iconSize: const Size.square(18), + enable: !isLocked, onTap: () => onAction( MobileViewBottomSheetBodyAction.delete, ), @@ -156,8 +202,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { List _buildPublishActions(BuildContext context) { final userProfile = context.read().state.userProfilePB; // the publish feature is only available for AppFlowy Cloud - if (userProfile == null || - userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + if (userProfile == null || userProfile.authType != AuthTypePB.Server) { return []; } @@ -190,6 +235,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.unpublish, ), ), + _divider(), ]; } else { return [ @@ -200,9 +246,43 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.publish, ), ), + _divider(), ]; } } Widget _divider() => const MobileQuickActionDivider(); } + +class _LockPageRightIconBuilder extends StatelessWidget { + const _LockPageRightIconBuilder({ + required this.onAction, + }); + + final MobileViewBottomSheetBodyActionCallback onAction; + + @override + Widget build(BuildContext context) { + final isLocked = + context.watch()?.state.isLocked ?? false; + return SizedBox( + width: 46, + height: 30, + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: isLocked, + activeTrackColor: Theme.of(context).colorScheme.primary, + onChanged: (value) { + onAction( + MobileViewBottomSheetBodyAction.lockPage, + arguments: { + MobileViewBottomSheetBodyActionArguments.isLockedKey: value, + }, + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart index faf02707b7..d4b4292443 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -8,6 +8,7 @@ import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -44,7 +45,6 @@ enum MobilePaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); @@ -60,7 +60,6 @@ enum MobilePaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys.button_favoriteSuccessfully.tr(), ); @@ -131,6 +130,11 @@ enum MobilePaneActionType { BlocProvider.value(value: favoriteBloc), if (recentViewsBloc != null) BlocProvider.value(value: recentViewsBloc), + BlocProvider( + create: (_) => + ViewLockStatusBloc(view: viewBloc.state.view) + ..add(const ViewLockStatusEvent.initial()), + ), ], child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart index 999813d63e..a0fa5dc6aa 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -74,7 +74,7 @@ Future showMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); - barrierColor ??= Colors.black.withOpacity(0.3); + barrierColor ??= Colors.black.withValues(alpha: 0.3); return showModalBottomSheet( context: context, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart index 0ff2a6634a..b29817251a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart @@ -329,7 +329,7 @@ class CupertinoSheetBottomRouteTransition extends StatelessWidget { (Theme.of(context).brightness == Brightness.dark ? Colors.grey : Colors.black) - .withOpacity(secondaryAnimation.value * 0.1), + .withValues(alpha: secondaryAnimation.value * 0.1), BlendMode.srcOver, ), child: child, 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 2885f37bbd..29841dd22a 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 @@ -1,5 +1,3 @@ -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/board/board.dart'; @@ -13,12 +11,14 @@ import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobi import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -143,6 +143,8 @@ class _BoardContentState extends State<_BoardContent> { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { + final isLocked = + context.watch()?.state.isLocked ?? false; final showCreateGroupButton = context .read() .groupingFieldType @@ -160,15 +162,20 @@ class _BoardContentState extends State<_BoardContent> { padding: config.groupHeaderPadding, ) : const HSpace(16), - trailing: showCreateGroupButton + trailing: showCreateGroupButton && !isLocked ? const MobileBoardTrailing() : const HSpace(16), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), - child: GroupCardHeader( - groupData: groupData, - ), - ), + headerBuilder: (_, groupData) { + final isLocked = + context.read()?.state.isLocked ?? + false; + return IgnorePointer( + ignoring: isLocked, + child: GroupCardHeader( + groupData: groupData, + ), + ); + }, footerBuilder: _buildFooter, cardBuilder: (_, column, columnItem) => _buildCard( context: context, @@ -184,34 +191,39 @@ class _BoardContentState extends State<_BoardContent> { } Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { + final isLocked = + context.read()?.state.isLocked ?? false; final style = Theme.of(context); return SizedBox( height: 42, width: double.infinity, - child: TextButton.icon( - style: TextButton.styleFrom( - padding: const EdgeInsets.only(left: 8), - alignment: Alignment.centerLeft, - ), - icon: FlowySvg( - FlowySvgs.add_m, - color: style.colorScheme.onSurface, - ), - label: Text( - LocaleKeys.board_column_createNewCard.tr(), - style: style.textTheme.bodyMedium?.copyWith( + child: IgnorePointer( + ignoring: isLocked, + child: TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.only(left: 8), + alignment: Alignment.centerLeft, + ), + icon: FlowySvg( + FlowySvgs.add_m, color: style.colorScheme.onSurface, ), - ), - onPressed: () => context.read().add( - BoardEvent.createRow( - columnData.id, - OrderObjectPositionTypePB.End, - null, - null, - ), + label: Text( + LocaleKeys.board_column_createNewCard.tr(), + style: style.textTheme.bodyMedium?.copyWith( + color: style.colorScheme.onSurface, ), + ), + onPressed: () => context.read().add( + BoardEvent.createRow( + columnData.id, + OrderObjectPositionTypePB.End, + null, + null, + ), + ), + ), ), ); } @@ -231,6 +243,8 @@ class _BoardContentState extends State<_BoardContent> { CardCellBuilder(databaseController: boardBloc.databaseController); final groupItemId = groupItem.row.id + groupData.group.groupId; + final isLocked = + context.read()?.state.isLocked ?? false; return Container( key: ValueKey(groupItemId), @@ -238,31 +252,34 @@ class _BoardContentState extends State<_BoardContent> { decoration: _makeBoxDecoration(context), child: BlocProvider.value( value: boardBloc, - child: RowCard( - fieldController: boardBloc.fieldController, - rowMeta: rowMeta, - viewId: boardBloc.viewId, - rowCache: boardBloc.rowCache, - groupingFieldId: groupItem.fieldInfo.id, - isEditing: false, - cellBuilder: cellBuilder, - onTap: (context) { - context.push( - MobileRowDetailPage.routeName, - extra: { - MobileRowDetailPage.argRowId: rowMeta.id, - MobileRowDetailPage.argDatabaseController: - context.read().databaseController, - }, - ); - }, - onStartEditing: () {}, - onEndEditing: () {}, - styleConfiguration: RowCardStyleConfiguration( - cellStyleMap: mobileBoardCardCellStyleMap(context), - showAccessory: false, + child: IgnorePointer( + ignoring: isLocked, + child: RowCard( + fieldController: boardBloc.fieldController, + rowMeta: rowMeta, + viewId: boardBloc.viewId, + rowCache: boardBloc.rowCache, + groupingFieldId: groupItem.fieldInfo.id, + isEditing: false, + cellBuilder: cellBuilder, + onTap: (context) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowMeta.id, + MobileRowDetailPage.argDatabaseController: + context.read().databaseController, + }, + ); + }, + onStartEditing: () {}, + onEndEditing: () {}, + styleConfiguration: RowCardStyleConfiguration( + cellStyleMap: mobileBoardCardCellStyleMap(context), + showAccessory: false, + ), + userProfile: boardBloc.userProfile, ), - userProfile: boardBloc.userProfile, ), ), ); @@ -276,14 +293,20 @@ class _BoardContentState extends State<_BoardContent> { border: themeMode == ThemeMode.light ? Border.fromBorderSide( BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), ), ) : null, boxShadow: themeMode == ThemeMode.light ? [ BoxShadow( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), blurRadius: 4, offset: const Offset(0, 2), ), 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 df44c84286..5896c51b9b 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,5 +1,3 @@ -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'; @@ -29,6 +27,7 @@ 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'; @@ -59,7 +58,9 @@ class _MobileRowDetailPageState extends State { late final PageController _pageController; String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + FieldController get fieldController => widget.databaseController.fieldController; @@ -380,7 +381,9 @@ class MobileRowDetailPageContentState late final EditableCellBuilder cellBuilder; String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + FieldController get fieldController => widget.databaseController.fieldController; ValueNotifier primaryFieldId = ValueNotifier(''); @@ -542,6 +545,7 @@ class _TitleSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart index fa3494002d..b0f21188cd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart @@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State { Log.info('Open row page(${widget.documentId})'); if (view == null) { - showToastNotification(context, message: 'Failed to open row page'); + showToastNotification(message: 'Failed to open row page'); // reload the view again unawaited(_preloadView(context)); Log.error('Failed to open row page(${widget.documentId})'); 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 a51e3561f8..75b52de414 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,5 +1,3 @@ -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'; @@ -8,6 +6,7 @@ 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 +48,7 @@ class _MobileEditPropertyScreenState extends State { final fieldId = widget.field.id; return PopScope( - onPopInvoked: (didPop) { + onPopInvokedWithResult: (didPop, _) { if (!didPop) { context.pop(_fieldOptionValues); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart index f665455dbe..a133739a9d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart @@ -75,7 +75,7 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { enableBackgroundColorSelection: false, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( - viewId: view.id, + view: view, viewIcon: r.data, ); Navigator.pop(context); @@ -84,7 +84,11 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { ); }, builder: (_) => const SizedBox.shrink(), - ).then((_) => Navigator.pop(context)); + ).then((_) { + if (context.mounted) { + Navigator.pop(context); + } + }); }, !isInline, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart index e6d2d895b1..0e7a7cb4c6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart @@ -31,9 +31,9 @@ class MobileFavoriteScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final latest = snapshots.data?[0].fold( + (latest) { + return latest as WorkspaceLatestPB?; }, (error) => null, ); @@ -46,7 +46,7 @@ class MobileFavoriteScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (latest == null || userProfile == null) { return const WorkspaceFailedScreen(); } 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 2d409f58b6..fdea8322c3 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 @@ -44,9 +44,9 @@ class MobileHomeScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) { + return workspaceLatestPB as WorkspaceLatestPB?; }, (error) => null, ); @@ -59,7 +59,7 @@ class MobileHomeScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } @@ -78,7 +78,7 @@ class MobileHomeScreen extends StatelessWidget { value: userProfile, child: MobileHomePage( userProfile: userProfile, - workspaceSetting: workspaceSetting, + workspaceLatest: workspaceLatest, ), ), ), @@ -95,11 +95,11 @@ class MobileHomePage extends StatefulWidget { const MobileHomePage({ super.key, required this.userProfile, - required this.workspaceSetting, + required this.workspaceLatest, }); final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; @override State createState() => _MobileHomePageState(); @@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> { } if (message != null) { - showToastNotification(context, message: message, type: toastType); + showToastNotification(message: message, type: toastType); } } } 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 97cc243c9e..113f12e543 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 @@ -194,6 +194,7 @@ class _MobileWorkspace extends StatelessWidget { context.read().add( UserWorkspaceEvent.openWorkspace( workspace.workspaceId, + workspace.workspaceAuthType, ), ); }, 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 1e0ddb5a51..a01df20549 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 @@ -3,16 +3,19 @@ import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/ai/ai_settings_group.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class MobileHomeSettingPage extends StatefulWidget { const MobileHomeSettingPage({ @@ -68,31 +71,42 @@ class _MobileHomeSettingPageState extends State { } Widget _buildSettingsWidget(UserProfilePB userProfile) { - // show the third-party sign in buttons if user logged in with local session and auth is enabled. - - final showThirdPartyLogin = - userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled; - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - PersonalInfoSettingGroup( - userProfile: userProfile, + return BlocProvider( + create: (context) => UserWorkspaceBloc(userProfile: userProfile) + ..add(const UserWorkspaceEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspaceId = state.currentWorkspace?.workspaceId ?? ''; + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + PersonalInfoSettingGroup( + userProfile: userProfile, + ), + const WorkspaceSettingGroup(), + const AppearanceSettingGroup(), + const LanguageSettingGroup(), + if (Env.enableCustomCloud) const CloudSettingGroup(), + if (isAuthEnabled) + AiSettingsGroup( + key: ValueKey(currentWorkspaceId), + userProfile: userProfile, + workspaceId: currentWorkspaceId, + ), + const SupportSettingGroup(), + const AboutSettingGroup(), + UserSessionSettingGroup( + userProfile: userProfile, + showThirdPartyLogin: false, + ), + const VSpace(20), + ], + ), ), - const WorkspaceSettingGroup(), - const AppearanceSettingGroup(), - const LanguageSettingGroup(), - if (Env.enableCustomCloud) const CloudSettingGroup(), - 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 73a5381d42..73da7594a7 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 @@ -212,7 +212,7 @@ class _DeletedFilesListView extends StatelessWidget { ?.copyWith(color: theme.colorScheme.onSurface), ), horizontalTitleGap: 0, - tileColor: theme.colorScheme.onSurface.withOpacity(0.1), + tileColor: theme.colorScheme.onSurface.withValues(alpha: 0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart index cad05acad4..966b1ac61a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -134,7 +134,8 @@ class _RecentCover extends StatelessWidget { Widget build(BuildContext context) { final placeholder = Container( // random color, update it once we have a better placeholder - color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2), + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.2), ); final value = this.value; if (value == null) { 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 cbbda8362a..bd41730934 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 @@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem { members, trash, help, + helpAndDocumentation, } class HomePageSettingsPopupMenu extends StatelessWidget { @@ -47,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget { text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), // only show the member items in cloud mode - if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthTypePB.Server) ...[ const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.members, @@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget { text: LocaleKeys.settings_popupMenuItem_trash.tr(), ), const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.helpAndDocumentation, + svg: FlowySvgs.help_and_documentation_s, + text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(), + ), + const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.help, svg: FlowySvgs.message_support_s, - text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(), + text: LocaleKeys.settings_popupMenuItem_getSupport.tr(), ), ], onSelected: (_MobileSettingsPopupMenuItem value) { @@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget { case _MobileSettingsPopupMenuItem.help: _openHelpPage(context); break; + case _MobileSettingsPopupMenuItem.helpAndDocumentation: + _openHelpAndDocumentationPage(context); + break; } }, child: const Padding( @@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget { void _openSettingsPage(BuildContext context) { context.push(MobileHomeSettingPage.routeName); } + + void _openHelpAndDocumentationPage(BuildContext context) { + afLaunchUrlString('https://appflowy.com/guide'); + } } class _PopupButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart index 638e20839e..87ce41d5b6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart @@ -225,7 +225,7 @@ class MobileViewPage extends StatelessWidget { Widget _buildLastViewed(BuildContext context) { final textColor = Theme.of(context).isLightMode ? const Color(0x7F171717) - : Colors.white.withOpacity(0.45); + : Colors.white.withValues(alpha: 0.45); if (timestamp == null) { return const SizedBox.shrink(); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart index e0dd520b2d..3bb62a92c8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -98,7 +98,7 @@ class MobileSpace extends StatelessWidget { Navigator.of(sheetContext).pop(); context.read().add( SpaceEvent.createPage( - name: layout.defaultName, + name: '', layout: layout, index: 0, openAfterCreate: true, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart index 0197f34940..485e07a28c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State { context.read().add(const SpaceEvent.duplicate()); showToastNotification( - context, message: LocaleKeys.space_success_duplicateSpace.tr(), ); @@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State { .add(SpaceEvent.rename(space: widget.space, name: name)); showToastNotification( - context, message: LocaleKeys.space_success_renameSpace.tr(), ); }, @@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State { ); showToastNotification( - context, message: LocaleKeys.space_success_updateSpace.tr(), ); @@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State { context.read().add(SpaceEvent.delete(widget.space)); showToastNotification( - context, message: LocaleKeys.space_success_deleteSpace.tr(), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart index b3f10bbbd2..cc4176e0ef 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart @@ -42,7 +42,7 @@ class FloatingAIEntry extends StatelessWidget { blurRadius: 20, spreadRadius: 1, offset: const Offset(0, 4), - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), ), ], ); @@ -51,8 +51,8 @@ class FloatingAIEntry extends StatelessWidget { BoxDecoration _buildWrapperDecoration(BuildContext context) { final outlineColor = Theme.of(context).colorScheme.outline; final borderColor = Theme.of(context).isLightMode - ? outlineColor.withOpacity(0.7) - : outlineColor.withOpacity(0.3); + ? outlineColor.withValues(alpha: 0.7) + : outlineColor.withValues(alpha: 0.3); return BoxDecoration( borderRadius: BorderRadius.circular(30), color: Theme.of(context).colorScheme.surface, 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 1a19845152..c89367f379 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 @@ -11,7 +11,6 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; @@ -73,7 +72,14 @@ class _MobileSpaceTabState extends State listener: (context, state) { final lastCreatedPage = state.lastCreatedPage; if (lastCreatedPage != null) { - context.pushView(lastCreatedPage); + context.pushView( + lastCreatedPage, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ); } }, ), @@ -161,8 +167,7 @@ class _MobileSpaceTabState extends State children: [ MobileHomeSpace(userProfile: widget.userProfile), // only show ai chat button for cloud user - if (widget.userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthTypePB.Server) Positioned( bottom: MediaQuery.of(context).padding.bottom + 16, left: 20, @@ -173,8 +178,6 @@ class _MobileSpaceTabState extends State ); case MobileSpaceTabType.favorites: return MobileFavoriteSpace(userProfile: widget.userProfile); - default: - throw Exception('Unknown tab type: $tab'); } }).toList(); } @@ -188,7 +191,7 @@ class _MobileSpaceTabState extends State if (context.read().state.spaces.isNotEmpty) { context.read().add( SpaceEvent.createPage( - name: layout.defaultName, + name: '', layout: layout, openAfterCreate: true, ), @@ -197,7 +200,7 @@ class _MobileSpaceTabState extends State // only support create document in section context.read().add( SidebarSectionsEvent.createRootViewInSection( - name: layout.defaultName, + name: '', index: 0, viewSection: FolderSpaceType.public.toViewSectionPB, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart index 9a5bd8c511..d306f48964 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -123,6 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget { context.read().add( UserWorkspaceEvent.createWorkspace( name, + AuthTypePB.Server, ), ); }, @@ -139,7 +140,7 @@ class _CreateWorkspaceButton extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( - color: const Color(0x01717171).withOpacity(0.12), + color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart index 5f6066930f..bb6f6207f6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart @@ -101,8 +101,6 @@ class WorkspaceMenuMoreOptions extends StatelessWidget { WorkspaceMenuMoreOption.leave, ), ); - default: - return const Placeholder(); } } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart new file mode 100644 index 0000000000..74d5e56bce --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart @@ -0,0 +1,253 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_inline_actions_menu_group.dart'; + +extension _StartWithsSort on List { + void sortByStartsWithKeyword(String search) => sort( + (a, b) { + final aCount = a.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + final bCount = b.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + if (aCount > bCount) { + return -1; + } else if (bCount > aCount) { + return 1; + } + + return 0; + }, + ); +} + +const _invalidSearchesAmount = 10; + +class MobileInlineActionsHandler extends StatefulWidget { + const MobileInlineActionsHandler({ + super.key, + required this.results, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.style, + required this.service, + this.startCharAmount = 1, + this.startOffset = 0, + this.cancelBySpaceHandler, + }); + + final List results; + final EditorState editorState; + final InlineActionsMenuService menuService; + final VoidCallback onDismiss; + final InlineActionsMenuStyle style; + final int startCharAmount; + final InlineActionsService service; + final bool Function()? cancelBySpaceHandler; + final int startOffset; + + @override + State createState() => + _MobileInlineActionsHandlerState(); +} + +class _MobileInlineActionsHandlerState + extends State { + final _focusNode = + FocusNode(debugLabel: 'mobile_inline_actions_menu_handler'); + + late List results = widget.results; + int invalidCounter = 0; + late int startOffset; + + String _search = ''; + + set search(String search) { + _search = search; + _doSearch(); + } + + Future _doSearch() async { + final List newResults = []; + for (final handler in widget.service.handlers) { + final group = await handler.search(_search); + + if (group.results.isNotEmpty) { + newResults.add(group); + } + } + + invalidCounter = results.every((group) => group.results.isEmpty) + ? invalidCounter + 1 + : 0; + + if (invalidCounter >= _invalidSearchesAmount) { + widget.onDismiss(); + + // Workaround to bring focus back to editor + await editorState.updateSelectionWithReason(editorState.selection); + + return; + } + + _resetSelection(); + + newResults.sortByStartsWithKeyword(_search); + setState(() => results = newResults); + } + + void _resetSelection() { + _selectedGroup = 0; + _selectedIndex = 0; + } + + int _selectedGroup = 0; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + + startOffset = editorState.selection?.endIndex ?? 0; + keepEditorFocusNotifier.increase(); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final width = editorState.renderBox!.size.width - 24 * 2; + return Focus( + focusNode: _focusNode, + child: Container( + constraints: BoxConstraints( + maxHeight: 192, + minWidth: width, + maxWidth: width, + ), + margin: EdgeInsets.symmetric(horizontal: 24.0), + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + ), + child: noResults + ? SizedBox( + width: 150, + child: FlowyText.regular( + LocaleKeys.inlineActions_noResults.tr(), + ), + ) + : SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Material( + color: Colors.transparent, + child: Padding( + padding: EdgeInsets.all(6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: results + .where((g) => g.results.isNotEmpty) + .mapIndexed( + (index, group) => MobileInlineActionsGroup( + result: group, + editorState: editorState, + menuService: menuService, + style: widget.style, + onSelected: widget.onDismiss, + startOffset: startOffset - widget.startCharAmount, + endOffset: + _search.length + widget.startCharAmount, + isLastGroup: index == results.length - 1, + isGroupSelected: _selectedGroup == index, + selectedIndex: _selectedIndex, + onPreSelect: (int value) { + setState(() { + _selectedGroup = index; + _selectedIndex = value; + }); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ), + ); + } + + bool get noResults => + results.isEmpty || results.every((e) => e.results.isEmpty); + + int get groupLength => results.length; + + int lengthOfGroup(int index) => + results.length > index ? results[index].results.length : -1; + + InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => + results[groupIndex].results[handlerIndex]; + + EditorState get editorState => widget.editorState; + + InlineActionsMenuService get menuService => widget.menuService; + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + menuService.dismiss(); + return; + } + if (!selection.isCollapsed) { + menuService.dismiss(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + menuService.dismiss(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + this.search = search; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart new file mode 100644 index 0000000000..6166671391 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_inline_actions_handler.dart'; + +class MobileInlineActionsMenu extends InlineActionsMenuService { + MobileInlineActionsMenu({ + required this.context, + required this.editorState, + required this.initialResults, + required this.style, + required this.service, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final BuildContext context; + final EditorState editorState; + final List initialResults; + final bool Function()? cancelBySpaceHandler; + final InlineActionsService service; + + @override + final InlineActionsMenuStyle style; + + final int startCharAmount; + + OverlayEntry? _menuEntry; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _menuEntry?.remove(); + _menuEntry = null; + } + + @override + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return; + } + + const double menuHeight = 192.0; + const Offset menuOffset = Offset(0, 10); + final Offset editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final Size editorSize = editorState.renderBox!.size; + + // Default to opening the overlay below + Alignment alignment = Alignment.topLeft; + + final firstRect = selectionRects.first; + Offset offset = firstRect.bottomRight + menuOffset; + + // Show above + if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { + offset = firstRect.topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + offset.dx, + MediaQuery.of(context).size.height - offset.dy, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + width: editorSize.width, + height: editorSize.height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: MobileInlineActionsHandler( + service: service, + results: initialResults, + editorState: editorState, + menuService: this, + onDismiss: dismiss, + style: style, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + startOffset: editorState.selection?.start.offset ?? 0, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = 0; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = 0; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart new file mode 100644 index 0000000000..f340319254 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class MobileInlineActionsGroup extends StatelessWidget { + const MobileInlineActionsGroup({ + super.key, + required this.result, + required this.editorState, + required this.menuService, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + required this.onPreSelect, + this.isLastGroup = false, + this.isGroupSelected = false, + this.selectedIndex = 0, + }); + + final InlineActionsResult result; + final EditorState editorState; + final InlineActionsMenuService menuService; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final ValueChanged onPreSelect; + final int startOffset; + final int endOffset; + + final bool isLastGroup; + final bool isGroupSelected; + final int selectedIndex; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.title != null) ...[ + SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.medium( + result.title!, + color: style.groupTextColor, + fontSize: 12, + ), + ), + ), + ), + ], + ...result.results.mapIndexed( + (index, item) => GestureDetector( + onTapDown: (e) { + onPreSelect.call(index); + }, + child: MobileInlineActionsWidget( + item: item, + editorState: editorState, + menuService: menuService, + isSelected: isGroupSelected && index == selectedIndex, + style: style, + onSelected: onSelected, + startOffset: startOffset, + endOffset: endOffset, + ), + ), + ), + ], + ); + } +} + +class MobileInlineActionsWidget extends StatelessWidget { + const MobileInlineActionsWidget({ + super.key, + required this.item, + required this.editorState, + required this.menuService, + required this.isSelected, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + }); + + final InlineActionsMenuItem item; + final EditorState editorState; + final InlineActionsMenuService menuService; + final bool isSelected; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final int startOffset; + final int endOffset; + + @override + Widget build(BuildContext context) { + final hasIcon = item.iconBuilder != null; + return Container( + height: 36, + decoration: BoxDecoration( + color: isSelected ? style.menuItemSelectedColor : null, + borderRadius: BorderRadius.circular(6.0), + ), + child: FlowyButton( + expand: true, + isSelected: isSelected, + text: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + if (hasIcon) ...[ + item.iconBuilder!.call(isSelected), + SizedBox(width: 12), + ], + Flexible( + child: FlowyText.regular( + item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + fontSize: 16, + color: style.menuItemSelectedTextColor, + ), + ), + ], + ), + ), + ), + onTap: () => _onPressed(context), + ), + ); + } + + void _onPressed(BuildContext context) { + onSelected(); + item.onSelected?.call( + context, + editorState, + menuService, + (startOffset, endOffset), + ); + } +} 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 4d8bc16103..3c6adb8627 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 @@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget { } showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), @@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget { } showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); @@ -365,14 +363,14 @@ class _NotificationNavigationBar extends StatelessWidget { extension on BuildContext { Color get backgroundColor { return Theme.of(this).isLightMode - ? Colors.white.withOpacity(0.95) - : const Color(0xFF23262B).withOpacity(0.95); + ? Colors.white.withValues(alpha: 0.95) + : const Color(0xFF23262B).withValues(alpha: 0.95); } Color get borderColor { return Theme.of(this).isLightMode ? const Color(0x141F2329) - : const Color(0xFF23262B).withOpacity(0.5); + : const Color(0xFF23262B).withValues(alpha: 0.5); } Border? get border { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart index a8055b8ba2..33c2eb3905 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -50,9 +50,9 @@ class _MobileNotificationsScreenState extends State orElse: () => const Center(child: CircularProgressIndicator.adaptive()), workspaceFailure: () => const WorkspaceFailedScreen(), - success: (workspaceSetting, userProfile) => + success: (workspaceLatest, userProfile) => _NotificationScreenContent( - workspaceSetting: workspaceSetting, + workspaceLatest: workspaceLatest, userProfile: userProfile, controller: controller, reminderBloc: reminderBloc, @@ -66,13 +66,13 @@ class _MobileNotificationsScreenState extends State class _NotificationScreenContent extends StatelessWidget { const _NotificationScreenContent({ - required this.workspaceSetting, + required this.workspaceLatest, required this.userProfile, required this.controller, required this.reminderBloc, }); - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; final UserProfilePB userProfile; final TabController controller; final ReminderBloc reminderBloc; @@ -84,7 +84,7 @@ class _NotificationScreenContent extends StatelessWidget { ..add( SidebarSectionsEvent.initial( userProfile, - workspaceSetting.workspaceId, + workspaceLatest.workspaceId, ), ), child: BlocBuilder( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart index 8a59336378..e11e91ada5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart @@ -6,6 +6,6 @@ extension NotificationItemColors on BuildContext { if (Theme.of(this).isLightMode) { return const Color(0xFF171717); } - return const Color(0xFFffffff).withOpacity(0.8); + return const Color(0xFFffffff).withValues(alpha: 0.8); } } 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 dfa277f2ef..e694f9932d 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 @@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { void _onMarkAllAsRead(BuildContext context) { showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), @@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { void _onArchiveAll(BuildContext context) { showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); @@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget { } showToastNotification( - context, message: 'Unarchive all success (Debug Mode)', ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart index 85f468c76c..d1216eed98 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -31,7 +31,6 @@ enum NotificationPaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: LocaleKeys .settings_notifications_markAsReadNotifications_success .tr(), @@ -55,7 +54,6 @@ enum NotificationPaneActionType { size: 24.0, onPressed: (context) { showToastNotification( - context, message: 'Unarchive notification success', ); @@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget { Navigator.of(context).pop(); showToastNotification( - context, message: LocaleKeys.settings_notifications_markAsReadNotifications_success .tr(), ); @@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget { void _onArchive(BuildContext context) { showToastNotification( - context, message: LocaleKeys.settings_notifications_archiveNotifications_success .tr() .tr(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart index 7dda8f0a14..45e801e07c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -74,7 +74,6 @@ class _NotificationTabState extends State if (context.mounted) { showToastNotification( - context, message: LocaleKeys.settings_notifications_refreshSuccess.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart new file mode 100644 index 0000000000..f69360575a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart @@ -0,0 +1,296 @@ +import 'dart:async'; + +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item_widget.dart'; +import 'mobile_selection_menu_widget.dart'; + +class MobileSelectionMenu extends SelectionMenuService { + MobileSelectionMenu({ + required this.context, + required this.editorState, + required this.selectionMenuItems, + this.deleteSlashByDefault = false, + this.deleteKeywordsByDefault = false, + this.style = MobileSelectionMenuStyle.light, + this.itemCountFilter = 0, + this.startOffset = 0, + this.singleColumn = false, + }); + + final BuildContext context; + final EditorState editorState; + final List selectionMenuItems; + final bool deleteSlashByDefault; + final bool deleteKeywordsByDefault; + final bool singleColumn; + + @override + final MobileSelectionMenuStyle style; + + OverlayEntry? _selectionMenuEntry; + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; + final int itemCountFilter; + final int startOffset; + ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero); + + @override + void dismiss() { + if (_selectionMenuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + editorState + .removeScrollViewScrolledListener(_checkPositionAfterScrolling); + _positionNotifier.dispose(); + } + + _selectionMenuEntry?.remove(); + _selectionMenuEntry = null; + } + + @override + Future show() async { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final position = _getCurrentPosition(); + if (position == null) return; + + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + _positionNotifier = ValueNotifier(position); + final showAtTop = position.top != null; + _selectionMenuEntry = OverlayEntry( + builder: (context) { + return SizedBox( + width: editorWidth, + height: editorHeight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _positionNotifier, + builder: (context, value, _) { + return Positioned( + top: value.top, + bottom: value.bottom, + left: value.left, + right: value.right, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MobileSelectionMenuWidget( + selectionMenuStyle: style, + singleColumn: singleColumn, + showAtTop: showAtTop, + items: selectionMenuItems + ..forEach((element) { + if (element is MobileSelectionMenuItem) { + element.deleteSlash = false; + element.deleteKeywords = + deleteKeywordsByDefault; + for (final e in element.children) { + e.deleteSlash = deleteSlashByDefault; + e.deleteKeywords = deleteKeywordsByDefault; + e.onSelected = () { + dismiss(); + }; + } + } else { + element.deleteSlash = deleteSlashByDefault; + element.deleteKeywords = + deleteKeywordsByDefault; + element.onSelected = () { + dismiss(); + }; + } + }), + maxItemInRow: 5, + editorState: editorState, + itemCountFilter: itemCountFilter, + startOffset: startOffset, + menuService: this, + onExit: () { + dismiss(); + }, + deleteSlashByDefault: deleteSlashByDefault, + ), + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + + Overlay.of(context, rootOverlay: true).insert(_selectionMenuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + /// the workaround for: editor auto scrolling that will cause wrong position + /// of slash menu + void _checkPositionAfterScrolling() { + final position = _getCurrentPosition(); + if (position == null) return; + if (position == _positionNotifier.value) { + Future.delayed(const Duration(milliseconds: 100)).then((_) { + final position = _getCurrentPosition(); + if (position == null) return; + if (position != _positionNotifier.value) { + _positionNotifier.value = position; + } + }); + } else { + _positionNotifier.value = position; + } + } + + _Position? _getCurrentPosition() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return null; + } + final screenSize = MediaQuery.of(context).size; + calculateSelectionMenuOffset(selectionRects.first, screenSize); + final (left, top, right, bottom) = getPosition(); + return _Position(left, top, right, bottom); + } + + @override + Alignment get alignment { + return _alignment; + } + + @override + Offset get offset { + return _offset; + } + + @override + (double? left, double? top, double? right, double? bottom) getPosition() { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + return (left, top, right, bottom); + } + + void calculateSelectionMenuOffset(Rect rect, Size screenSize) { + // Workaround: We can customize the padding through the [EditorStyle], + // but the coordinates of overlay are not properly converted currently. + // Just subtract the padding here as a result. + const menuHeight = 192.0, menuWidth = 240.0; + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final screenHeight = screenSize.height; + final editorWidth = editorState.renderBox!.size.width; + final rectHeight = rect.height; + + // show below default + _alignment = Alignment.bottomRight; + final bottomRight = rect.topLeft; + final offset = bottomRight; + final limitX = editorWidth + editorOffset.dx - menuWidth, + limitY = screenHeight - + editorHeight + + editorOffset.dy - + menuHeight - + rectHeight; + _offset = Offset( + editorWidth - offset.dx - menuWidth, + screenHeight - offset.dy - menuHeight - rectHeight, + ); + + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + /// show above + if (offset.dy > menuHeight) { + _offset = Offset( + _offset.dx, + offset.dy - menuHeight, + ); + _alignment = Alignment.topRight; + } else { + _offset = Offset( + _offset.dx, + limitY, + ); + } + } + + if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) { + /// show left + if (offset.dx > menuWidth) { + _alignment = _alignment == Alignment.bottomRight + ? Alignment.bottomLeft + : Alignment.topLeft; + _offset = Offset( + offset.dx - menuWidth, + _offset.dy, + ); + } else { + _offset = Offset( + limitX, + _offset.dy, + ); + } + } + } +} + +class _Position { + const _Position(this.left, this.top, this.right, this.bottom); + + final double? left; + final double? top; + final double? right; + final double? bottom; + + static const _Position zero = _Position(0, 0, 0, 0); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Position && + runtimeType == other.runtimeType && + left == other.left && + top == other.top && + right == other.right && + bottom == other.bottom; + + @override + int get hashCode => + left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart new file mode 100644 index 0000000000..22e202816e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class MobileSelectionMenuItem extends SelectionMenuItem { + MobileSelectionMenuItem({ + required super.getName, + required super.icon, + super.keywords = const [], + required super.handler, + this.children = const [], + super.nameBuilder, + super.deleteKeywords, + super.deleteSlash, + }); + + final List children; + + bool get isNotEmpty => children.isNotEmpty; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart new file mode 100644 index 0000000000..bdee8f1857 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart @@ -0,0 +1,138 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; + +class MobileSelectionMenuItemWidget extends StatelessWidget { + const MobileSelectionMenuItemWidget({ + super.key, + required this.editorState, + required this.menuService, + required this.item, + required this.isSelected, + required this.selectionMenuStyle, + required this.onTap, + }); + + final EditorState editorState; + final SelectionMenuService menuService; + final SelectionMenuItem item; + final bool isSelected; + final MobileSelectionMenuStyle selectionMenuStyle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final style = selectionMenuStyle; + final showRightArrow = item is MobileSelectionMenuItem && + (item as MobileSelectionMenuItem).isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: TextButton.icon( + icon: item.icon( + editorState, + false, + selectionMenuStyle, + ), + style: ButtonStyle( + alignment: Alignment.centerLeft, + overlayColor: WidgetStateProperty.all(Colors.transparent), + backgroundColor: isSelected + ? WidgetStateProperty.all( + style.selectionMenuItemSelectedColor, + ) + : WidgetStateProperty.all(Colors.transparent), + ), + label: Row( + children: [ + item.nameBuilder?.call(item.name, style, false) ?? + Text( + item.name, + textAlign: TextAlign.left, + style: TextStyle( + color: style.selectionMenuItemTextColor, + fontSize: 16.0, + ), + ), + if (showRightArrow) ...[ + Spacer(), + Icon( + Icons.keyboard_arrow_right_rounded, + color: style.selectionMenuItemRightIconColor, + ), + ], + ], + ), + onPressed: () { + onTap.call(); + item.handler( + editorState, + menuService, + context, + ); + }, + ), + ); + } +} + +class MobileSelectionMenuStyle extends SelectionMenuStyle { + const MobileSelectionMenuStyle({ + required super.selectionMenuBackgroundColor, + required super.selectionMenuItemTextColor, + required super.selectionMenuItemIconColor, + required super.selectionMenuItemSelectedTextColor, + required super.selectionMenuItemSelectedIconColor, + required super.selectionMenuItemSelectedColor, + required super.selectionMenuUnselectedLabelColor, + required super.selectionMenuDividerColor, + required super.selectionMenuLinkBorderColor, + required super.selectionMenuInvalidLinkColor, + required super.selectionMenuButtonColor, + required super.selectionMenuButtonTextColor, + required super.selectionMenuButtonIconColor, + required super.selectionMenuButtonBorderColor, + required super.selectionMenuTabIndicatorColor, + required this.selectionMenuItemRightIconColor, + }); + + final Color selectionMenuItemRightIconColor; + + static const MobileSelectionMenuStyle light = MobileSelectionMenuStyle( + selectionMenuBackgroundColor: Color(0xFFFFFFFF), + selectionMenuItemTextColor: Color(0xFF1F2225), + selectionMenuItemIconColor: Color(0xFF333333), + selectionMenuItemSelectedColor: Color(0xFFF2F5F7), + selectionMenuItemRightIconColor: Color(0xB31E2022), + selectionMenuItemSelectedTextColor: Color.fromARGB(255, 56, 91, 247), + selectionMenuItemSelectedIconColor: Color.fromARGB(255, 56, 91, 247), + selectionMenuUnselectedLabelColor: Color(0xFF333333), + selectionMenuDividerColor: Color(0xFF00BCF0), + selectionMenuLinkBorderColor: Color(0xFF00BCF0), + selectionMenuInvalidLinkColor: Color(0xFFE53935), + selectionMenuButtonColor: Color(0xFF00BCF0), + selectionMenuButtonTextColor: Color(0xFF333333), + selectionMenuButtonIconColor: Color(0xFF333333), + selectionMenuButtonBorderColor: Color(0xFF00BCF0), + selectionMenuTabIndicatorColor: Color(0xFF00BCF0), + ); + + static const MobileSelectionMenuStyle dark = MobileSelectionMenuStyle( + selectionMenuBackgroundColor: Color(0xFF424242), + selectionMenuItemTextColor: Color(0xFFFFFFFF), + selectionMenuItemIconColor: Color(0xFFFFFFFF), + selectionMenuItemSelectedColor: Color(0xFF666666), + selectionMenuItemRightIconColor: Color(0xB3FFFFFF), + selectionMenuItemSelectedTextColor: Color(0xFF131720), + selectionMenuItemSelectedIconColor: Color(0xFF131720), + selectionMenuUnselectedLabelColor: Color(0xFFBBC3CD), + selectionMenuDividerColor: Color(0xFF3A3F44), + selectionMenuLinkBorderColor: Color(0xFF3A3F44), + selectionMenuInvalidLinkColor: Color(0xFFE53935), + selectionMenuButtonColor: Color(0xFF00BCF0), + selectionMenuButtonTextColor: Color(0xFFFFFFFF), + selectionMenuButtonIconColor: Color(0xFFFFFFFF), + selectionMenuButtonBorderColor: Color(0xFF00BCF0), + selectionMenuTabIndicatorColor: Color(0xFF00BCF0), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart new file mode 100644 index 0000000000..d96dd224e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart @@ -0,0 +1,392 @@ +import 'dart:math'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; +import 'mobile_selection_menu_item_widget.dart'; +import 'slash_keyboard_service_interceptor.dart'; + +class MobileSelectionMenuWidget extends StatefulWidget { + const MobileSelectionMenuWidget({ + super.key, + required this.items, + required this.itemCountFilter, + required this.maxItemInRow, + required this.menuService, + required this.editorState, + required this.onExit, + required this.selectionMenuStyle, + required this.deleteSlashByDefault, + required this.singleColumn, + required this.startOffset, + required this.showAtTop, + this.nameBuilder, + }); + + final List items; + final int itemCountFilter; + final int maxItemInRow; + + final SelectionMenuService menuService; + final EditorState editorState; + + final VoidCallback onExit; + + final MobileSelectionMenuStyle selectionMenuStyle; + + final bool deleteSlashByDefault; + final bool singleColumn; + final bool showAtTop; + final int startOffset; + + final SelectionMenuItemNameBuilder? nameBuilder; + + @override + State createState() => + _MobileSelectionMenuWidgetState(); +} + +class _MobileSelectionMenuWidgetState extends State { + final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); + + List _showingItems = []; + + int _searchCounter = 0; + + EditorState get editorState => widget.editorState; + + SelectionMenuService get menuService => widget.menuService; + + String _keyword = ''; + + String get keyword => _keyword; + + int selectedIndex = 0; + + late AppFlowyKeyboardServiceInterceptor keyboardInterceptor; + + List get filterItems { + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + for (final childItem in item.children) { + items.add(childItem); + } + } else { + items.add(item); + } + } + return items; + } + + set keyword(String newKeyword) { + _keyword = newKeyword; + + // Search items according to the keyword, and calculate the length of + // the longest keyword, which is used to dismiss the selection_service. + var maxKeywordLength = 0; + + final items = newKeyword.isEmpty + ? widget.items + : filterItems + .where( + (item) => item.allKeywords.any((keyword) { + final value = keyword.contains(newKeyword.toLowerCase()); + if (value) { + maxKeywordLength = max(maxKeywordLength, keyword.length); + } + return value; + }), + ) + .toList(growable: false); + + AppFlowyEditorLog.ui.debug('$items'); + + if (keyword.length >= maxKeywordLength + 2 && + !(widget.deleteSlashByDefault && _searchCounter < 2)) { + return widget.onExit(); + } + + _showingItems = items; + refreshSelectedIndex(); + + if (_showingItems.isEmpty) { + _searchCounter++; + } else { + _searchCounter = 0; + } + } + + @override + void initState() { + super.initState(); + _showingItems = buildInitialItems(); + + keepEditorFocusNotifier.increase(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + + keyboardInterceptor = SlashKeyboardServiceInterceptor( + onDelete: () async { + if (!mounted) return false; + final hasItemsChanged = !isInitialItems(); + if (keyword.isEmpty && hasItemsChanged) { + _showingItems = buildInitialItems(); + refreshSelectedIndex(); + return true; + } + return false; + }, + onEnter: () { + if (!mounted) return; + if (_showingItems.isEmpty) return; + final item = _showingItems[selectedIndex]; + if (item is MobileSelectionMenuItem) { + selectedIndex = 0; + item.onSelected?.call(); + } else { + item.handler( + editorState, + menuService, + context, + ); + } + }, + ); + editorState.service.keyboardService + ?.registerInterceptor(keyboardInterceptor); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.service.keyboardService + ?.unregisterInterceptor(keyboardInterceptor); + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 192, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showAtTop) Spacer(), + Focus( + focusNode: _focusNode, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.selectionMenuStyle.selectionMenuBackgroundColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: _showingItems.isEmpty + ? _buildNoResultsWidget(context) + : _buildResultsWidget( + context, + _showingItems, + widget.itemCountFilter, + ), + ), + ), + if (!widget.showAtTop) Spacer(), + ], + ), + ); + } + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + widget.onExit(); + return; + } + if (!selection.isCollapsed) { + widget.onExit(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + widget.onExit(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + keyword = search; + } + + Widget _buildResultsWidget( + BuildContext buildContext, + List items, + int itemCountFilter, + ) { + if (widget.singleColumn) { + final List itemWidgets = []; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + itemWidgets.add( + GestureDetector( + onTapDown: (e) { + setState(() { + selectedIndex = i; + }); + }, + child: MobileSelectionMenuItemWidget( + item: item, + isSelected: i == selectedIndex, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + onTap: () { + if (item is MobileSelectionMenuItem) refreshSelectedIndex(); + }, + ), + ), + ); + } + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 192, + minWidth: 240, + maxWidth: 240, + ), + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: itemWidgets, + ), + ); + } else { + final List columns = []; + List itemWidgets = []; + // apply item count filter + if (itemCountFilter > 0) { + items = items.take(itemCountFilter).toList(); + } + + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (i != 0 && i % (widget.maxItemInRow) == 0) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + itemWidgets.add( + MobileSelectionMenuItemWidget( + item: item, + isSelected: false, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + onTap: () { + if (item is MobileSelectionMenuItem) refreshSelectedIndex(); + }, + ), + ); + } + if (itemWidgets.isNotEmpty) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns, + ); + } + } + + void refreshSelectedIndex() { + if (!mounted) return; + setState(() { + selectedIndex = 0; + }); + } + + Widget _buildNoResultsWidget(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 240, + height: 48, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Material( + color: Colors.transparent, + child: Center( + child: Text( + LocaleKeys.inlineActions_noResults.tr(), + style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ); + } + + List buildInitialItems() { + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + item.onSelected = () { + if (mounted) { + setState(() { + _showingItems = item.children + .map((e) => e..onSelected = widget.onExit) + .toList(); + }); + } + }; + } + items.add(item); + } + return items; + } + + bool isInitialItems() { + if (_showingItems.length != widget.items.length) return false; + int i = 0; + for (final item in _showingItems) { + final widgetItem = widget.items[i]; + if (widgetItem.name != item.name) return false; + i++; + } + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart new file mode 100644 index 0000000000..b7d0fd6e83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class SlashKeyboardServiceInterceptor extends EditorKeyboardInterceptor { + SlashKeyboardServiceInterceptor({ + required this.onDelete, + required this.onEnter, + }); + + final AsyncValueGetter onDelete; + final VoidCallback onEnter; + + @override + Future interceptDelete( + TextEditingDeltaDeletion deletion, + EditorState editorState, + ) async { + final intercept = await onDelete.call(); + if (intercept) { + return true; + } else { + return super.interceptDelete(deletion, editorState); + } + } + + @override + Future interceptInsert( + TextEditingDeltaInsertion insertion, + EditorState editorState, + List characterShortcutEvents, + ) async { + final text = insertion.textInserted; + if (text.contains('\n')) { + onEnter.call(); + return true; + } + return super + .interceptInsert(insertion, editorState, characterShortcutEvents); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart index d4f0766626..2d5a3176cd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget { trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/privacy'), + onTap: () => afLaunchUrlString('https://appflowy.com/privacy'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_termsAndConditions.tr(), trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/terms'), + onTap: () => afLaunchUrlString('https://appflowy.com/terms'), ), if (kDebugMode) MobileSettingItem( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart new file mode 100644 index 0000000000..b43ada6e42 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart'; +import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class AiSettingsGroup extends StatelessWidget { + const AiSettingsGroup({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return BlocProvider( + create: (context) => SettingsAIBloc( + userProfile, + workspaceId, + )..add(const SettingsAIEvent.started()), + child: BlocBuilder( + builder: (context, state) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_aiPage_title.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + trailing: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText( + state.availableModels?.selectedModel.name ?? "", + color: theme.colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + onTap: () => _onLLMModelTypeTap(context, state), + ), + // enable AI search if needed + // MobileSettingItem( + // name: LocaleKeys.settings_aiPage_keys_enableAISearchTitle.tr(), + // trailing: const Icon( + // Icons.chevron_right, + // ), + // onTap: () => context.push(AppFlowyCloudPage.routeName), + // ), + ], + ); + }, + ), + ); + } + + void _onLLMModelTypeTap(BuildContext context, SettingsAIState state) { + final availableModels = state.availableModels; + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + builder: (_) { + return Column( + children: (availableModels?.models ?? []) + .asMap() + .entries + .map( + (entry) => FlowyOptionTile.checkbox( + text: entry.value.name, + showTopBorder: entry.key == 0, + isSelected: + availableModels?.selectedModel.name == entry.value.name, + onTap: () { + context + .read() + .add(SettingsAIEvent.selectModel(entry.value)); + context.pop(); + }, + ), + ) + .toList(), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart index 8e1aceefae..5b8035f004 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart @@ -83,7 +83,6 @@ class RTLSetting extends StatelessWidget { case AppFlowyTextDirection.rtl: return LocaleKeys.settings_appearance_textDirection_rtl.tr(); case AppFlowyTextDirection.ltr: - default: return LocaleKeys.settings_appearance_textDirection_ltr.tr(); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart index 24c50f7ae6..02d620e559 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart @@ -1,6 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -18,6 +19,7 @@ class AppFlowyCloudPage extends StatelessWidget { ), body: SettingCloud( restartAppFlowy: () async { + await getIt().signOut(); await runAppFlowy(); }, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index cfdf3defb0..28ebdb750e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; @@ -7,10 +5,10 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../widgets/widgets.dart'; - import 'personal_info.dart'; class PersonalInfoSettingGroup extends StatelessWidget { @@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { selector: (state) => state.userProfile.name, builder: (context, userName) { return MobileSettingGroup( - groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(), + groupTitle: LocaleKeys.settings_accountPage_title.tr(), settingItemList: [ MobileSettingItem( name: userName, @@ -60,7 +58,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { userName: userName, onSubmitted: (value) => context .read() - .add(SettingsUserEvent.updateUserName(value)), + .add(SettingsUserEvent.updateUserName(name: value)), ); }, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart index 584b867736..e5e4efef77 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget { ); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.settings_files_clearCacheSuccess.tr(), ); } 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 b3b7cb71c5..405fef0d1a 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 @@ -40,7 +40,7 @@ class UserSessionSettingGroup extends StatelessWidget { // delete account button // only show the delete account button in cloud mode - if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + if (userProfile.authType == AuthTypePB.Server) ...[ const VSpace(16.0), MobileLogoutButton( text: LocaleKeys.button_deleteAccount.tr(), @@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget { ); }, builder: (context, state) { - return const ThirdPartySignInButtons( - expanded: true, + return Column( + children: [ + const ContinueWithEmailAndPassword(), + const VSpace(12.0), + const ThirdPartySignInButtons( + expanded: true, + ), + const VSpace(16.0), + ], ); }, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart index 2e805c5c5a..62aa114ef3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), bottomPadding: keyboardHeight, @@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( - context, type: ToastificationType.error, bottomPadding: keyboardHeight, message: message, @@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), bottomPadding: keyboardHeight, @@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( - context, type: ToastificationType.error, message: message, bottomPadding: keyboardHeight, @@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { result.fold( (s) { showToastNotification( - context, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), @@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { }, (f) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed @@ -282,11 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> { void _inviteMember(BuildContext context) { final email = emailController.text; if (!isEmail(email)) { - return showToastNotification( - context, + showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); + return; } context .read() diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart index b447ed3d57..191deb1e9f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart @@ -12,6 +12,7 @@ class MobileQuickActionButton extends StatelessWidget { this.iconColor, this.iconSize, this.enable = true, + this.rightIconBuilder, }); final VoidCallback onTap; @@ -21,34 +22,39 @@ class MobileQuickActionButton extends StatelessWidget { final Color? iconColor; final Size? iconSize; final bool enable; + final WidgetBuilder? rightIconBuilder; @override Widget build(BuildContext context) { final iconSize = this.iconSize ?? const Size.square(18); - return InkWell( - onTap: enable ? onTap : null, - overlayColor: - enable ? null : const WidgetStatePropertyAll(Colors.transparent), - splashColor: Colors.transparent, - child: Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - FlowySvg( - icon, - size: iconSize, - color: enable ? iconColor : Theme.of(context).disabledColor, - ), - HSpace(30 - iconSize.width), - Expanded( - child: FlowyText.regular( - text, - fontSize: 16, - color: enable ? textColor : Theme.of(context).disabledColor, + return Opacity( + opacity: enable ? 1.0 : 0.5, + child: InkWell( + onTap: enable ? onTap : null, + overlayColor: + enable ? null : const WidgetStatePropertyAll(Colors.transparent), + splashColor: Colors.transparent, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + FlowySvg( + icon, + size: iconSize, + color: iconColor, ), - ), - ], + HSpace(30 - iconSize.width), + Expanded( + child: FlowyText.regular( + text, + fontSize: 16, + color: textColor, + ), + ), + if (rightIconBuilder != null) rightIconBuilder!(context), + ], + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart index 4f76003e23..f38c724a22 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart @@ -37,6 +37,7 @@ class FlowyOptionTile extends StatelessWidget { this.backgroundColor, this.fontFamily, this.height, + this.enable = true, }); factory FlowyOptionTile.text({ @@ -49,6 +50,7 @@ class FlowyOptionTile extends StatelessWidget { Widget? trailing, VoidCallback? onTap, double? height, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.text, @@ -61,6 +63,7 @@ class FlowyOptionTile extends StatelessWidget { leading: leftIcon, trailing: trailing, height: height, + enable: enable, ); } @@ -77,6 +80,7 @@ class FlowyOptionTile extends StatelessWidget { Widget? trailing, String? textFieldHintText, bool autofocus = false, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.textField, @@ -90,6 +94,7 @@ class FlowyOptionTile extends StatelessWidget { onTextChanged: onTextChanged, onTextSubmitted: onTextSubmitted, autofocus: autofocus, + enable: enable, ); } @@ -105,6 +110,7 @@ class FlowyOptionTile extends StatelessWidget { bool showBottomBorder = true, String? fontFamily, Color? backgroundColor, + bool enable = true, }) { return FlowyOptionTile._( key: key, @@ -119,6 +125,7 @@ class FlowyOptionTile extends StatelessWidget { showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, + enable: enable, trailing: isSelected ? const FlowySvg( FlowySvgs.m_blue_check_s, @@ -136,6 +143,7 @@ class FlowyOptionTile extends StatelessWidget { bool showTopBorder = true, bool showBottomBorder = true, Widget? leftIcon, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.toggle, @@ -146,6 +154,7 @@ class FlowyOptionTile extends StatelessWidget { showBottomBorder: showBottomBorder, leading: leftIcon, trailing: _Toggle(value: isSelected, onChanged: onValueChanged), + enable: enable, ); } @@ -181,11 +190,13 @@ class FlowyOptionTile extends StatelessWidget { final double? height; + final bool enable; + @override Widget build(BuildContext context) { final leadingWidget = _buildLeading(); - final child = FlowyOptionDecorateBox( + Widget child = FlowyOptionDecorateBox( color: backgroundColor, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, @@ -209,12 +220,21 @@ class FlowyOptionTile extends StatelessWidget { if (type == FlowyOptionTileType.checkbox || type == FlowyOptionTileType.toggle || type == FlowyOptionTileType.text) { - return GestureDetector( + child = GestureDetector( onTap: onTap, child: child, ); } + if (!enable) { + child = Opacity( + opacity: 0.5, + child: IgnorePointer( + child: child, + ), + ); + } + return child; } @@ -299,7 +319,7 @@ class _Toggle extends StatelessWidget { fit: BoxFit.fill, child: CupertinoSwitch( value: value, - activeColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: onChanged, ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart index 9df9d2e6fd..96c18f5d91 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart @@ -99,7 +99,7 @@ Future showFlowyCupertinoConfirmDialog({ }) { return showDialog( context: context ?? AppGlobals.context, - barrierColor: Colors.black.withOpacity(0.25), + barrierColor: Colors.black.withValues(alpha: 0.25), builder: (context) => CupertinoAlertDialog( title: FlowyText.medium( title, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart new file mode 100644 index 0000000000..2cfc349bf8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef OnUpdateSelectedModel = void Function(AIModelPB model); + +class AIModelSwitchListener { + AIModelSwitchListener({required this.objectId}) { + _parser = ChatNotificationParser(id: objectId, callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + final String objectId; + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + void start({ + OnUpdateSelectedModel? onUpdateSelectedModel, + }) { + this.onUpdateSelectedModel = onUpdateSelectedModel; + } + + OnUpdateSelectedModel? onUpdateSelectedModel; + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.DidUpdateSelectedModel: + onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r)); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart index f3bef96418..47c1668a2c 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -23,11 +23,126 @@ class ChatAIMessageBloc extends Bloc { parseMetadata(refSourceJsonString), ), ) { - _dispatch(); + _registerEventHandlers(); + _initializeStreamListener(); + _checkInitialStreamState(); + } + final String chatId; + final Int64? questionId; + + void _registerEventHandlers() { + on<_UpdateText>((event, emit) { + emit( + state.copyWith( + text: event.text, + messageState: const MessageState.ready(), + ), + ); + }); + + on<_ReceiveError>((event, emit) { + emit(state.copyWith(messageState: MessageState.onError(event.error))); + }); + + on<_Retry>((event, emit) async { + if (questionId == null) { + Log.error("Question id is not valid: $questionId"); + return; + } + emit(state.copyWith(messageState: const MessageState.loading())); + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: questionId, + ); + final result = await AIEventGetAnswerForQuestion(payload).send(); + if (!isClosed) { + result.fold( + (answer) => add(ChatAIMessageEvent.retryResult(answer.content)), + (err) { + Log.error("Failed to get answer: $err"); + add(ChatAIMessageEvent.receiveError(err.toString())); + }, + ); + } + }); + + on<_RetryResult>((event, emit) { + emit( + state.copyWith( + text: event.text, + messageState: const MessageState.ready(), + ), + ); + }); + + on<_OnAIResponseLimit>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onAIResponseLimit(), + ), + ); + }); + + on<_OnAIImageResponseLimit>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onAIImageResponseLimit(), + ), + ); + }); + + on<_OnAIMaxRquired>((event, emit) { + emit( + state.copyWith( + messageState: MessageState.onAIMaxRequired(event.message), + ), + ); + }); + + on<_OnLocalAIInitializing>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onInitializingLocalAI(), + ), + ); + }); + + on<_ReceiveMetadata>((event, emit) { + Log.debug("AI Steps: ${event.metadata.progress?.step}"); + emit( + state.copyWith( + sources: event.metadata.sources, + progress: event.metadata.progress, + ), + ); + }); + } + + void _initializeStreamListener() { if (state.stream != null) { - _startListening(); + state.stream!.listen( + onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)), + onError: (error) => + _safeAdd(ChatAIMessageEvent.receiveError(error.toString())), + onAIResponseLimit: () => + _safeAdd(const ChatAIMessageEvent.onAIResponseLimit()), + onAIImageResponseLimit: () => + _safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()), + onMetadata: (metadata) => + _safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)), + onAIMaxRequired: (message) { + Log.info(message); + _safeAdd(ChatAIMessageEvent.onAIMaxRequired(message)); + }, + onLocalAIInitializing: () => + _safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()), + ); + } + } + void _checkInitialStreamState() { + if (state.stream != null) { if (state.stream!.aiLimitReached) { add(const ChatAIMessageEvent.onAIResponseLimit()); } else if (state.stream!.error != null) { @@ -36,117 +151,10 @@ class ChatAIMessageBloc extends Bloc { } } - final String chatId; - final Int64? questionId; - - void _dispatch() { - on( - (event, emit) { - event.when( - updateText: (newText) { - emit( - state.copyWith( - text: newText, - messageState: const MessageState.ready(), - ), - ); - }, - receiveError: (error) { - emit(state.copyWith(messageState: MessageState.onError(error))); - }, - retry: () { - if (questionId is! Int64) { - Log.error("Question id is not Int64: $questionId"); - return; - } - emit( - state.copyWith( - messageState: const MessageState.loading(), - ), - ); - - final payload = ChatMessageIdPB( - chatId: chatId, - messageId: questionId, - ); - AIEventGetAnswerForQuestion(payload).send().then((result) { - if (!isClosed) { - result.fold( - (answer) { - add(ChatAIMessageEvent.retryResult(answer.content)); - }, - (err) { - Log.error("Failed to get answer: $err"); - add(ChatAIMessageEvent.receiveError(err.toString())); - }, - ); - } - }); - }, - retryResult: (String text) { - emit( - state.copyWith( - text: text, - messageState: const MessageState.ready(), - ), - ); - }, - onAIResponseLimit: () { - emit( - state.copyWith( - messageState: const MessageState.onAIResponseLimit(), - ), - ); - }, - onAIImageResponseLimit: () { - emit( - state.copyWith( - messageState: const MessageState.onAIImageResponseLimit(), - ), - ); - }, - receiveMetadata: (metadata) { - Log.debug("AI Steps: ${metadata.progress?.step}"); - emit( - state.copyWith( - sources: metadata.sources, - progress: metadata.progress, - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - state.stream!.listen( - onData: (text) { - if (!isClosed) { - add(ChatAIMessageEvent.updateText(text)); - } - }, - onError: (error) { - if (!isClosed) { - add(ChatAIMessageEvent.receiveError(error.toString())); - } - }, - onAIResponseLimit: () { - if (!isClosed) { - add(const ChatAIMessageEvent.onAIResponseLimit()); - } - }, - onAIImageResponseLimit: () { - if (!isClosed) { - add(const ChatAIMessageEvent.onAIImageResponseLimit()); - } - }, - onMetadata: (metadata) { - if (!isClosed) { - add(ChatAIMessageEvent.receiveMetadata(metadata)); - } - }, - ); + void _safeAdd(ChatAIMessageEvent event) { + if (!isClosed) { + add(event); + } } } @@ -159,6 +167,10 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent { const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit; const factory ChatAIMessageEvent.onAIImageResponseLimit() = _OnAIImageResponseLimit; + const factory ChatAIMessageEvent.onAIMaxRequired(String message) = + _OnAIMaxRquired; + const factory ChatAIMessageEvent.onLocalAIInitializing() = + _OnLocalAIInitializing; const factory ChatAIMessageEvent.receiveMetadata( MetadataCollection metadata, ) = _ReceiveMetadata; @@ -193,6 +205,8 @@ class MessageState with _$MessageState { const factory MessageState.onError(String error) = _Error; const factory MessageState.onAIResponseLimit() = _AIResponseLimit; const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit; + const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired; + const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing; const factory MessageState.ready() = _Ready; const factory MessageState.loading() = _Loading; } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 71bf7f63bf..602b46f97a 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; @@ -10,6 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -28,6 +30,7 @@ class ChatBloc extends Bloc { required this.userId, }) : chatController = InMemoryChatController(), listener = ChatMessageListener(chatId: chatId), + selectedSourcesNotifier = ValueNotifier([]), super(ChatState.initial()) { _startListening(); _dispatch(); @@ -38,7 +41,7 @@ class ChatBloc extends Bloc { final String chatId; final String userId; final ChatMessageListener listener; - + final ValueNotifier> selectedSourcesNotifier; final ChatController chatController; /// The last streaming message id @@ -68,7 +71,7 @@ class ChatBloc extends Bloc { await listener.stop(); final request = ViewIdPB(value: chatId); unawaited(FolderEventCloseView(request).send()); - + selectedSourcesNotifier.dispose(); return super.close(); } @@ -236,9 +239,9 @@ class ChatBloc extends Bloc { ), ); }, - regenerateAnswer: (id, format) { + regenerateAnswer: (id, format, model) { _clearRelatedQuestions(); - _regenerateAnswer(id, format); + _regenerateAnswer(id, format, model); lastSentMessage = null; isFetchingRelatedQuestions = false; @@ -251,12 +254,10 @@ class ChatBloc extends Bloc { ); }, didReceiveChatSettings: (settings) { - emit( - state.copyWith(selectedSourceIds: settings.ragIds), - ); + selectedSourcesNotifier.value = settings.ragIds; }, updateSelectedSources: (selectedSourcesIds) async { - emit(state.copyWith(selectedSourceIds: selectedSourcesIds)); + selectedSourcesNotifier.value = [...selectedSourcesIds]; final payload = UpdateChatSettingsPB( chatId: ChatId(value: chatId), @@ -434,7 +435,7 @@ class ChatBloc extends Bloc { messageType: ChatMessageTypePB.User, questionStreamPort: Int64(questionStream.nativePort), answerStreamPort: Int64(answerStream!.nativePort), - metadata: await metadataPBFromMetadata(metadata), + //metadata: await metadataPBFromMetadata(metadata), ); if (format != null) { payload.format = format.toPB(); @@ -482,6 +483,7 @@ class ChatBloc extends Bloc { void _regenerateAnswer( String answerMessageIdString, PredefinedFormat? format, + AIModelPB? model, ) async { final id = temporaryMessageIDMap.entries .firstWhereOrNull((e) => e.value == answerMessageIdString) @@ -504,6 +506,9 @@ class ChatBloc extends Bloc { if (format != null) { payload.format = format.toPB(); } + if (model != null) { + payload.model = model; + } await AIEventRegenerateResponse(payload).send().fold( (success) { @@ -636,6 +641,7 @@ class ChatEvent with _$ChatEvent { const factory ChatEvent.regenerateAnswer( String id, PredefinedFormat? format, + AIModelPB? model, ) = _RegenerateAnswer; // streaming answer @@ -665,14 +671,12 @@ class ChatEvent with _$ChatEvent { @freezed class ChatState with _$ChatState { const factory ChatState({ - required List selectedSourceIds, required LoadChatMessageStatus loadingState, required PromptResponseState promptResponseState, required bool clearErrorMessages, }) = _ChatState; factory ChatState.initial() => const ChatState( - selectedSourceIds: [], loadingState: LoadChatMessageStatus.loading, promptResponseState: PromptResponseState.ready, clearErrorMessages: false, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart index fe2ed44193..41e2a6946d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart @@ -1,11 +1,8 @@ import 'dart:io'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:path/path.dart' as path; @@ -140,87 +137,3 @@ enum LoadChatMessageStatus { loadingRemote, ready, } - -class PredefinedFormat extends Equatable { - const PredefinedFormat({ - required this.imageFormat, - required this.textFormat, - }); - - const PredefinedFormat.auto() - : imageFormat = ImageFormat.text, - textFormat = TextFormat.auto; - - final ImageFormat imageFormat; - final TextFormat? textFormat; - - PredefinedFormatPB toPB() { - return PredefinedFormatPB( - imageFormat: switch (imageFormat) { - ImageFormat.text => ResponseImageFormatPB.TextOnly, - ImageFormat.image => ResponseImageFormatPB.ImageOnly, - ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage, - }, - textFormat: switch (textFormat) { - TextFormat.auto => ResponseTextFormatPB.Paragraph, - TextFormat.bulletList => ResponseTextFormatPB.BulletedList, - TextFormat.numberedList => ResponseTextFormatPB.NumberedList, - TextFormat.table => ResponseTextFormatPB.Table, - _ => null, - }, - ); - } - - @override - List get props => [imageFormat, textFormat]; -} - -enum ImageFormat { - text, - image, - textAndImage; - - bool get hasText => this == text || this == textAndImage; - - FlowySvgData get icon { - return switch (this) { - ImageFormat.text => FlowySvgs.ai_text_s, - ImageFormat.image => FlowySvgs.ai_image_s, - ImageFormat.textAndImage => FlowySvgs.ai_text_image_s, - }; - } - - String get i18n { - return switch (this) { - ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(), - ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(), - ImageFormat.textAndImage => - LocaleKeys.chat_changeFormat_textAndImage.tr(), - }; - } -} - -enum TextFormat { - auto, - bulletList, - numberedList, - table; - - FlowySvgData get icon { - return switch (this) { - TextFormat.auto => FlowySvgs.ai_paragraph_s, - TextFormat.bulletList => FlowySvgs.ai_list_s, - TextFormat.numberedList => FlowySvgs.ai_number_list_s, - TextFormat.table => FlowySvgs.ai_table_s, - }; - } - - String get i18n { - return switch (this) { - TextFormat.auto => LocaleKeys.chat_changeFormat_text.tr(), - TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(), - TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(), - TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(), - }; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart index c675780838..c22559f21b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart @@ -2,47 +2,25 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; +import 'package:appflowy/ai/service/ai_entities.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; +/// A stream that receives answer events from an isolate or external process. +/// It caches events that might occur before a listener is attached. class AnswerStream { AnswerStream() { _port.handler = _controller.add; _subscription = _controller.stream.listen( - (event) { - if (event.startsWith("data:")) { - _hasStarted = true; - final newText = event.substring(5); - _text += newText; - _onData?.call(_text); - } else if (event.startsWith("error:")) { - _error = event.substring(5); - _onError?.call(_error!); - } else if (event.startsWith("metadata:")) { - if (_onMetadata != null) { - final s = event.substring(9); - _onMetadata!(parseMetadata(s)); - } - } else if (event == "AI_RESPONSE_LIMIT") { - _aiLimitReached = true; - _onAIResponseLimit?.call(); - } else if (event == "AI_IMAGE_RESPONSE_LIMIT") { - _aiImageLimitReached = true; - _onAIImageResponseLimit?.call(); - } - }, - onDone: () { - _onEnd?.call(); - }, - onError: (error) { - _error = error.toString(); - _onError?.call(error.toString()); - }, + _handleEvent, + onDone: _onDoneCallback, + onError: _handleError, ); } final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; + bool _hasStarted = false; bool _aiLimitReached = false; bool _aiImageLimitReached = false; @@ -54,9 +32,15 @@ class AnswerStream { void Function()? _onStart; void Function()? _onEnd; void Function(String error)? _onError; + void Function()? _onLocalAIInitializing; void Function()? _onAIResponseLimit; void Function()? _onAIImageResponseLimit; - void Function(MetadataCollection metadataCollection)? _onMetadata; + void Function(String message)? _onAIMaxRequired; + void Function(MetadataCollection metadata)? _onMetadata; + + // Caches for events that occur before listen() is called. + final List _pendingAIMaxRequiredEvents = []; + bool _pendingLocalAINotReady = false; int get nativePort => _port.sendPort.nativePort; bool get hasStarted => _hasStarted; @@ -65,12 +49,61 @@ class AnswerStream { String? get error => _error; String get text => _text; + /// Releases the resources used by the AnswerStream. Future dispose() async { await _controller.close(); await _subscription.cancel(); _port.close(); } + /// Handles incoming events from the underlying stream. + void _handleEvent(String event) { + if (event.startsWith(AIStreamEventPrefix.data)) { + _hasStarted = true; + final newText = event.substring(AIStreamEventPrefix.data.length); + _text += newText; + _onData?.call(_text); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + _error = event.substring(AIStreamEventPrefix.error.length); + _onError?.call(_error!); + } else if (event.startsWith(AIStreamEventPrefix.metadata)) { + final s = event.substring(AIStreamEventPrefix.metadata.length); + _onMetadata?.call(parseMetadata(s)); + } else if (event == AIStreamEventPrefix.aiResponseLimit) { + _aiLimitReached = true; + _onAIResponseLimit?.call(); + } else if (event == AIStreamEventPrefix.aiImageResponseLimit) { + _aiImageLimitReached = true; + _onAIImageResponseLimit?.call(); + } else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length); + if (_onAIMaxRequired != null) { + _onAIMaxRequired!(msg); + } else { + _pendingAIMaxRequiredEvents.add(msg); + } + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { + if (_onLocalAIInitializing != null) { + _onLocalAIInitializing!(); + } else { + _pendingLocalAINotReady = true; + } + } + } + + void _onDoneCallback() { + _onEnd?.call(); + } + + void _handleError(dynamic error) { + _error = error.toString(); + _onError?.call(_error!); + } + + /// Registers listeners for various events. + /// + /// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY), + /// they will be flushed immediately. void listen({ void Function(String text)? onData, void Function()? onStart, @@ -78,7 +111,9 @@ class AnswerStream { void Function(String error)? onError, void Function()? onAIResponseLimit, void Function()? onAIImageResponseLimit, + void Function(String message)? onAIMaxRequired, void Function(MetadataCollection metadata)? onMetadata, + void Function()? onLocalAIInitializing, }) { _onData = onData; _onStart = onStart; @@ -86,7 +121,23 @@ class AnswerStream { _onError = onError; _onAIResponseLimit = onAIResponseLimit; _onAIImageResponseLimit = onAIImageResponseLimit; + _onAIMaxRequired = onAIMaxRequired; _onMetadata = onMetadata; + _onLocalAIInitializing = onLocalAIInitializing; + + // Flush pending AI_MAX_REQUIRED events. + if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) { + for (final msg in _pendingAIMaxRequiredEvents) { + _onAIMaxRequired!(msg); + } + _pendingAIMaxRequiredEvents.clear(); + } + + // Flush pending LOCAL_AI_NOT_READY event. + if (_pendingLocalAINotReady && _onLocalAIInitializing != null) { + _onLocalAIInitializing!(); + _pendingLocalAINotReady = false; + } _onStart?.call(); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart index 90a2db168f..9977d1df72 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart @@ -19,9 +19,17 @@ class ChatSelectMessageBloc on( (event, emit) { event.when( + enableStartSelectingMessages: () { + emit(state.copyWith(enabled: true)); + }, toggleSelectingMessages: () { if (state.isSelectingMessages) { - emit(ChatSelectMessageState.initial()); + emit( + state.copyWith( + isSelectingMessages: false, + selectedMessages: [], + ), + ); } else { emit(state.copyWith(isSelectingMessages: true)); } @@ -50,8 +58,13 @@ class ChatSelectMessageBloc unselectAllMessages: () { emit(state.copyWith(selectedMessages: const [])); }, - saveAsPage: () { - emit(ChatSelectMessageState.initial()); + reset: () { + emit( + state.copyWith( + isSelectingMessages: false, + selectedMessages: [], + ), + ); }, ); }, @@ -70,6 +83,8 @@ class ChatSelectMessageBloc @freezed class ChatSelectMessageEvent with _$ChatSelectMessageEvent { + const factory ChatSelectMessageEvent.enableStartSelectingMessages() = + _EnableStartSelectingMessages; const factory ChatSelectMessageEvent.toggleSelectingMessages() = _ToggleSelectingMessages; const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) = @@ -79,7 +94,7 @@ class ChatSelectMessageEvent with _$ChatSelectMessageEvent { ) = _SelectAllMessages; const factory ChatSelectMessageEvent.unselectAllMessages() = _UnselectAllMessages; - const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage; + const factory ChatSelectMessageEvent.reset() = _Reset; } @freezed @@ -87,9 +102,11 @@ class ChatSelectMessageState with _$ChatSelectMessageState { const factory ChatSelectMessageState({ required bool isSelectingMessages, required List selectedMessages, + required bool enabled, }) = _ChatSelectMessageState; factory ChatSelectMessageState.initial() => const ChatSelectMessageState( + enabled: false, isSelectingMessages: false, selectedMessages: [], ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart index 7a9f790e63..76cbd69cdf 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart @@ -113,9 +113,8 @@ class ChatSettingsCubit extends Cubit { String filter = ''; void updateSelectedSources(List newSelectedSourceIds) { - selectedSourceIds - ..clear - ..addAll(newSelectedSourceIds); + selectedSourceIds.clear(); + selectedSourceIds.addAll(newSelectedSourceIds); } void refreshSources(List spaceViews, ViewPB? currentSpace) async { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart index 1b3880e01d..76aba27dc0 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -178,8 +178,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder customActions: [ CustomViewAction( view: notifier.view, + disabled: !state.enabled, leftIcon: FlowySvgs.ai_add_to_page_s, label: LocaleKeys.moreAction_saveAsNewPage.tr(), + tooltipMessage: state.enabled + ? null + : LocaleKeys.moreAction_saveAsNewPageDisabled.tr(), onTap: () { chatMessageSelectorBloc.add( const ChatSelectMessageEvent diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index 067ba95b6b..90085354db 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; @@ -8,9 +8,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:desktop_drop/desktop_drop.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart' @@ -19,14 +18,12 @@ import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'application/ai_prompt_input_bloc.dart'; import 'application/chat_bloc.dart'; import 'application/chat_entity.dart'; import 'application/chat_member_bloc.dart'; import 'application/chat_select_message_bloc.dart'; import 'application/chat_message_stream.dart'; import 'presentation/animated_chat_list.dart'; -import 'presentation/chat_input/desktop_chat_input.dart'; import 'presentation/chat_input/mobile_chat_input.dart'; import 'presentation/chat_related_question.dart'; import 'presentation/chat_welcome_page.dart'; @@ -51,14 +48,14 @@ class AIChatPage extends StatelessWidget { @override Widget build(BuildContext context) { - if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { - return Center( - child: FlowyText( - LocaleKeys.chat_unsupportedCloudPrompt.tr(), - fontSize: 20, - ), - ); - } + // if (userProfile.authenticator != AuthTypePB.Server) { + // return Center( + // child: FlowyText( + // LocaleKeys.chat_unsupportedCloudPrompt.tr(), + // fontSize: 20, + // ), + // ); + // } return MultiBlocProvider( providers: [ @@ -71,7 +68,15 @@ class AIChatPage extends StatelessWidget { ), /// [AIPromptInputBloc] is used to handle the user prompt - BlocProvider(create: (_) => AIPromptInputBloc()), + BlocProvider( + create: (_) => AIPromptInputBloc( + objectId: view.id, + predefinedFormat: PredefinedFormat( + imageFormat: ImageFormat.text, + textFormat: TextFormat.bulletList, + ), + ), + ), BlocProvider(create: (_) => ChatMemberBloc()), ], child: Builder( @@ -86,9 +91,29 @@ class AIChatPage extends StatelessWidget { } } }, - child: _ChatContentPage( - view: view, - userProfile: userProfile, + child: FocusScope( + onKeyEvent: (focusNode, event) { + if (event is! KeyUpEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.keyC && + HardwareKeyboard.instance.isControlPressed) { + final chatBloc = context.read(); + if (chatBloc.state.promptResponseState != + PromptResponseState.ready) { + chatBloc.add(ChatEvent.stopStream()); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + child: _ChatContentPage( + view: view, + userProfile: userProfile, + ), ), ); }, @@ -121,21 +146,24 @@ class _ChatContentPage extends StatelessWidget { child: Align( alignment: Alignment.topCenter, child: _wrapConstraints( - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith(scrollbars: false), - child: Chat( - chatController: - context.read().chatController, - user: User(id: userProfile.id.toString()), - darkTheme: ChatTheme.fromThemeData(Theme.of(context)), - theme: ChatTheme.fromThemeData(Theme.of(context)), - builders: Builders( - inputBuilder: (_) => const SizedBox.shrink(), - textMessageBuilder: _buildTextMessage, - chatMessageBuilder: _buildChatMessage, - scrollToBottomBuilder: _buildScrollToBottom, - chatAnimatedListBuilder: _buildChatAnimatedList, + SelectionArea( + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: Chat( + chatController: + context.read().chatController, + user: User(id: userProfile.id.toString()), + darkTheme: + ChatTheme.fromThemeData(Theme.of(context)), + theme: ChatTheme.fromThemeData(Theme.of(context)), + builders: Builders( + inputBuilder: (_) => const SizedBox.shrink(), + textMessageBuilder: _buildTextMessage, + chatMessageBuilder: _buildChatMessage, + scrollToBottomBuilder: _buildScrollToBottom, + chatAnimatedListBuilder: _buildChatAnimatedList, + ), ), ), ), @@ -143,7 +171,7 @@ class _ChatContentPage extends StatelessWidget { ), ), _wrapConstraints( - _builtInput(context), + _Input(view: view), ), ], ), @@ -181,26 +209,25 @@ class _ChatContentPage extends StatelessWidget { return RelatedQuestionList( relatedQuestions: message.metadata!['questions'], onQuestionSelected: (question) { - context - .read() - .add(ChatEvent.sendMessage(message: question)); + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + + context.read().add( + ChatEvent.sendMessage( + message: question, + format: showPredefinedFormats ? predefinedFormat : null, + ), + ); }, ); } - if (message.author.id == userProfile.id.toString()) { + if (message.author.id == userProfile.id.toString() || + isOtherUserMessage(message)) { return ChatUserMessageWidget( user: message.author, message: message, - isCurrentUser: true, - ); - } - - if (isOtherUserMessage(message)) { - return ChatUserMessageWidget( - user: message.author, - message: message, - isCurrentUser: false, ); } @@ -235,10 +262,16 @@ class _ChatContentPage extends StatelessWidget { _onSelectMetadata(context, metadata), onRegenerate: () => context .read() - .add(ChatEvent.regenerateAnswer(message.id, null)), + .add(ChatEvent.regenerateAnswer(message.id, null, null)), onChangeFormat: (format) => context .read() - .add(ChatEvent.regenerateAnswer(message.id, format)), + .add(ChatEvent.regenerateAnswer(message.id, format, null)), + onChangeModel: (model) => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, null, model)), + onStopStream: () => context.read().add( + const ChatEvent.stopStream(), + ), ); }, ); @@ -283,11 +316,24 @@ class _ChatContentPage extends StatelessWidget { return ChatWelcomePage( userProfile: userProfile, onSelectedQuestion: (question) { - bloc.add(ChatEvent.sendMessage(message: question)); + final aiPromptInputBloc = context.read(); + final showPredefinedFormats = + aiPromptInputBloc.state.showPredefinedFormats; + final predefinedFormat = aiPromptInputBloc.state.predefinedFormat; + bloc.add( + ChatEvent.sendMessage( + message: question, + format: showPredefinedFormats ? predefinedFormat : null, + ), + ); }, ); } + context + .read() + .add(ChatSelectMessageEvent.enableStartSelectingMessages()); + return BlocSelector( selector: (state) => state.isSelectingMessages, builder: (context, isSelectingMessages) { @@ -305,7 +351,62 @@ class _ChatContentPage extends StatelessWidget { ); } - Widget _builtInput(BuildContext context) { + void _onSelectMetadata( + BuildContext context, + ChatMessageRefSource metadata, + ) async { + // When the source of metatdata is appflowy, which means it is a appflowy page + if (metadata.source == "appflowy") { + final sidebarView = + await ViewBackendService.getView(metadata.id).toNullable(); + if (context.mounted) { + openPageFromMessage(context, sidebarView); + } + return; + } + + if (metadata.source == "web") { + if (isURL(metadata.name)) { + late Uri uri; + try { + uri = Uri.parse(metadata.name); + // `Uri` identifies `localhost` as a scheme + if (!uri.hasScheme || uri.scheme == 'localhost') { + uri = Uri.parse("http://${metadata.name}"); + await InternetAddress.lookup(uri.host); + } + await launchUrl(uri); + } catch (err) { + Log.error("failed to open url $err"); + } + } + return; + } + } +} + +class _Input extends StatefulWidget { + const _Input({ + required this.view, + }); + + final ViewPB view; + + @override + State<_Input> createState() => _InputState(); +} + +class _InputState extends State<_Input> { + final textController = TextEditingController(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return BlocSelector( selector: (state) => state.isSelectingMessages, builder: (context, isSelectingMessages) { @@ -331,9 +432,9 @@ class _ChatContentPage extends StatelessWidget { final chatBloc = context.read(); return UniversalPlatform.isDesktop - ? DesktopChatInput( - chatId: view.id, + ? DesktopPromptInput( isStreaming: !canSendMessage, + textController: textController, onStopStreaming: () { chatBloc.add(const ChatEvent.stopStream()); }, @@ -346,6 +447,8 @@ class _ChatContentPage extends StatelessWidget { ), ); }, + selectedSourcesNotifier: + chatBloc.selectedSourcesNotifier, onUpdateSelectedSources: (ids) { chatBloc.add( ChatEvent.updateSelectedSources( @@ -355,7 +458,6 @@ class _ChatContentPage extends StatelessWidget { }, ) : MobileChatInput( - chatId: view.id, isStreaming: !canSendMessage, onStopStreaming: () { chatBloc.add(const ChatEvent.stopStream()); @@ -369,6 +471,8 @@ class _ChatContentPage extends StatelessWidget { ), ); }, + selectedSourcesNotifier: + chatBloc.selectedSourcesNotifier, onUpdateSelectedSources: (ids) { chatBloc.add( ChatEvent.updateSelectedSources( @@ -384,30 +488,4 @@ class _ChatContentPage extends StatelessWidget { }, ); } - - void _onSelectMetadata( - BuildContext context, - ChatMessageRefSource metadata, - ) async { - if (isURL(metadata.name)) { - late Uri uri; - try { - uri = Uri.parse(metadata.name); - // `Uri` identifies `localhost` as a scheme - if (!uri.hasScheme || uri.scheme == 'localhost') { - uri = Uri.parse("http://${metadata.name}"); - await InternetAddress.lookup(uri.host); - } - await launchUrl(uri); - } catch (err) { - Log.error("failed to open url $err"); - } - } else { - final sidebarView = - await ViewBackendService.getView(metadata.id).toNullable(); - if (context.mounted) { - openPageFromMessage(context, sidebarView); - } - } - } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart index e4fc090605..9b7aadf4a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart @@ -7,10 +7,9 @@ import 'package:diffutil_dart/diffutil.dart' as diffutil; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; -import 'package:provider/provider.dart'; - import 'package:flutter_chat_ui/src/scroll_to_bottom.dart'; import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; +import 'package:provider/provider.dart'; class ChatAnimatedListReversed extends StatefulWidget { const ChatAnimatedListReversed({ diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart index d5ecd09c38..59b7fbd39b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -27,7 +27,7 @@ class ChatAIAvatar extends StatelessWidget { child: const CircleAvatar( backgroundColor: Colors.transparent, child: FlowySvg( - FlowySvgs.flowy_logo_s, + FlowySvgs.app_logo_s, size: Size.square(16), blendMode: null, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart index a979e80746..b79e3c52c3 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart @@ -75,7 +75,8 @@ class ChatEditorStyleCustomizer extends EditorStyleCustomizer { fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, - backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + backgroundColor: + theme.colorScheme.inverseSurface.withValues(alpha: 0.8), ), ), ), @@ -144,7 +145,7 @@ class ChatEditorStyleCustomizer extends EditorStyleCustomizer { return TextStyle( fontFamily: defaultFontFamily, height: 1.5, - color: AFThemeExtension.of(context).onBackground.withOpacity(0.6), + color: AFThemeExtension.of(context).onBackground.withValues(alpha: 0.6), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart index 9b667889e9..76d1af7134 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart @@ -1,8 +1,7 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:extended_text_field/extended_text_field.dart'; @@ -10,21 +9,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../layout_define.dart'; - class MobileChatInput extends StatefulWidget { const MobileChatInput({ super.key, - required this.chatId, required this.isStreaming, required this.onStopStreaming, required this.onSubmitted, + required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); - final String chatId; final bool isStreaming; final void Function() onStopStreaming; + final ValueNotifier> selectedSourcesNotifier; final void Function(String, PredefinedFormat?, Map) onSubmitted; final void Function(List) onUpdateSelectedSources; @@ -38,18 +35,13 @@ class _MobileChatInputState extends State { final focusNode = FocusNode(); final textController = TextEditingController(); - bool showPredefinedFormatSection = true; - PredefinedFormat predefinedFormat = const PredefinedFormat( - imageFormat: ImageFormat.text, - textFormat: TextFormat.bulletList, - ); late SendButtonState sendButtonState; @override void initState() { super.initState(); - textController.addListener(handleTextControllerChange); + textController.addListener(handleTextControllerChanged); // focusNode.onKeyEvent = handleKeyEvent; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -106,51 +98,62 @@ class _MobileChatInputState extends State { borderRadius: const BorderRadius.vertical(top: Radius.circular(8.0)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - MobileAIPromptSizes.attachedFilesBarPadding.vertical + + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MobileAIPromptSizes + .attachedFilesBarPadding.vertical + MobileAIPromptSizes.attachedFilesPreviewHeight, - ), - child: PromptInputFile( - chatId: widget.chatId, - onDeleted: (file) => context - .read() - .add(AIPromptInputEvent.removeFile(file)), - ), - ), - if (showPredefinedFormatSection) - TextFieldTapRegion( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ChangeFormatBar( - predefinedFormat: predefinedFormat, - spacing: 8.0, - onSelectPredefinedFormat: (format) { - setState(() => predefinedFormat = format); - }, + ), + child: PromptInputFile( + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.removeFile(file)), ), ), - ) - else - const VSpace(8.0), - inputTextField(context), - Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - const HSpace(8.0), - leadingButtons(context), - const Spacer(), - sendButton(), - const HSpace(12.0), - ], - ), - ), - ], + if (state.showPredefinedFormats) + TextFieldTapRegion( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ChangeFormatBar( + predefinedFormat: state.predefinedFormat, + spacing: 8.0, + onSelectPredefinedFormat: (format) => + context.read().add( + AIPromptInputEvent.updatePredefinedFormat( + format, + ), + ), + ), + ), + ) + else + const VSpace(8.0), + inputTextField(context), + TextFieldTapRegion( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const HSpace(8.0), + leadingButtons( + context, + state.showPredefinedFormats, + ), + const Spacer(), + sendButton(), + const HSpace(12.0), + ], + ), + ), + ), + ], + ); + }, ), ), ), @@ -183,14 +186,18 @@ class _MobileChatInputState extends State { // get the attached files and mentioned pages final metadata = context.read().consumeMetadata(); + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + widget.onSubmitted( trimmedText, - showPredefinedFormatSection ? predefinedFormat : null, + showPredefinedFormats ? predefinedFormat : null, metadata, ); } - void handleTextControllerChange() { + void handleTextControllerChanged() { if (textController.value.isComposingRangeValid) { return; } @@ -261,10 +268,10 @@ class _MobileChatInputState extends State { focusedBorder: InputBorder.none, contentPadding: MobileAIPromptSizes.textFieldContentPadding, hintText: switch (state.aiType) { - AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), - AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() + AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(), + AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr() }, - hintStyle: AIChatUILayout.inputHintTextStyle(context), + hintStyle: inputHintTextStyle(context), isCollapsed: true, isDense: true, ), @@ -281,12 +288,21 @@ class _MobileChatInputState extends State { fontWeight: FontWeight.w600, ), ), + onTapOutside: (_) => focusNode.unfocus(), ); }, ); } - Widget leadingButtons(BuildContext context) { + TextStyle? inputHintTextStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).isLightMode + ? const Color(0xFFBDC2C8) + : const Color(0xFF3C3E51), + ); + } + + Widget leadingButtons(BuildContext context, bool showPredefinedFormats) { return _LeadingActions( // onMention: () { // textController.text += '@'; @@ -297,13 +313,13 @@ class _MobileChatInputState extends State { // mentionPage(context); // }); // }, - showPredefinedFormatSection: showPredefinedFormatSection, - predefinedFormat: predefinedFormat, + showPredefinedFormats: showPredefinedFormats, onTogglePredefinedFormatSection: () { - setState(() { - showPredefinedFormatSection = !showPredefinedFormatSection; - }); + context + .read() + .add(AIPromptInputEvent.toggleShowPredefinedFormat()); }, + selectedSourcesNotifier: widget.selectedSourcesNotifier, onUpdateSelectedSources: widget.onUpdateSelectedSources, ); } @@ -319,15 +335,15 @@ class _MobileChatInputState extends State { class _LeadingActions extends StatelessWidget { const _LeadingActions({ - required this.showPredefinedFormatSection, - required this.predefinedFormat, + required this.showPredefinedFormats, required this.onTogglePredefinedFormatSection, + required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); - final bool showPredefinedFormatSection; - final PredefinedFormat predefinedFormat; + final bool showPredefinedFormats; final void Function() onTogglePredefinedFormatSection; + final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override @@ -339,10 +355,11 @@ class _LeadingActions extends StatelessWidget { separatorBuilder: () => const HSpace(4.0), children: [ PromptInputMobileSelectSourcesButton( + selectedSourcesNotifier: selectedSourcesNotifier, onUpdateSelectedSources: onUpdateSelectedSources, ), PromptInputMobileToggleFormatButton( - showFormatBar: showPredefinedFormatSection, + showFormatBar: showPredefinedFormats, onTap: onTogglePredefinedFormatSection, ), ], diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart index 4b25297d63..790a3fac3c 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart @@ -250,7 +250,7 @@ class _SaveToPageButtonState extends State { showSaveMessageSuccessToast(context, view); } - bloc.add(const ChatSelectMessageEvent.saveAsPage()); + bloc.add(const ChatSelectMessageEvent.reset()); return view; } @@ -275,7 +275,7 @@ class _SaveToPageButtonState extends State { showSaveMessageSuccessToast(context, newView); openPageFromMessage(context, newView); } - bloc.add(const ChatSelectMessageEvent.saveAsPage()); + bloc.add(const ChatSelectMessageEvent.reset()); } Future forceReload(String documentId) async { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart index deba1cb96d..2c09e77050 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart @@ -21,33 +21,35 @@ class RelatedQuestionList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: relatedQuestions.length + 1, - padding: - const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin, - separatorBuilder: (context, index) => const VSpace(4.0), - itemBuilder: (context, index) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: FlowyText( - LocaleKeys.chat_relatedQuestion.tr(), - color: Theme.of(context).hintColor, - fontWeight: FontWeight.w600, - ), - ); - } else { - return Align( - alignment: AlignmentDirectional.centerStart, - child: RelatedQuestionItem( - question: relatedQuestions[index - 1], - onQuestionSelected: onQuestionSelected, - ), - ); - } - }, + return SelectionContainer.disabled( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: relatedQuestions.length + 1, + padding: + const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin, + separatorBuilder: (context, index) => const VSpace(4.0), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + LocaleKeys.chat_relatedQuestion.tr(), + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ); + } else { + return Align( + alignment: AlignmentDirectional.centerStart, + child: RelatedQuestionItem( + question: relatedQuestions[index - 1], + onQuestionSelected: onQuestionSelected, + ), + ); + } + }, + ), ); } } @@ -70,6 +72,7 @@ class RelatedQuestionItem extends StatelessWidget { child: FlowyText( question, lineHeight: 1.4, + maxLines: 2, overflow: TextOverflow.ellipsis, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart index 6c3dd4bd4d..30dc918f70 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -46,7 +46,7 @@ class ChatWelcomePage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, size: Size.square(32), blendMode: null, ), @@ -125,14 +125,14 @@ class WelcomeSampleQuestion extends StatelessWidget { spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), @@ -140,7 +140,7 @@ class WelcomeSampleQuestion extends StatelessWidget { spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart index eddf778e8a..611ff5d922 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -20,14 +19,6 @@ class AIChatUILayout { static EdgeInsets get messageMargin => UniversalPlatform.isMobile ? const EdgeInsets.symmetric(horizontal: 16) : EdgeInsets.zero; - - static TextStyle? inputHintTextStyle(BuildContext context) { - return Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).isLightMode - ? const Color(0xFFBDC2C8) - : const Color(0xFF3C3E51), - ); - } } class DesktopAIChatSizes { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart index 7edc5f7d5e..5fa3b8f8a7 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -122,7 +122,7 @@ class _Body extends StatelessWidget { opacity: predefinedFormat?.imageFormat.hasText ?? true ? 1 : 0, child: Column( children: [ - _buildTextFormatButton(TextFormat.auto, true), + _buildTextFormatButton(TextFormat.paragraph, true), _buildTextFormatButton(TextFormat.bulletList), _buildTextFormatButton(TextFormat.numberedList), _buildTextFormatButton(TextFormat.table), @@ -153,7 +153,8 @@ class _Body extends StatelessWidget { return; } if (format.hasText) { - final textFormat = predefinedFormat?.textFormat ?? TextFormat.auto; + final textFormat = + predefinedFormat?.textFormat ?? TextFormat.paragraph; onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: textFormat), ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart new file mode 100644 index 0000000000..aa0d840574 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart @@ -0,0 +1,145 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +Future showChangeModelBottomSheet( + BuildContext context, + List models, +) { + return showMobileBottomSheet( + context, + showDragHandle: true, + builder: (context) => _ChangeModelBottomSheetContent(models: models), + ); +} + +class _ChangeModelBottomSheetContent extends StatefulWidget { + const _ChangeModelBottomSheetContent({ + required this.models, + }); + + final List models; + + @override + State<_ChangeModelBottomSheetContent> createState() => + _ChangeModelBottomSheetContentState(); +} + +class _ChangeModelBottomSheetContentState + extends State<_ChangeModelBottomSheetContent> { + AIModelPB? model; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header( + onCancel: () => Navigator.of(context).pop(), + onDone: () => Navigator.of(context).pop(model), + ), + const VSpace(4.0), + _Body( + models: widget.models, + selectedModel: model, + onSelectModel: (format) { + setState(() => model = format); + }, + ), + const VSpace(16.0), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.onCancel, + required this.onDone, + }); + + final VoidCallback onCancel; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: onCancel, + ), + ), + Align( + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + LocaleKeys.chat_switchModel_label.tr(), + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: onDone, + ), + ), + ], + ), + ); + } +} + +class _Body extends StatelessWidget { + const _Body({ + required this.models, + required this.selectedModel, + required this.onSelectModel, + }); + + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB) onSelectModel; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: models + .mapIndexed( + (index, model) => _buildModelButton(model, index == 0), + ) + .toList(), + ); + } + + Widget _buildModelButton( + AIModelPB model, [ + bool isFirst = false, + ]) { + return FlowyOptionTile.checkbox( + text: model.name, + isSelected: model == selectedModel, + showTopBorder: isFirst, + onTap: () { + onSelectModel(model); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart index f178808390..1e7d428263 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart @@ -64,8 +64,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { super.didUpdateWidget(oldWidget); if (oldWidget.markdown != widget.markdown) { - editorState.dispose(); - editorState = _parseMarkdown(widget.markdown.trim()); + final editorState = _parseMarkdown( + widget.markdown.trim(), + previousDocument: this.editorState.document, + ); + this.editorState.dispose(); + this.editorState = editorState; scrollController.dispose(); scrollController = EditorScrollController( editorState: editorState, @@ -101,7 +105,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { styleCustomizer: styleCustomizer, // the editor is not editable in the chat editable: false, - alwaysDistributeSimpleTableColumnWidths: true, + alwaysDistributeSimpleTableColumnWidths: UniversalPlatform.isDesktop, ); return IntrinsicHeight( child: AppFlowyEditor( @@ -109,6 +113,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { // the editor is not editable in the chat editable: false, disableKeyboardService: UniversalPlatform.isMobile, + disableSelectionService: UniversalPlatform.isMobile, editorStyle: editorStyle, editorScrollController: scrollController, blockComponentBuilders: blockBuilders, @@ -128,8 +133,30 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { ); } - EditorState _parseMarkdown(String markdown) { + EditorState _parseMarkdown( + String markdown, { + Document? previousDocument, + }) { + // merge the nodes from the previous document with the new document to keep the same node ids final document = customMarkdownToDocument(markdown); + final documentIterator = NodeIterator( + document: document, + startNode: document.root, + ); + if (previousDocument != null) { + final previousDocumentIterator = NodeIterator( + document: previousDocument, + startNode: previousDocument.root, + ); + while ( + documentIterator.moveNext() && previousDocumentIterator.moveNext()) { + final currentNode = documentIterator.current; + final previousNode = previousDocumentIterator.current; + if (currentNode.path.equals(previousNode.path)) { + currentNode.id = previousNode.id; + } + } + } final editorState = EditorState(document: document); return editorState; } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart index 7ae4a11f19..08fd82188d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart @@ -1,14 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'package:appflowy/ai/widgets/prompt_input/layout_define.dart'; -import 'package:appflowy/ai/widgets/prompt_input/predefined_format_buttons.dart'; -import 'package:appflowy/ai/widgets/prompt_input/select_sources_menu.dart'; +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; @@ -24,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -44,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget { required this.showDecoration, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, this.onOverrideVisibility, }); @@ -51,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget { final bool showDecoration; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; final void Function(bool)? onOverrideVisibility; @override @@ -89,14 +89,14 @@ class _AIMessageActionBarState extends State { spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), @@ -104,7 +104,7 @@ class _AIMessageActionBarState extends State { spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), @@ -129,6 +129,12 @@ class _AIMessageActionBarState extends State { popoverMutex: popoverMutex, onOverrideVisibility: widget.onOverrideVisibility, ), + ChangeModelButton( + isInHoverBar: widget.showDecoration, + onRegenerate: widget.onChangeModel, + popoverMutex: popoverMutex, + onOverrideVisibility: widget.onOverrideVisibility, + ), SaveToPageButton( textMessage: widget.message as TextMessage, isInHoverBar: widget.showDecoration, @@ -178,8 +184,7 @@ class CopyButton extends StatelessWidget { ); if (context.mounted) { showToastNotification( - context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } }, @@ -220,7 +225,7 @@ class RegenerateButton extends StatelessWidget { ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: FlowySvg( - FlowySvgs.ai_undo_s, + FlowySvgs.ai_try_again_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), @@ -263,8 +268,11 @@ class _ChangeFormatButtonState extends State { constraints: const BoxConstraints(), onClose: () => widget.onOverrideVisibility?.call(false), child: buildButton(context), - popupBuilder: (_) => _ChangeFormatPopoverContent( - onRegenerate: widget.onRegenerate, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: _ChangeFormatPopoverContent( + onRegenerate: widget.onRegenerate, + ), ), ); } @@ -340,14 +348,14 @@ class _ChangeFormatPopoverContentState spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), @@ -355,18 +363,23 @@ class _ChangeFormatPopoverContentState spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) - : Theme.of(context).shadowColor.withOpacity(0.02), + : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - ChangeFormatBar( - spacing: 2.0, - predefinedFormat: predefinedFormat, - onSelectPredefinedFormat: (format) { - setState(() => predefinedFormat = format); + BlocBuilder( + builder: (context, state) { + return ChangeFormatBar( + spacing: 2.0, + showImageFormats: state.aiType.isCloud, + predefinedFormat: predefinedFormat, + onSelectPredefinedFormat: (format) { + setState(() => predefinedFormat = format); + }, + ); }, ), const HSpace(4.0), @@ -377,8 +390,9 @@ class _ChangeFormatPopoverContentState child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { - widget.onRegenerate - ?.call(predefinedFormat ?? const PredefinedFormat.auto()); + if (predefinedFormat != null) { + widget.onRegenerate?.call(predefinedFormat!); + } }, child: SizedBox.square( dimension: DesktopAIPromptSizes.predefinedFormatButtonHeight, @@ -399,6 +413,85 @@ class _ChangeFormatPopoverContentState } } +class ChangeModelButton extends StatefulWidget { + const ChangeModelButton({ + super.key, + required this.isInHoverBar, + this.popoverMutex, + this.onRegenerate, + this.onOverrideVisibility, + }); + + final bool isInHoverBar; + final PopoverMutex? popoverMutex; + final void Function(AIModelPB)? onRegenerate; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _ChangeModelButtonState(); +} + +class _ChangeModelButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + mutex: widget.popoverMutex, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: Offset(8, 0), + direction: PopoverDirection.rightWithBottomAligned, + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + onClose: () => widget.onOverrideVisibility?.call(false), + child: buildButton(context), + popupBuilder: (_) { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + return SelectModelPopoverContent( + models: models, + selectedModel: null, + onSelectModel: widget.onRegenerate, + ); + }, + ); + } + + Widget buildButton(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: FlowyIconButton( + width: 32.0, + height: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + onPressed: () { + widget.onOverrideVisibility?.call(true); + popoverController.show(); + }, + ), + ); + } +} + class SaveToPageButton extends StatefulWidget { const SaveToPageButton({ super.key, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart index 802b8d6142..2786799520 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -1,17 +1,18 @@ import 'dart:convert'; +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -22,8 +23,8 @@ import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../chat_avatar.dart'; -import '../../../../ai/widgets/prompt_input/mention_page_bottom_sheet.dart'; import '../layout_define.dart'; +import 'ai_change_model_bottom_sheet.dart'; import 'ai_message_action_bar.dart'; import 'ai_change_format_bottom_sheet.dart'; import 'message_util.dart'; @@ -42,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget { this.isSelectingMessages = false, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Message message; @@ -51,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget { final bool isSelectingMessages; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { @@ -74,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -83,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -92,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget { message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: child, ); } @@ -104,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { @@ -128,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget { showDecoration: false, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, ), ), const VSpace(32.0), @@ -143,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; @override State createState() => _ChatAIMessageHoverState(); @@ -218,22 +229,23 @@ class _ChatAIMessageHoverState extends State { setState(() => hoverActionBar = false); } }, - child: Container( - constraints: BoxConstraints( - maxWidth: 784, - maxHeight: DesktopAIChatSizes.messageActionBarIconSize + - DesktopAIChatSizes - .messageHoverActionBarPadding.vertical, - ), + child: SizedBox( + width: 784, + height: DesktopAIChatSizes.messageActionBarIconSize + + DesktopAIChatSizes.messageHoverActionBarPadding.vertical, child: hoverBubble || hoverActionBar || overrideVisibility - ? AIMessageActionBar( - message: widget.message, - showDecoration: true, - onRegenerate: widget.onRegenerate, - onChangeFormat: widget.onChangeFormat, - onOverrideVisibility: (visibility) { - overrideVisibility = visibility; - }, + ? Align( + alignment: AlignmentDirectional.centerStart, + child: AIMessageActionBar( + message: widget.message, + showDecoration: true, + onRegenerate: widget.onRegenerate, + onChangeFormat: widget.onChangeFormat, + onChangeModel: widget.onChangeModel, + onOverrideVisibility: (visibility) { + overrideVisibility = visibility; + }, + ), ) : null, ), @@ -303,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget { required this.message, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { @@ -329,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget { _divider(), _changeFormatButton(context), _divider(), + _changeModelButton(context), + _divider(), _saveToPageButton(context), ], ); @@ -360,8 +376,7 @@ class ChatAIMessagePopup extends StatelessWidget { } if (context.mounted) { showToastNotification( - context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } }, @@ -377,7 +392,7 @@ class ChatAIMessagePopup extends StatelessWidget { onRegenerate?.call(); Navigator.of(context).pop(); }, - icon: FlowySvgs.ai_undo_s, + icon: FlowySvgs.ai_try_again_s, iconSize: const Size.square(20), text: LocaleKeys.chat_regenerate.tr(), ); @@ -400,6 +415,25 @@ class ChatAIMessagePopup extends StatelessWidget { ); } + Widget _changeModelButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () async { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + final result = await showChangeModelBottomSheet(context, models); + if (result != null) { + onChangeModel?.call(result); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + icon: FlowySvgs.ai_sparks_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_switchModel_label.tr(), + ); + } + Widget _saveToPageButton(BuildContext context) { return MobileQuickActionButton( onTap: () async { @@ -470,7 +504,9 @@ class _WrapIsSelectingMessage extends StatelessWidget { if (isSelectingMessages) ChatSelectMessageIndicator(isSelected: isSelected) else - const ChatAIAvatar(), + SelectionContainer.disabled( + child: const ChatAIAvatar(), + ), const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), Expanded( child: IgnorePointer( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index f36a77c0ee..380767105f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -1,16 +1,16 @@ -import 'package:appflowy/ai/widgets/loading_indicator.dart'; +import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; -import 'package:universal_platform/universal_platform.dart'; import '../layout_define.dart'; import 'ai_markdown_text.dart'; @@ -33,9 +33,11 @@ class ChatAIMessageWidget extends StatelessWidget { required this.questionId, required this.chatId, required this.refSourceJsonString, + required this.onStopStream, this.onSelectedMetadata, this.onRegenerate, this.onChangeFormat, + this.onChangeModel, this.isLastMessage = false, this.isStreaming = false, this.isSelectingMessages = false, @@ -51,7 +53,9 @@ class ChatAIMessageWidget extends StatelessWidget { final String? refSourceJsonString; final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; final void Function()? onRegenerate; + final void Function() onStopStream; final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; final bool isStreaming; final bool isLastMessage; final bool isSelectingMessages; @@ -85,14 +89,20 @@ class ChatAIMessageWidget extends StatelessWidget { loading: () => ChatAIMessageBubble( message: message, showActions: false, - child: AILoadingIndicator(text: loadingText), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AILoadingIndicator(text: loadingText), + ), ), ready: () { return state.text.isEmpty ? ChatAIMessageBubble( message: message, showActions: false, - child: AILoadingIndicator(text: loadingText), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AILoadingIndicator(text: loadingText), + ), ) : ChatAIMessageBubble( message: message, @@ -103,17 +113,19 @@ class ChatAIMessageWidget extends StatelessWidget { isSelectingMessages: isSelectingMessages, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - IgnorePointer( - ignoring: UniversalPlatform.isMobile, - child: AIMarkdownText(markdown: state.text), + AIMarkdownText( + markdown: state.text, ), if (state.sources.isNotEmpty) - AIMessageMetadata( - sources: state.sources, - onSelectedMetadata: onSelectedMetadata, + SelectionContainer.disabled( + child: AIMessageMetadata( + sources: state.sources, + onSelectedMetadata: onSelectedMetadata, + ), ), if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0), @@ -137,6 +149,20 @@ class ChatAIMessageWidget extends StatelessWidget { errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(), ); }, + onAIMaxRequired: (message) { + return ChatErrorMessageWidget( + errorMessage: message, + ); + }, + onInitializingLocalAI: () { + onStopStream(); + + return ChatErrorMessageWidget( + errorMessage: LocaleKeys + .settings_aiPage_keys_localAIInitializing + .tr(), + ); + }, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart index 1b0084c77c..652fe3791b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart @@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart'; void openPageFromMessage(BuildContext context, ViewPB? view) { if (view == null) { showToastNotification( - context, message: LocaleKeys.chat_openPagePreviewFailedToast.tr(), type: ToastificationType.error, ); @@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { return; } showToastNotification( - context, richMessage: TextSpan( children: [ TextSpan( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart index e70f346ecc..8bd115ad0f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -15,13 +15,11 @@ class ChatUserMessageBubble extends StatelessWidget { super.key, required this.message, required this.child, - required this.isCurrentUser, this.files = const [], }); final Message message; final Widget child; - final bool isCurrentUser; final List files; @override @@ -44,40 +42,28 @@ class ChatUserMessageBubble extends StatelessWidget { const VSpace(6), ], Row( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: getChildren(context), + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildBubble(context), + const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), + _buildAvatar(), + ], ), ], ), ); } - List getChildren(BuildContext context) { - if (isCurrentUser) { - return [ - const Spacer(), - _buildBubble(context), - const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), - _buildAvatar(), - ]; - } else { - return [ - _buildAvatar(), - const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), - _buildBubble(context), - const Spacer(), - ]; - } - } - Widget _buildAvatar() { return BlocBuilder( builder: (context, state) { final member = state.members[message.author.id]; - return ChatUserAvatar( - iconUrl: member?.info.avatarUrl ?? "", - name: member?.info.name ?? "", + return SelectionContainer.disabled( + child: ChatUserAvatar( + iconUrl: member?.info.avatarUrl ?? "", + name: member?.info.name ?? "", + ), ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart index ae0d2863ce..c73100b59d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart @@ -15,12 +15,10 @@ class ChatUserMessageWidget extends StatelessWidget { super.key, required this.user, required this.message, - required this.isCurrentUser, }); final User user; final TextMessage message; - final bool isCurrentUser; @override Widget build(BuildContext context) { @@ -34,7 +32,6 @@ class ChatUserMessageWidget extends StatelessWidget { ), child: ChatUserMessageBubble( message: message, - isCurrentUser: isCurrentUser, files: _getFiles(), child: BlocBuilder( builder: (context, state) { @@ -83,7 +80,6 @@ class TextMessageText extends StatelessWidget { text, lineHeight: 1.4, maxLines: null, - selectable: true, color: AFThemeExtension.of(context).textColor, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart index 0cfa2efe7b..d66a6665b3 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart @@ -41,21 +41,21 @@ class CustomScrollToBottom extends StatelessWidget { spreadRadius: 8, color: isLightMode ? const Color(0x0F1F2329) - : Theme.of(context).shadowColor.withOpacity(0.06), + : Theme.of(context).shadowColor.withValues(alpha: 0.06), ), BoxShadow( offset: const Offset(0, 4), blurRadius: 8, color: isLightMode ? const Color(0x141F2329) - : Theme.of(context).shadowColor.withOpacity(0.08), + : Theme.of(context).shadowColor.withValues(alpha: 0.08), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x1F1F2329) - : Theme.of(context).shadowColor.withOpacity(0.12), + : Theme.of(context).shadowColor.withValues(alpha: 0.12), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart index df159b817b..73b2d2977b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart @@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc { if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); - - // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. - // So for every cell data that will be formatted in the backend. - // It needs to get the formatted data after saving. - add( - NumberCellEvent.didReceiveCellUpdate( - cellController.getCellData(), - ), - ); } }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart index 70c5e074ab..ec789b03a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -143,12 +143,11 @@ class RelationCellBloc extends Bloc { (f) => null, ); if (databaseMeta != null) { - final result = - await ViewBackendService.getView(databaseMeta.inlineViewId); + final result = await ViewBackendService.getView(databaseMeta.viewId); return result.fold( (s) => DatabaseMeta( databaseId: databaseId, - inlineViewId: databaseMeta.inlineViewId, + viewId: databaseMeta.viewId, databaseName: s.name, ), (f) => null, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart index f8ed915b62..c6e4e6484b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart @@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc } else if (!state.selectedOptions .any((option) => option.id == focusedOptionId)) { _selectOptionService.select(optionIds: [focusedOptionId]); + emit( + state.copyWith( + clearFilter: true, + ), + ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart index 5d0bb760fe..5317539128 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/view/view_cache.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; @@ -14,6 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'defines.dart'; import 'row/row_cache.dart'; @@ -41,7 +40,9 @@ class GroupCallbacks { } class DatabaseLayoutSettingCallbacks { - DatabaseLayoutSettingCallbacks({required this.onLayoutSettingsChanged}); + DatabaseLayoutSettingCallbacks({ + required this.onLayoutSettingsChanged, + }); final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged; } @@ -97,9 +98,11 @@ class DatabaseController { final List _databaseCallbacks = []; final List _groupCallbacks = []; final List _layoutCallbacks = []; + final Set> _compactModeCallbacks = {}; // Getters RowCache get rowCache => _viewCache.rowCache; + String get viewId => view.id; // Listener @@ -107,17 +110,26 @@ class DatabaseController { final DatabaseLayoutSettingListener _layoutListener; final ValueNotifier _isLoading = ValueNotifier(true); + final ValueNotifier _compactMode = ValueNotifier(true); - void setIsLoading(bool isLoading) { - _isLoading.value = isLoading; - } + void setIsLoading(bool isLoading) => _isLoading.value = isLoading; ValueNotifier get isLoading => _isLoading; + void setCompactMode(bool compactMode) { + _compactMode.value = compactMode; + for (final callback in Set.of(_compactModeCallbacks)) { + callback.call(compactMode); + } + } + + ValueNotifier get compactModeNotifier => _compactMode; + void addListener({ DatabaseCallbacks? onDatabaseChanged, DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, + ValueChanged? onCompactModeChanged, }) { if (onLayoutSettingsChanged != null) { _layoutCallbacks.add(onLayoutSettingsChanged); @@ -130,12 +142,17 @@ class DatabaseController { if (onGroupChanged != null) { _groupCallbacks.add(onGroupChanged); } + + if (onCompactModeChanged != null) { + _compactModeCallbacks.add(onCompactModeChanged); + } } void removeListener({ DatabaseCallbacks? onDatabaseChanged, DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, + ValueChanged? onCompactModeChanged, }) { if (onDatabaseChanged != null) { _databaseCallbacks.remove(onDatabaseChanged); @@ -148,6 +165,10 @@ class DatabaseController { if (onGroupChanged != null) { _groupCallbacks.remove(onGroupChanged); } + + if (onCompactModeChanged != null) { + _compactModeCallbacks.remove(onCompactModeChanged); + } } Future> open() async { @@ -242,6 +263,7 @@ class DatabaseController { _databaseCallbacks.clear(); _groupCallbacks.clear(); _layoutCallbacks.clear(); + _compactModeCallbacks.clear(); _isLoading.dispose(); } @@ -376,4 +398,10 @@ class DatabaseController { }, ); } + + void initCompactMode(bool enableCompactMode) { + if (_compactMode.value != enableCompactMode) { + _compactMode.value = enableCompactMode; + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart index 8370bd9bff..93fd69bcfc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -411,23 +411,28 @@ class FieldController { /// Listen for field setting changes in the backend. void _listenOnFieldSettingsChanged() { FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) { - final List newFields = fieldInfos; - var updatedField = newFields.firstOrNull; + final newFields = [...fieldInfos]; - if (updatedField == null) { + if (newFields.isEmpty) { return null; } final index = newFields .indexWhere((field) => field.id == updatedFieldSettings.fieldId); + if (index != -1) { newFields[index] = newFields[index].copyWith(fieldSettings: updatedFieldSettings); - updatedField = newFields[index]; + _fieldNotifier.fieldInfos = newFields; + _fieldSettings + ..removeWhere( + (field) => field.fieldId == updatedFieldSettings.fieldId, + ) + ..add(updatedFieldSettings); + return newFields[index]; } - _fieldNotifier.fieldInfos = newFields; - return updatedField; + return null; } _fieldSettingsListener.start( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart index 691b6b7227..4ddde80b79 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart @@ -17,11 +17,11 @@ class RelationDatabaseListCubit extends Cubit { .send() .fold>((s) => s.items, (f) => []); final futures = metaPBs.map((meta) { - return ViewBackendService.getView(meta.inlineViewId).then( + return ViewBackendService.getView(meta.viewId).then( (result) => result.fold( (s) => DatabaseMeta( databaseId: meta.databaseId, - inlineViewId: meta.inlineViewId, + viewId: meta.viewId, databaseName: s.name, ), (f) => null, @@ -43,10 +43,10 @@ class DatabaseMeta with _$DatabaseMeta { /// id of the database required String databaseId, - /// id of the inline view - required String inlineViewId, + /// id of the view + required String viewId, - /// name of the database, currently identical to the name of the inline view + /// name of the database required String databaseName, }) = _DatabaseMeta; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart index a5dd0d9ca1..0f884a1e9a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart @@ -31,6 +31,7 @@ class DatabaseLayoutBloc @freezed class DatabaseLayoutEvent with _$DatabaseLayoutEvent { const factory DatabaseLayoutEvent.initial() = _Initial; + const factory DatabaseLayoutEvent.updateLayout(DatabaseLayoutPB layout) = _UpdateLayout; } 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 4f975cd1a6..f735618dd8 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 @@ -73,27 +73,24 @@ class RelatedRowDetailPageBloc }); } - /// initialize bloc through the `database_id` and `row_id`. The process is as - /// follows: - /// 1. use the `database_id` to get the database meta, which contains the - /// `inline_view_id` - /// 2. use the `inline_view_id` to instantiate a `DatabaseController`. - /// 3. use the `row_id` with the DatabaseController` to create `RowController` void _init(String databaseId, String initialRowId) async { - final databaseMeta = - await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId)) - .send() - .fold((s) => s, (f) => null); - if (databaseMeta == null) { + final viewId = await DatabaseEventGetDefaultDatabaseViewId( + DatabaseIdPB(value: databaseId), + ).send().fold( + (pb) => pb.value, + (error) => null, + ); + + if (viewId == null) { return; } - final inlineView = - await ViewBackendService.getView(databaseMeta.inlineViewId) - .fold((viewPB) => viewPB, (f) => null); - if (inlineView == null) { + + final databaseView = await ViewBackendService.getView(viewId) + .fold((viewPB) => viewPB, (f) => null); + if (databaseView == null) { return; } - final databaseController = DatabaseController(view: inlineView); + final databaseController = DatabaseController(view: databaseView); await databaseController.open().fold( (s) => databaseController.setIsLoading(false), (f) => null, @@ -104,7 +101,7 @@ class RelatedRowDetailPageBloc } final rowController = RowController( rowMeta: rowInfo.rowMeta, - viewId: inlineView.id, + viewId: databaseView.id, rowCache: databaseController.rowCache, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart index ae0b9173c7..5116785c1f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart @@ -30,9 +30,9 @@ class DatabaseSyncBloc extends Bloc { .then((value) => value.fold((s) => s, (f) => null)); emit( state.copyWith( - shouldShowIndicator: userProfile?.authenticator == - AuthenticatorPB.AppFlowyCloud && - databaseId != null, + shouldShowIndicator: + userProfile?.authType == AuthTypePB.Server && + databaseId != null, ), ); if (databaseId != null) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart index bb892f4b47..e55bbb96a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; @@ -17,8 +18,17 @@ part 'tab_bar_bloc.freezed.dart'; class DatabaseTabBarBloc extends Bloc { - DatabaseTabBarBloc({required ViewPB view}) - : super(DatabaseTabBarState.initial(view)) { + DatabaseTabBarBloc({ + required ViewPB view, + required String compactModeId, + required bool enableCompactMode, + }) : super( + DatabaseTabBarState.initial( + view, + compactModeId, + enableCompactMode, + ), + ) { on( (event, emit) async { await event.when( @@ -154,10 +164,13 @@ class DatabaseTabBarBloc ) { final tabBarControllerByViewId = {...state.tabBarControllerByViewId}; for (final view in newViews) { - final controller = DatabaseTabBarController(view: view); - controller.onViewUpdated = (newView) { - add(DatabaseTabBarEvent.viewDidUpdate(newView)); - }; + final controller = DatabaseTabBarController( + view: view, + compactModeId: state.compactModeId, + enableCompactMode: state.enableCompactMode, + )..onViewUpdated = (newView) { + add(DatabaseTabBarEvent.viewDidUpdate(newView)); + }; tabBarControllerByViewId[view.id] = controller; } @@ -205,20 +218,27 @@ class DatabaseTabBarBloc @freezed class DatabaseTabBarEvent with _$DatabaseTabBarEvent { const factory DatabaseTabBarEvent.initial() = _Initial; + const factory DatabaseTabBarEvent.didLoadChildViews( List childViews, ) = _DidLoadChildViews; + const factory DatabaseTabBarEvent.selectView(String viewId) = _DidSelectView; + const factory DatabaseTabBarEvent.createView( DatabaseLayoutPB layout, String? name, ) = _CreateView; + const factory DatabaseTabBarEvent.renameView(String viewId, String newName) = _RenameView; + const factory DatabaseTabBarEvent.deleteView(String viewId) = _DeleteView; + const factory DatabaseTabBarEvent.didUpdateChildViews( ChildViewUpdatePB updatePB, ) = _DidUpdateChildViews; + const factory DatabaseTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate; } @@ -227,19 +247,29 @@ class DatabaseTabBarState with _$DatabaseTabBarState { const factory DatabaseTabBarState({ required ViewPB parentView, required int selectedIndex, + required String compactModeId, + required bool enableCompactMode, required List tabBars, required Map tabBarControllerByViewId, }) = _DatabaseTabBarState; - factory DatabaseTabBarState.initial(ViewPB view) { + factory DatabaseTabBarState.initial( + ViewPB view, + String compactModeId, + bool enableCompactMode, + ) { final tabBar = DatabaseTabBar(view: view); return DatabaseTabBarState( parentView: view, selectedIndex: 0, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, tabBars: [tabBar], tabBarControllerByViewId: { view.id: DatabaseTabBarController( view: view, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, ), }, ); @@ -257,7 +287,9 @@ class DatabaseTabBar extends Equatable { final DatabaseTabBarItemBuilder _builder; String get viewId => view.id; + DatabaseTabBarItemBuilder get builder => _builder; + ViewLayoutPB get layout => view.layout; @override @@ -274,8 +306,18 @@ typedef OnViewChildViewChanged = void Function( ); class DatabaseTabBarController { - DatabaseTabBarController({required this.view}) - : controller = DatabaseController(view: view), + DatabaseTabBarController({ + required this.view, + required String compactModeId, + required bool enableCompactMode, + }) : controller = DatabaseController(view: view) + ..initCompactMode(enableCompactMode) + ..addListener( + onCompactModeChanged: (v) async { + compactModeEventBus + .fire(CompactModeEvent(id: compactModeId, enable: v)); + }, + ), viewListener = ViewListener(viewId: view.id) { viewListener.start( onViewChildViewsUpdated: (update) => onViewChildViewChanged?.call(update), 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 b373e33b2f..70d00bcd25 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 @@ -1,9 +1,5 @@ import 'dart:io'; -import 'package:appflowy/util/field_type_extension.dart'; -import 'package:flutter/material.dart' hide Card; -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart'; @@ -19,6 +15,9 @@ import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desk import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/shared/conditional_listenable_builder.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.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'; @@ -26,13 +25,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart' hide Card; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../widgets/card/card.dart'; import '../../widgets/cell/card_cell_builder.dart'; import '../application/board_bloc.dart'; - import 'toolbar/board_setting_bar.dart'; import 'widgets/board_focus_scope.dart'; import 'widgets/board_hidden_groups.dart'; @@ -206,6 +206,7 @@ class _DesktopBoardPageState extends State { onEditStateChanged: widget.onEditStateChanged, focusScope: _focusScope, boardController: _boardController, + view: widget.view, ), ), ), @@ -240,6 +241,7 @@ class _BoardContent extends StatefulWidget { const _BoardContent({ required this.boardController, required this.focusScope, + required this.view, this.onEditStateChanged, this.shrinkWrap = false, }); @@ -248,6 +250,7 @@ class _BoardContent extends StatefulWidget { final BoardFocusScope focusScope; final VoidCallback? onEditStateChanged; final bool shrinkWrap; + final ViewPB view; @override State<_BoardContent> createState() => _BoardContentState(); @@ -282,6 +285,9 @@ class _BoardContentState extends State<_BoardContent> { @override Widget build(BuildContext context) { + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; return MultiBlocListener( listeners: [ BlocListener( @@ -334,57 +340,92 @@ class _BoardContentState extends State<_BoardContent> { focusScope: widget.focusScope, child: Padding( padding: const EdgeInsets.only(top: 8.0), - child: AppFlowyBoard( - boardScrollController: scrollManager, - scrollController: scrollController, - controller: context.read().boardController, - groupConstraints: const BoxConstraints.tightFor(width: 256), - config: config, - leading: HiddenGroupsColumn(margin: config.groupHeaderPadding), - trailing: context - .read() - .groupingFieldType - ?.canCreateNewGroup ?? - false - ? BoardTrailing(scrollController: scrollController) - : const HSpace(40), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), - child: BoardColumnHeader( - databaseController: databaseController, - groupData: groupData, - margin: config.groupHeaderPadding, - ), - ), - footerBuilder: (_, groupData) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - BlocProvider.value(value: context.read()), - ], - child: BoardColumnFooter( - columnData: groupData, - boardConfig: config, - scrollManager: scrollManager, - ), - ), - cardBuilder: (_, column, columnItem) => MultiBlocProvider( - key: ValueKey("board_card_${column.id}_${columnItem.id}"), - providers: [ - BlocProvider.value( + child: ValueListenableBuilder( + valueListenable: databaseController.compactModeNotifier, + builder: (context, compactMode, _) { + return AppFlowyBoard( + boardScrollController: scrollManager, + scrollController: scrollController, + shrinkWrap: widget.shrinkWrap, + controller: context.read().boardController, + groupConstraints: + BoxConstraints.tightFor(width: compactMode ? 196 : 256), + config: config, + leading: HiddenGroupsColumn( + shrinkWrap: widget.shrinkWrap, + margin: config.groupHeaderPadding + + EdgeInsets.only( + left: widget.shrinkWrap ? horizontalPadding : 0.0, + ), + ), + trailing: context + .read() + .groupingFieldType + ?.canCreateNewGroup ?? + false + ? BoardTrailing(scrollController: scrollController) + : const HSpace(40), + headerBuilder: (_, groupData) => BlocProvider.value( value: context.read(), + child: BoardColumnHeader( + databaseController: databaseController, + groupData: groupData, + margin: config.groupHeaderPadding, + ), ), - BlocProvider.value( - value: context.read(), + footerBuilder: (_, groupData) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value( + value: context.read(), + ), + ], + child: BoardColumnFooter( + columnData: groupData, + boardConfig: config, + scrollManager: scrollManager, + ), ), - ], - child: _BoardCard( - afGroupData: column, - groupItem: columnItem as GroupItem, - boardConfig: config, - notifier: widget.focusScope, - cellBuilder: cellBuilder, - ), - ), + cardBuilder: (cardContext, column, columnItem) => + MultiBlocProvider( + key: ValueKey("board_card_${column.id}_${columnItem.id}"), + providers: [ + BlocProvider.value( + value: cardContext.read(), + ), + BlocProvider.value( + value: cardContext.read(), + ), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (lockStatusContext, state) { + return IgnorePointer( + ignoring: state.isLocked, + child: _BoardCard( + afGroupData: column, + groupItem: columnItem as GroupItem, + boardConfig: config, + notifier: widget.focusScope, + cellBuilder: cellBuilder, + compactMode: compactMode, + onOpenCard: (rowMeta) => _openCard( + context: context, + databaseController: lockStatusContext + .read() + .databaseController, + rowMeta: rowMeta, + ), + ), + ); + }, + ), + ), + ); + }, ), ), ), @@ -546,6 +587,8 @@ class _BoardCard extends StatefulWidget { required this.boardConfig, required this.cellBuilder, required this.notifier, + required this.compactMode, + required this.onOpenCard, }); final AppFlowyGroupData afGroupData; @@ -553,6 +596,8 @@ class _BoardCard extends StatefulWidget { final AppFlowyBoardConfig boardConfig; final CardCellBuilder cellBuilder; final BoardFocusScope notifier; + final bool compactMode; + final void Function(RowMetaPB) onOpenCard; @override State<_BoardCard> createState() => _BoardCardState(); @@ -639,15 +684,21 @@ class _BoardCardState extends State<_BoardCard> { return previousContainsFocus != currentContainsFocus; }, - builder: (context, focusedItems, child) => Container( - margin: widget.boardConfig.cardMargin, - decoration: _makeBoxDecoration( - context, - groupData.group.groupId, - widget.groupItem.id, - ), - child: child, - ), + builder: (context, focusedItems, child) { + final cardMargin = widget.boardConfig.cardMargin; + final margin = widget.compactMode + ? cardMargin - EdgeInsets.symmetric(horizontal: 2) + : cardMargin; + return Container( + margin: margin, + decoration: _makeBoxDecoration( + context, + groupData.group.groupId, + widget.groupItem.id, + ), + child: child, + ); + }, child: RowCard( fieldController: databaseController.fieldController, rowMeta: rowMeta, @@ -656,10 +707,8 @@ class _BoardCardState extends State<_BoardCard> { groupingFieldId: widget.groupItem.fieldInfo.id, isEditing: _isEditing, cellBuilder: widget.cellBuilder, - onTap: (context) => _openCard( - context: context, - databaseController: databaseController, - rowMeta: context.read().rowController.rowMeta, + onTap: (context) => widget.onOpenCard( + context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); @@ -714,19 +763,19 @@ class _BoardCardState extends State<_BoardCard> { .isFocused(GroupedRowId(rowId: rowId, groupId: groupId)) ? Theme.of(context).colorScheme.primary : Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) + ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xFF59647A), ), ), boxShadow: [ BoxShadow( blurRadius: 4, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); @@ -805,7 +854,7 @@ class _BoardTrailingState extends State { suffixIcon: Padding( padding: const EdgeInsets.only(left: 4, bottom: 8.0), child: FlowyIconButton( - icon: const FlowySvg(FlowySvgs.close_filled_m), + icon: const FlowySvg(FlowySvgs.close_filled_s), hoverColor: Colors.transparent, onPressed: () => _textController.clear(), ), @@ -856,10 +905,13 @@ void _openCard({ FlowyOverlay.show( context: context, - builder: (_) => RowDetailPage( - databaseController: databaseController, - rowController: rowController, - userProfile: context.read().userProfile, + builder: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart index 9e3203e093..e57364b2d8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart @@ -2,10 +2,13 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; class BoardSettingBar extends StatelessWidget { const BoardSettingBar({ @@ -30,6 +33,8 @@ class BoardSettingBar extends StatelessWidget { if (value) { return const SizedBox.shrink(); } + final isReference = + Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( @@ -38,6 +43,10 @@ class BoardSettingBar extends StatelessWidget { FilterButton( toggleExtension: toggleExtension, ), + if (isReference) ...[ + const HSpace(2), + ViewDatabaseButton(view: databaseController.view), + ], const HSpace(2), SettingButton( databaseController: databaseController, 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 c2a90fb49e..1a0d1a3163 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,7 +1,5 @@ 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'; @@ -15,20 +13,24 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class HiddenGroupsColumn extends StatelessWidget { const HiddenGroupsColumn({ super.key, required this.margin, + required this.shrinkWrap, }); final EdgeInsets margin; + final bool shrinkWrap; @override Widget build(BuildContext context) { @@ -84,11 +86,7 @@ class HiddenGroupsColumn extends StatelessWidget { ], ), ), - Expanded( - child: HiddenGroupList( - databaseController: databaseController, - ), - ), + _hiddenGroupList(databaseController), ], ), ), @@ -97,6 +95,14 @@ class HiddenGroupsColumn extends StatelessWidget { ); } + Widget _hiddenGroupList(DatabaseController databaseController) { + final hiddenGroupList = HiddenGroupList( + shrinkWrap: shrinkWrap, + databaseController: databaseController, + ); + return shrinkWrap ? hiddenGroupList : Expanded(child: hiddenGroupList); + } + Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { return FlowyTooltip( message: isCollapsed @@ -124,9 +130,11 @@ class HiddenGroupList extends StatelessWidget { const HiddenGroupList({ super.key, required this.databaseController, + required this.shrinkWrap, }); final DatabaseController databaseController; + final bool shrinkWrap; @override Widget build(BuildContext context) { @@ -149,6 +157,7 @@ class HiddenGroupList extends StatelessWidget { ], ), ), + shrinkWrap: shrinkWrap, buildDefaultDragHandles: false, itemCount: state.hiddenGroups.length, itemBuilder: (_, index) => Padding( @@ -426,10 +435,13 @@ class HiddenGroupPopupItemList extends StatelessWidget { onPressed: () { FlowyOverlay.show( context: context, - builder: (_) => RowDetailPage( - databaseController: databaseController, - rowController: rowController, - userProfile: context.read().userProfile, + builder: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), ), ); PopoverContainer.of(context).close(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart index de5648291e..1d2838210d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart @@ -256,16 +256,16 @@ class NewEventButton extends StatelessWidget { boxShadow: [ BoxShadow( spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart index d4abb79a32..5ef2e2c327 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart @@ -1,24 +1,23 @@ -import 'package:appflowy/plugins/database/application/row/row_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../application/calendar_bloc.dart'; - import 'calendar_event_editor.dart'; class EventCard extends StatefulWidget { @@ -128,16 +127,16 @@ class _EventCardState extends State { boxShadow: [ BoxShadow( spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], @@ -178,10 +177,13 @@ class _EventCardState extends State { FlowyOverlay.show( context: context, - builder: (_) => RowDetailPage( - databaseController: widget.databaseController, - rowController: rowController, - userProfile: context.read().userProfile, + builder: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: widget.databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), ), ); }, 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 40f57b5e9a..dcbe626dd8 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 @@ -12,6 +12,7 @@ import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dar import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -92,36 +93,55 @@ class EventEditorControls extends StatelessWidget { message: LocaleKeys.calendar_duplicateEvent.tr(), child: FlowyIconButton( width: 20, - icon: const FlowySvg( + icon: FlowySvg( FlowySvgs.m_duplicate_s, - size: Size.square(17), + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, ), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - onPressed: () => context.read().add( - CalendarEvent.duplicateEvent( - rowController.viewId, - rowController.rowId, - ), - ), + onPressed: () { + context.read().add( + CalendarEvent.duplicateEvent( + rowController.viewId, + rowController.rowId, + ), + ); + PopoverContainer.of(context).close(); + }, ), ), const HSpace(8.0), FlowyIconButton( width: 20, - icon: const FlowySvg(FlowySvgs.delete_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - onPressed: () => context.read().add( - CalendarEvent.deleteEvent( - rowController.viewId, - rowController.rowId, - ), - ), + icon: FlowySvg( + FlowySvgs.delete_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.grid_row_label.tr(), + description: LocaleKeys.grid_row_deleteRowPrompt.tr(), + onConfirm: () { + context.read().add( + CalendarEvent.deleteEvent( + rowController.viewId, + rowController.rowId, + ), + ); + PopoverContainer.of(context).close(); + }, + ); + }, ), const HSpace(8.0), FlowyIconButton( width: 20, - icon: const FlowySvg(FlowySvgs.full_view_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + icon: FlowySvg( + FlowySvgs.full_view_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), onPressed: () { PopoverContainer.of(context).close(); onExpand.call(); @@ -269,6 +289,7 @@ class _TitleTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart index 231c8830d9..1876332d01 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart @@ -1,8 +1,3 @@ -import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; -import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; @@ -11,22 +6,26 @@ import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/application/unschedule_event_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; -import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../application/row/row_controller.dart'; import '../../widgets/row/row_detail.dart'; - import 'calendar_day.dart'; import 'layout/sizes.dart'; import 'toolbar/calendar_setting_bar.dart'; @@ -123,8 +122,18 @@ class _CalendarPageState extends State { Widget build(BuildContext context) { return CalendarControllerProvider( controller: _eventController, - child: BlocProvider.value( - value: _calendarBloc, + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: _calendarBloc, + ), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add( + ViewLockStatusEvent.initial(), + ), + ), + ], child: MultiBlocListener( listeners: [ BlocListener( @@ -235,7 +244,21 @@ class _CalendarPageState extends State { showBorder: false, headerBuilder: _headerNavigatorBuilder, weekDayBuilder: _headerWeekDayBuilder, - cellBuilder: _calendarDayBuilder, + cellBuilder: ( + date, + calenderEvents, + isToday, + isInMonth, + position, + ) => + _calendarDayBuilder( + context, + date, + calenderEvents, + isToday, + isInMonth, + position, + ), useAvailableVerticalSpace: widget.shrinkWrap, ), ), @@ -344,6 +367,7 @@ class _CalendarPageState extends State { } Widget _calendarDayBuilder( + BuildContext context, DateTime date, List> calenderEvents, isToday, @@ -355,17 +379,22 @@ class _CalendarPageState extends State { // is implemnted in the develop branch(WIP). Will be replaced with that. final events = calenderEvents.map((value) => value.event!).toList() ..sort((a, b) => a.event.timestamp.compareTo(b.event.timestamp)); + final isLocked = + context.watch()?.state.isLocked ?? false; - return CalendarDayCard( - viewId: widget.view.id, - isToday: isToday, - isInMonth: isInMonth, - events: events, - date: date, - rowCache: _calendarBloc.rowCache, - onCreateEvent: (date) => - _calendarBloc.add(CalendarEvent.createEvent(date)), - position: position, + return IgnorePointer( + ignoring: isLocked, + child: CalendarDayCard( + viewId: widget.view.id, + isToday: isToday, + isInMonth: isInMonth, + events: events, + date: date, + rowCache: _calendarBloc.rowCache, + onCreateEvent: (date) => + _calendarBloc.add(CalendarEvent.createEvent(date)), + position: position, + ), ); } @@ -390,7 +419,7 @@ void showEventDetails({ context: context, builder: (BuildContext overlayContext) { return BlocProvider.value( - value: context.read(), + value: context.read(), child: RowDetailPage( rowController: rowController, databaseController: databaseController, @@ -457,14 +486,18 @@ class _UnscheduledEventsButtonState extends State { ), ), ), - popupBuilder: (_) => BlocProvider.value( - value: context.read(), - child: BlocProvider.value( - value: context.read(), - child: UnscheduleEventsList( - databaseController: widget.databaseController, - unscheduleEvents: state.unscheduleEvents, + popupBuilder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), ), + BlocProvider.value( + value: context.read(), + ), + ], + child: UnscheduleEventsList( + databaseController: widget.databaseController, + unscheduleEvents: state.unscheduleEvents, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart index c2307c63a5..6bfe7b99a8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart @@ -2,10 +2,13 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; class CalendarSettingBar extends StatelessWidget { const CalendarSettingBar({ @@ -30,6 +33,8 @@ class CalendarSettingBar extends StatelessWidget { if (value) { return const SizedBox.shrink(); } + final isReference = + Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( @@ -38,6 +43,10 @@ class CalendarSettingBar extends StatelessWidget { FilterButton( toggleExtension: toggleExtension, ), + if (isReference) ...[ + const HSpace(2), + ViewDatabaseButton(view: databaseController.view), + ], const HSpace(2), SettingButton( databaseController: databaseController, 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 19330221c6..4c9fd7bd61 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,19 +1,18 @@ import 'dart:async'; -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:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; -import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -21,6 +20,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import 'package:provider/provider.dart'; @@ -30,7 +30,6 @@ import '../../application/row/row_controller.dart'; import '../../tab_bar/tab_bar_view.dart'; import '../../widgets/row/row_detail.dart'; import '../application/grid_bloc.dart'; - import 'grid_scroll.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; @@ -140,8 +139,16 @@ class _GridPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => gridBloc, + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => gridBloc, + ), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + ], child: BlocListener( listener: (context, state) { final action = state.action; @@ -196,7 +203,7 @@ class _GridPageState extends State { FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( - value: context.read(), + value: context.read(), child: RowDetailPage( databaseController: context.read().databaseController, rowController: rowController, @@ -233,7 +240,7 @@ class _GridPageState extends State { FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( - value: context.read(), + value: context.read(), child: RowDetailPage( databaseController: context.read().databaseController, @@ -288,7 +295,11 @@ class _GridPageContentState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - _GridHeader(headerScrollController: headerScrollController), + _GridHeader( + headerScrollController: headerScrollController, + editable: !context.read().state.isLocked, + shrinkWrap: widget.shrinkWrap, + ), _GridRows( viewId: widget.view.id, scrollController: _scrollController, @@ -300,18 +311,33 @@ class _GridPageContentState extends State { } class _GridHeader extends StatelessWidget { - const _GridHeader({required this.headerScrollController}); + const _GridHeader({ + required this.headerScrollController, + required this.editable, + required this.shrinkWrap, + }); final ScrollController headerScrollController; + final bool editable; + final bool shrinkWrap; @override Widget build(BuildContext context) { - return BlocBuilder( + Widget child = BlocBuilder( builder: (_, state) => GridHeaderSliverAdaptor( viewId: state.viewId, anchorScrollController: headerScrollController, + shrinkWrap: shrinkWrap, ), ); + + if (!editable) { + child = IgnorePointer( + child: child, + ); + } + + return child; } } @@ -394,12 +420,13 @@ class _GridRowsState extends State<_GridRows> { constraints: BoxConstraints( maxWidth: GridLayout.headerWidth( context - .read() - .horizontalPadding, + .read() + .horizontalPadding * + 3, context.read().state.fields, ), ), - child: _renderList(context), + child: _shrinkWrapRenderList(context), ), ), ); @@ -430,9 +457,31 @@ class _GridRowsState extends State<_GridRows> { return Flexible(child: child); } + Widget _shrinkWrapRenderList(BuildContext context) { + final state = context.read().state; + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; + return ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + children: [ + widget.shrinkWrap + ? _reorderableListView(state) + : Expanded(child: _reorderableListView(state)), + if (showFloatingCalculations && !widget.shrinkWrap) ...[ + _PositionedCalculationsRow( + viewId: widget.viewId, + isAtBottom: isAtBottom, + ), + ], + ], + ); + } + Widget _renderList(BuildContext context) { final state = context.read().state; - return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -474,7 +523,7 @@ class _GridRowsState extends State<_GridRows> { proxyDecorator: (child, _, __) => Provider.value( value: context.read(), child: Material( - color: Colors.white.withOpacity(.1), + color: Colors.white.withValues(alpha: .1), child: Opacity(opacity: .5, child: child), ), ), @@ -504,12 +553,21 @@ class _GridRowsState extends State<_GridRows> { itemCount: itemCount, itemBuilder: (context, index) { if (index == itemCount - 1) { - return Column( + final child = Column( key: const Key('grid_footer'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: footer, ); + + if (context.read().state.isLocked) { + return IgnorePointer( + key: const Key('grid_footer'), + child: child, + ); + } + + return child; } return _renderRow( @@ -544,6 +602,7 @@ class _GridRowsState extends State<_GridRows> { rowId: rowId, viewId: viewId, index: index, + editable: !context.watch().state.isLocked, rowController: RowController( viewId: viewId, rowMeta: rowMeta, @@ -559,7 +618,7 @@ class _GridRowsState extends State<_GridRows> { } return BlocProvider.value( - value: context.read(), + value: context.read(), child: RowDetailPage( rowController: RowController( viewId: viewId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart index 903ecbb864..c7402a17f9 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; + import 'sizes.dart'; class GridLayout { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart index 18883e8a0c..78a8c97dae 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart @@ -5,16 +5,26 @@ class GridSize { static double scale = 1; static double get scrollBarSize => 8 * scale; + static double get headerHeight => 36 * scale; + static double get buttonHeight => 38 * scale; + static double get footerHeight => 36 * scale; + static double get horizontalHeaderPadding => UniversalPlatform.isDesktop ? 40 * scale : 16 * scale; + static double get cellHPadding => 10 * scale; - static double get cellVPadding => 10 * scale; + + static double get cellVPadding => 8 * scale; + static double get popoverItemHeight => 26 * scale; + static double get typeOptionSeparatorHeight => 4 * scale; + static double get newPropertyButtonWidth => 140 * scale; + static double get mobileNewPropertyButtonWidth => 200 * scale; static EdgeInsets get cellContentInsets => EdgeInsets.symmetric( @@ -22,10 +32,8 @@ class GridSize { vertical: GridSize.cellVPadding, ); - static EdgeInsets get fieldContentInsets => EdgeInsets.symmetric( - horizontal: GridSize.cellHPadding, - vertical: GridSize.cellVPadding, - ); + static EdgeInsets get compactCellContentInsets => + cellContentInsets - EdgeInsets.symmetric(vertical: 2); static EdgeInsets get typeOptionContentInsets => const EdgeInsets.all(4); 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 3874dc801e..17e4c0ed1d 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 @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; @@ -182,6 +183,8 @@ class _GridPageContentState extends State { @override Widget build(BuildContext context) { + final isLocked = + context.read()?.state.isLocked ?? false; return BlocListener( listenWhen: (previous, current) => previous.createdRow != current.createdRow, @@ -215,7 +218,7 @@ class _GridPageContentState extends State { ), ], ), - if (!widget.shrinkWrap) + if (!widget.shrinkWrap && !isLocked) Positioned( bottom: 16, right: 16, @@ -356,7 +359,7 @@ class _GridRows extends StatelessWidget { final databaseController = context.read().databaseController; - final child = MobileGridRow( + Widget child = MobileGridRow( key: ValueKey(rowMeta.id), rowId: rowId, isDraggable: isDraggable, @@ -373,12 +376,20 @@ class _GridRows extends StatelessWidget { ); if (animation != null) { - return SizeTransition( + child = SizeTransition( sizeFactor: animation, child: child, ); } + final isLocked = + context.read()?.state.isLocked ?? false; + if (isLocked) { + child = IgnorePointer( + child: child, + ); + } + return child; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart index 1b973554cd..43a0301a10 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; @@ -9,6 +7,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class GridAddRowButton extends StatelessWidget { @@ -17,8 +16,8 @@ class GridAddRowButton extends StatelessWidget { @override Widget build(BuildContext context) { final color = Theme.of(context).brightness == Brightness.light - ? const Color(0xFF171717).withOpacity(0.4) - : const Color(0xFFFFFFFF).withOpacity(0.4); + ? const Color(0xFF171717).withValues(alpha: 0.4) + : const Color(0xFFFFFFFF).withValues(alpha: 0.4); return FlowyButton( radius: BorderRadius.zero, decoration: BoxDecoration( @@ -66,8 +65,8 @@ class GridRowLoadMoreButton extends StatelessWidget { final padding = context.read().horizontalPadding; final color = Theme.of(context).brightness == Brightness.light - ? const Color(0xFF171717).withOpacity(0.4) - : const Color(0xFFFFFFFF).withOpacity(0.4); + ? const Color(0xFF171717).withValues(alpha: 0.4) + : const Color(0xFFFFFFFF).withValues(alpha: 0.4); return Container( padding: GridSize.footerContentInsets.copyWith(left: 0) + 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 f0597c15e4..915bf70a61 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 @@ -1,17 +1,16 @@ -import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; -import 'package:appflowy/util/theme_extension.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; @@ -109,7 +108,7 @@ class _GridFieldCellState extends State { top: 0, bottom: 0, right: 0, - child: _DragToExpandLine(), + child: DragToExpandLine(), ); return _GridHeaderCellContainer( @@ -159,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget { } } -class _DragToExpandLine extends StatelessWidget { - const _DragToExpandLine(); +@visibleForTesting +class DragToExpandLine extends StatelessWidget { + const DragToExpandLine({ + super.key, + }); @override Widget build(BuildContext context) { @@ -261,7 +263,7 @@ class FieldIcon extends StatelessWidget { return svgContent == null ? FlowySvg( fieldInfo.fieldType.svgData, - color: color.withOpacity(0.6), + color: color.withValues(alpha: 0.6), size: Size.square(dimension), ) : SizedBox.square( @@ -269,7 +271,7 @@ class FieldIcon extends StatelessWidget { child: Center( child: FlowySvg.string( svgContent, - color: color.withOpacity(0.45), + color: color.withValues(alpha: 0.45), size: Size.square(dimension - 2), ), ), 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 bcb9139406..e30c238f96 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 @@ -21,11 +21,13 @@ class GridHeaderSliverAdaptor extends StatefulWidget { const GridHeaderSliverAdaptor({ super.key, required this.viewId, + required this.shrinkWrap, required this.anchorScrollController, }); final String viewId; final ScrollController anchorScrollController; + final bool shrinkWrap; @override State createState() => @@ -37,6 +39,9 @@ class _GridHeaderSliverAdaptorState extends State { Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; return BlocProvider( create: (context) { return GridHeaderBloc( @@ -47,9 +52,14 @@ class _GridHeaderSliverAdaptorState extends State { child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.anchorScrollController, - child: _GridHeader( - viewId: widget.viewId, - fieldController: fieldController, + child: Padding( + padding: widget.shrinkWrap + ? EdgeInsets.symmetric(horizontal: horizontalPadding) + : EdgeInsets.zero, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart index e20beba726..369bdeb523 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -39,6 +40,8 @@ class _MobileGridHeaderState extends State { Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; + final isLocked = + context.read()?.state.isLocked ?? false; return BlocProvider( create: (context) { return GridHeaderBloc( @@ -76,12 +79,15 @@ class _MobileGridHeaderState extends State { ); }, ), - SizedBox( - height: _kGridHeaderHeight, - child: _GridHeader( - viewId: widget.viewId, - fieldController: fieldController, - scrollController: widget.reorderableController, + IgnorePointer( + ignoring: isLocked, + child: SizedBox( + height: _kGridHeaderHeight, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + scrollController: widget.reorderableController, + ), ), ), ], 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 4de6f20bc2..2306767f46 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,27 +1,25 @@ -import 'package:appflowy/plugins/database/domain/sort_service.dart'; -import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.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'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../../../widgets/row/accessory/cell_accessory.dart'; import '../../../../widgets/row/cells/cell_container.dart'; import '../../layout/sizes.dart'; - import 'action.dart'; class GridRow extends StatelessWidget { @@ -35,6 +33,7 @@ class GridRow extends StatelessWidget { required this.openDetailPage, required this.index, this.shrinkWrap = false, + required this.editable, }); final FieldController fieldController; @@ -45,6 +44,7 @@ class GridRow extends StatelessWidget { final void Function(BuildContext context) openDetailPage; final int index; final bool shrinkWrap; + final bool editable; @override Widget build(BuildContext context) { @@ -58,7 +58,7 @@ class GridRow extends StatelessWidget { rowContent = Expanded(child: rowContent); } - return BlocProvider( + rowContent = BlocProvider( create: (_) => RowBloc( fieldController: fieldController, rowId: rowId, @@ -74,6 +74,14 @@ class GridRow extends StatelessWidget { ), ), ); + + if (!editable) { + rowContent = IgnorePointer( + child: rowContent, + ); + } + + return rowContent; } } @@ -300,14 +308,20 @@ class RowContent extends StatelessWidget { Widget _finalCellDecoration(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.basic, - child: Container( - width: GridSize.newPropertyButtonWidth, - constraints: const BoxConstraints(minHeight: 36), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), - ), - ), + child: ValueListenableBuilder( + valueListenable: cellBuilder.databaseController.compactModeNotifier, + builder: (context, compactMode, _) { + return Container( + width: GridSize.newPropertyButtonWidth, + constraints: BoxConstraints(minHeight: compactMode ? 32 : 36), + decoration: BoxDecoration( + border: Border( + bottom: + BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart index ece2658cf2..5c33426281 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart @@ -1,14 +1,13 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../layout/sizes.dart'; import '../filter/create_filter_list.dart'; class FilterButton extends StatefulWidget { @@ -30,27 +29,25 @@ class _FilterButtonState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final textColor = state.filters.isEmpty - ? Theme.of(context).hintColor - : Theme.of(context).colorScheme.primary; - return _wrapPopover( - FlowyTextButton( - LocaleKeys.grid_settings_filter.tr(), - fontColor: textColor, - fontSize: FontSizes.s12, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: () { - final bloc = context.read(); - if (bloc.state.filters.isEmpty) { - _popoverController.show(); - } else { - widget.toggleExtension.toggle(); - } - }, + MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_settings_filter.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.database_filter_s), + onPressed: () { + final bloc = context.read(); + if (bloc.state.filters.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart index 047781c6cc..f325ab206f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart @@ -3,12 +3,15 @@ import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_ import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; import 'filter_button.dart'; import 'sort_button.dart'; +import 'view_database_button.dart'; class GridSettingBar extends StatelessWidget { const GridSettingBar({ @@ -43,18 +46,22 @@ class GridSettingBar extends StatelessWidget { if (isLoading) { return const SizedBox.shrink(); } + final isReference = + Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FilterButton(toggleExtension: toggleExtension), - const HSpace(6), + const HSpace(2), SortButton(toggleExtension: toggleExtension), - const HSpace(6), - SettingButton( - databaseController: controller, - ), + if (isReference) ...[ + const HSpace(2), + ViewDatabaseButton(view: controller.view), + ], + const HSpace(2), + SettingButton(databaseController: controller), ], ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart index e16c851f3a..6649d53594 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart @@ -1,13 +1,12 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import '../sort/create_sort_list.dart'; @@ -27,26 +26,24 @@ class _SortButtonState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final textColor = state.sorts.isEmpty - ? Theme.of(context).hintColor - : Theme.of(context).colorScheme.primary; - return wrapPopover( - FlowyTextButton( - LocaleKeys.grid_settings_sort.tr(), - fontColor: textColor, - fontSize: FontSizes.s12, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: () { - if (state.sorts.isEmpty) { - _popoverController.show(); - } else { - widget.toggleExtension.toggle(); - } - }, + MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_settings_sort.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.database_sort_s), + onPressed: () { + if (state.sorts.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart new file mode 100644 index 0000000000..93493e599f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +class ViewDatabaseButton extends StatelessWidget { + const ViewDatabaseButton({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_rowPage_openAsFullPage.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + icon: const FlowySvg(FlowySvgs.database_fullscreen_s), + onPressed: () { + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(), + view: view, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart index c7cc314046..fa5e44a5e6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart @@ -1,8 +1,8 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; -import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -16,6 +16,7 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; import 'tab_bar_add_button.dart'; @@ -26,12 +27,8 @@ class TabBarHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return SizedBox( height: 35, - padding: EdgeInsets.symmetric( - horizontal: - context.read().horizontalPadding, - ), child: Stack( children: [ Positioned( @@ -263,7 +260,7 @@ class _TabBarItemButtonState extends State { enableBackgroundColorSelection: false, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( - viewId: widget.view.id, + view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) { @@ -332,7 +329,24 @@ class _TabBarItemButtonState extends State { enableColor: false, ); } - return Opacity(opacity: 0.6, child: icon); + final isReference = + Provider.of(context)?.isReference ?? false; + final iconWidget = Opacity(opacity: 0.6, child: icon); + return isReference + ? Stack( + children: [ + iconWidget, + const Positioned( + right: 0, + bottom: 0, + child: FlowySvg( + FlowySvgs.referenced_page_s, + blendMode: BlendMode.dstIn, + ), + ), + ], + ) + : iconWidget; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index 09e6d7f2d5..7c2dc40869 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -1,9 +1,17 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; @@ -12,7 +20,9 @@ import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; @@ -55,38 +65,101 @@ abstract class DatabaseTabBarItemBuilder { void dispose() {} } -class DatabaseTabBarView extends StatelessWidget { +class DatabaseTabBarView extends StatefulWidget { const DatabaseTabBarView({ super.key, required this.view, required this.shrinkWrap, + required this.showActions, this.initialRowId, + this.actionBuilder, + this.node, }); final ViewPB view; final bool shrinkWrap; + final BlockComponentActionBuilder? actionBuilder; + final bool showActions; + final Node? node; /// Used to open a Row on plugin load /// final String? initialRowId; + @override + State createState() => _DatabaseTabBarViewState(); +} + +class _DatabaseTabBarViewState extends State { + bool enableCompactMode = false; + bool initialed = false; + StreamSubscription? compactModeSubscription; + + String get compactModeId => widget.node?.id ?? widget.view.id; + + @override + void initState() { + super.initState(); + if (widget.node != null) { + enableCompactMode = + widget.node!.attributes[DatabaseBlockKeys.enableCompactMode] ?? false; + setState(() { + initialed = true; + }); + } else { + fetchLocalCompactMode(compactModeId).then((v) { + if (mounted) { + setState(() { + enableCompactMode = v; + initialed = true; + }); + } + }); + compactModeSubscription = + compactModeEventBus.on().listen((event) { + if (event.id != widget.view.id) return; + updateLocalCompactMode(event.enable); + }); + } + } + + @override + void dispose() { + super.dispose(); + compactModeSubscription?.cancel(); + } + @override Widget build(BuildContext context) { + if (!initialed) return Center(child: CircularProgressIndicator()); return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => DatabaseTabBarBloc(view: view) - ..add(const DatabaseTabBarEvent.initial()), + create: (_) => DatabaseTabBarBloc( + view: widget.view, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, + )..add(const DatabaseTabBarEvent.initial()), ), BlocProvider( - create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + create: (_) => ViewBloc(view: widget.view) + ..add( + const ViewEvent.initial(), + ), ), ], child: BlocBuilder( - builder: (_, state) { + builder: (innerContext, state) { final layout = state.tabBars[state.selectedIndex].layout; - return Column( + final isCalendar = layout == ViewLayoutPB.Calendar; + final horizontalPadding = + context.read().horizontalPadding; + final showActionWrapper = widget.showActions && + widget.actionBuilder != null && + widget.node != null; + final Widget child = Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ if (UniversalPlatform.isMobile) const VSpace(12), ValueListenableBuilder( @@ -99,25 +172,97 @@ class DatabaseTabBarView extends StatelessWidget { return const SizedBox.shrink(); } - return UniversalPlatform.isDesktop + Widget child = UniversalPlatform.isDesktop ? const TabBarHeader() : const MobileTabBarHeader(); + + if (innerContext.watch().state.view.isLocked) { + child = IgnorePointer( + child: child, + ); + } + + if (showActionWrapper) { + child = BlockComponentActionWrapper( + node: widget.node!, + actionBuilder: widget.actionBuilder!, + child: Padding( + padding: EdgeInsets.only(right: horizontalPadding), + child: child, + ), + ); + } + + if (UniversalPlatform.isDesktop) { + child = Container( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: child, + ); + } + + return child; }, ), pageSettingBarExtensionFromState(context, state), wrapContent( layout: layout, - child: pageContentFromState(context, state), + child: Padding( + padding: + (isCalendar && widget.shrinkWrap || showActionWrapper) + ? EdgeInsets.only(left: 42 - horizontalPadding) + : EdgeInsets.zero, + child: pageContentFromState(context, state), + ), ), ], ); + + return child; }, ), ); } + Future fetchLocalCompactMode(String compactModeId) async { + Set compactModeIds = {}; + try { + final localIds = await getIt().get( + KVKeys.compactModeIds, + ); + final List decodedList = jsonDecode(localIds ?? ''); + compactModeIds = Set.from(decodedList.map((item) => item as String)); + } catch (e) { + Log.warn('fetch local compact mode from id :$compactModeId failed', e); + } + return compactModeIds.contains(compactModeId); + } + + Future updateLocalCompactMode(bool enableCompactMode) async { + Set compactModeIds = {}; + try { + final localIds = await getIt().get( + KVKeys.compactModeIds, + ); + final List decodedList = jsonDecode(localIds ?? ''); + compactModeIds = Set.from(decodedList.map((item) => item as String)); + } catch (e) { + Log.warn('get compact mode ids failed', e); + } + if (enableCompactMode) { + compactModeIds.add(compactModeId); + } else { + compactModeIds.remove(compactModeId); + } + await getIt().set( + KVKeys.compactModeIds, + jsonEncode(compactModeIds.toList()), + ); + } + Widget wrapContent({required ViewLayoutPB layout, required Widget child}) { - if (shrinkWrap) { + if (widget.shrinkWrap) { if (layout.shrinkWrappable) { return child; } @@ -139,8 +284,8 @@ class DatabaseTabBarView extends StatelessWidget { context, tab.view, controller, - shrinkWrap, - initialRowId, + widget.shrinkWrap, + widget.initialRowId, ); } @@ -212,11 +357,18 @@ class DatabaseTabBarViewPlugin extends Plugin { } const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding'; +const kDatabasePluginWidgetBuilderShowActions = 'show_actions'; +const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder'; +const kDatabasePluginWidgetBuilderNode = 'node'; class DatabasePluginWidgetBuilderSize { - const DatabasePluginWidgetBuilderSize({required this.horizontalPadding}); + const DatabasePluginWidgetBuilderSize({ + required this.horizontalPadding, + this.verticalPadding = 16.0, + }); final double horizontalPadding; + final double verticalPadding; } class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { @@ -260,6 +412,11 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { final horizontalPadding = data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ?? GridSize.horizontalHeaderPadding + 40; + final BlockComponentActionBuilder? actionBuilder = + data?[kDatabasePluginWidgetBuilderActionBuilder]; + final bool showActions = + data?[kDatabasePluginWidgetBuilderShowActions] ?? false; + final Node? node = data?[kDatabasePluginWidgetBuilderNode]; return Provider( create: (context) => DatabasePluginWidgetBuilderSize( @@ -270,6 +427,9 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { view: notifier.view, shrinkWrap: shrinkWrap, initialRowId: initialRowId, + actionBuilder: actionBuilder, + showActions: showActions, + node: node, ), ); } 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 e7c6150448..68c4b15d5c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; @@ -16,12 +14,12 @@ import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../cell/card_cell_builder.dart'; import '../cell/card_cell_skeleton/card_cell.dart'; - import 'card_bloc.dart'; import 'container/accessory.dart'; import 'container/card_container.dart'; @@ -462,7 +460,7 @@ class RowCardStyleConfiguration { const RowCardStyleConfiguration({ required this.cellStyleMap, this.showAccessory = true, - this.cardPadding = const EdgeInsets.all(8), + this.cardPadding = const EdgeInsets.all(4), this.hoverStyle, }); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart index 7078685845..e74f947b46 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; enum AccessoryType { edit, @@ -45,7 +44,7 @@ class CardAccessoryContainer extends StatelessWidget { width: 1, thickness: 1, color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) + ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ); @@ -77,19 +76,19 @@ class CardAccessoryContainer extends StatelessWidget { border: Border.fromBorderSide( BorderSide( color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) + ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ), boxShadow: [ BoxShadow( blurRadius: 4, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart index ba71fcd30b..a91ffae42d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart @@ -40,7 +40,7 @@ class RowCardContainer extends StatelessWidget { } }, child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 42), + constraints: const BoxConstraints(minHeight: 36), child: _CardEnterRegion( shouldBuildAccessory: shouldBuildAccessory, accessories: accessories, @@ -77,8 +77,8 @@ class _CardEnterRegion extends StatelessWidget { child, if (onEnter && shouldBuildAccessory) Positioned( - top: 10.0, - right: 10.0, + top: 7.0, + right: 7.0, child: CardAccessoryContainer( accessories: accessories, onTapAccessory: onTapAccessory, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart index befe49dd6d..74abcecb3a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -12,22 +12,31 @@ class DesktopGridCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { - return Container( - alignment: AlignmentDirectional.centerStart, - padding: GridSize.cellContentInsets, - child: FlowyIconButton( - hoverColor: Colors.transparent, - onPressed: () => bloc.add(const CheckboxCellEvent.select()), - icon: FlowySvg( - state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, - blendMode: BlendMode.dst, - size: const Size.square(20), - ), - width: 20, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Container( + alignment: AlignmentDirectional.centerStart, + padding: padding, + child: FlowyIconButton( + hoverColor: Colors.transparent, + onPressed: () => bloc.add(const CheckboxCellEvent.select()), + icon: FlowySvg( + state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(20), + ), + width: 20, + ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart index f5ad4f3970..ebc4a6f976 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/checklist.dart'; @@ -15,6 +14,7 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { @@ -39,15 +39,24 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { onClose: () => cellContainerNotifier.isFocus = false, child: BlocBuilder( builder: (context, state) { - return Container( - alignment: AlignmentDirectional.centerStart, - padding: GridSize.cellContentInsets, - child: state.tasks.isEmpty - ? const SizedBox.shrink() - : ChecklistProgressBar( - tasks: state.tasks, - percent: state.percent, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: padding, + child: state.tasks.isEmpty + ? const SizedBox.shrink() + : ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ), + ); + }, ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart index 21bbee23ff..de7f7f5a2e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; @@ -15,6 +15,7 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, @@ -28,11 +29,11 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { child: Align( alignment: AlignmentDirectional.centerStart, child: state.fieldInfo.wrapCellContent ?? false - ? _buildCellContent(state) + ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: _buildCellContent(state), + child: _buildCellContent(state, compactModeNotifier), ), ), popupBuilder: (BuildContext popoverContent) { @@ -47,33 +48,44 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { ); } - Widget _buildCellContent(DateCellState state) { + Widget _buildCellContent( + DateCellState state, + ValueNotifier compactModeNotifier, + ) { final wrap = state.fieldInfo.wrapCellContent ?? false; final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); - return Padding( - padding: GridSize.cellContentInsets, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText( - dateStr, - overflow: wrap ? null : TextOverflow.ellipsis, - maxLines: wrap ? null : 1, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText( + dateStr, + overflow: wrap ? null : TextOverflow.ellipsis, + maxLines: wrap ? null : 1, + ), + ), + if (state.cellData.reminderId.isNotEmpty) ...[ + const HSpace(4), + FlowyTooltip( + message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), + child: const FlowySvg(FlowySvgs.clock_alarm_s), + ), + ], + ], ), - if (state.cellData.reminderId.isNotEmpty) ...[ - const HSpace(4), - FlowyTooltip( - message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), - child: const FlowySvg(FlowySvgs.clock_alarm_s), - ), - ], - ], - ), + ); + }, ); } } 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 index f66d106ed0..b070af7cc7 100644 --- 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 @@ -72,7 +72,7 @@ class GridMediaCellSkin extends IEditableMediaCellSkin { if (!isMobile && wrapContent) { return Padding( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(horizontal: 4), child: SizedBox( width: double.infinity, child: Wrap( @@ -233,7 +233,7 @@ class _FilePreviewRender extends StatelessWidget { height: 28, width: 28, clipBehavior: Clip.antiAlias, - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: AFThemeExtension.of(context).greyHover, borderRadius: BorderRadius.circular(4), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart index 04368bc725..7a6f3e63bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart @@ -1,6 +1,6 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,27 +11,37 @@ class DesktopGridNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { - return TextField( - controller: textEditingController, - focusNode: focusNode, - onEditingComplete: () => focusNode.unfocus(), - onSubmitted: (_) => focusNode.unfocus(), - maxLines: context.watch().state.wrap ? null : 1, - style: Theme.of(context).textTheme.bodyMedium, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - contentPadding: GridSize.cellContentInsets, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - isDense: true, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: padding, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart index f4b8fb97f0..dda3183b59 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart @@ -1,8 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -16,10 +17,12 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { + final userWorkspaceBloc = context.read(); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, @@ -27,16 +30,24 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { margin: EdgeInsets.zero, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: userWorkspaceBloc), + BlocProvider.value(value: bloc), + ], child: const RelationCellEditor(), ); }, child: Align( alignment: AlignmentDirectional.centerStart, - child: state.wrap - ? _buildWrapRows(context, state.rows) - : _buildNoWrapRows(context, state.rows), + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + return state.wrap + ? _buildWrapRows(context, state.rows, compactMode) + : _buildNoWrapRows(context, state.rows, compactMode); + }, + ), ), ); } @@ -44,9 +55,12 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget _buildWrapRows( BuildContext context, List rows, + bool compactMode, ) { return Padding( - padding: GridSize.cellContentInsets, + padding: compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets, child: Wrap( runSpacing: 4, spacing: 4.0, @@ -68,6 +82,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget _buildNoWrapRows( BuildContext context, List rows, + bool compactMode, ) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart index 8cebb2d77a..b599acc4f1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart @@ -15,6 +15,7 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { @@ -35,63 +36,92 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { return Align( alignment: AlignmentDirectional.centerStart, child: state.wrap - ? _buildWrapOptions(context, state.selectedOptions) - : _buildNoWrapOptions(context, state.selectedOptions), + ? _buildWrapOptions( + context, + state.selectedOptions, + compactModeNotifier, + ) + : _buildNoWrapOptions( + context, + state.selectedOptions, + compactModeNotifier, + ), ); }, ), ); } - Widget _buildWrapOptions(BuildContext context, List options) { - return Padding( - padding: GridSize.cellContentInsets, - child: Wrap( - runSpacing: 4, - children: options.map( - (option) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: SelectOptionTag( - option: option, - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 8, - ), - ), - ); - }, - ).toList(), - ), + Widget _buildWrapOptions( + BuildContext context, + List options, + ValueNotifier compactModeNotifier, + ) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: Wrap( + runSpacing: 4, + children: options.map( + (option) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SelectOptionTag( + option: option, + padding: EdgeInsets.symmetric( + vertical: compactMode ? 2 : 4, + horizontal: 8, + ), + ), + ); + }, + ).toList(), + ), + ); + }, ); } Widget _buildNoWrapOptions( BuildContext context, List options, + ValueNotifier compactModeNotifier, ) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: Padding( - padding: GridSize.cellContentInsets, - child: Row( - mainAxisSize: MainAxisSize.min, - children: options.map( - (option) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: SelectOptionTag( - option: option, - padding: const EdgeInsets.symmetric( - vertical: 1, - horizontal: 8, - ), - ), - ); - }, - ).toList(), - ), + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: options.map( + (option) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SelectOptionTag( + option: option, + padding: const EdgeInsets.symmetric( + vertical: 1, + horizontal: 8, + ), + ), + ); + }, + ).toList(), + ), + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart index b5d915b022..1f3ded0109 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart @@ -11,6 +11,7 @@ class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -27,58 +28,69 @@ class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { onExit: (p) => Provider.of(context, listen: false) .onEnter = false, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: GridSize.headerHeight, - ), - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: TextField( - controller: textEditingController, - enabled: false, - focusNode: focusNode, - onEditingComplete: () => focusNode.unfocus(), - onSubmitted: (_) => focusNode.unfocus(), - maxLines: null, - style: Theme.of(context).textTheme.bodyMedium, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - contentPadding: GridSize.cellContentInsets, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - isDense: true, - ), - ), + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: compactMode + ? GridSize.headerHeight - 4 + : GridSize.headerHeight, ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.cellVPadding, - ), - child: Consumer( - builder: ( - BuildContext context, - SummaryMouseNotifier notifier, - Widget? child, - ) { - if (notifier.onEnter) { - return SummaryCellAccessory( - viewId: bloc.cellController.viewId, - fieldId: bloc.cellController.fieldId, - rowId: bloc.cellController.rowId, - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - ).positioned(right: 0, bottom: 8), - ], - ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: TextField( + controller: textEditingController, + enabled: false, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: padding, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + SummaryMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return SummaryCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: compactMode ? 4 : 8), + ], + ), + ); + }, ), ); }, 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 f28cb756c8..75c973d886 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 @@ -13,43 +13,52 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { - return Padding( - padding: GridSize.cellContentInsets, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _IconOrEmoji(), - Expanded( - child: TextField( - controller: textEditingController, - focusNode: focusNode, - maxLines: context.watch().state.wrap ? null : 1, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: context - .read() - .cellController - .fieldInfo - .isPrimary - ? FontWeight.w500 - : null, + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, data) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _IconOrEmoji(), + Expanded( + child: TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: context + .read() + .cellController + .fieldInfo + .isPrimary + ? FontWeight.w500 + : null, + ), + decoration: const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + isCollapsed: true, ), - decoration: const InputDecoration( - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - isDense: true, - isCollapsed: true, + ), ), - ), + ], ), - ], - ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart index a1690310d4..8a1fd92499 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart @@ -1,6 +1,6 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; @@ -11,29 +11,41 @@ class DesktopGridTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, child: state.wrap - ? _buildCellContent(state) + ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: _buildCellContent(state), + child: _buildCellContent(state, compactModeNotifier), ), ); } - Widget _buildCellContent(TimestampCellState state) { - return Padding( - padding: GridSize.cellContentInsets, - child: FlowyText( - state.dateStr, - overflow: state.wrap ? null : TextOverflow.ellipsis, - maxLines: state.wrap ? null : 1, - ), + Widget _buildCellContent( + TimestampCellState state, + ValueNotifier compactModeNotifier, + ) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: FlowyText( + state.dateStr, + overflow: state.wrap ? null : TextOverflow.ellipsis, + maxLines: state.wrap ? null : 1, + ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart index aece28373c..102b491f52 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart @@ -11,6 +11,7 @@ class DesktopGridTranslateCellSkin extends IEditableTranslateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -18,68 +19,79 @@ class DesktopGridTranslateCellSkin extends IEditableTranslateCellSkin { return ChangeNotifierProvider( create: (_) => TranslateMouseNotifier(), builder: (context, child) { - return MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, - onEnter: (p) => - Provider.of(context, listen: false) - .onEnter = true, - onExit: (p) => - Provider.of(context, listen: false) - .onEnter = false, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: GridSize.headerHeight, - ), - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: TextField( - controller: textEditingController, - readOnly: true, - focusNode: focusNode, - onEditingComplete: () => focusNode.unfocus(), - onSubmitted: (_) => focusNode.unfocus(), - maxLines: null, - style: Theme.of(context).textTheme.bodyMedium, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - contentPadding: GridSize.cellContentInsets, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - isDense: true, - ), - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => + Provider.of(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of(context, listen: false) + .onEnter = false, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: compactMode + ? GridSize.headerHeight - 4 + : GridSize.headerHeight, ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.cellVPadding, - ), - child: Consumer( - builder: ( - BuildContext context, - TranslateMouseNotifier notifier, - Widget? child, - ) { - if (notifier.onEnter) { - return TranslateCellAccessory( - viewId: bloc.cellController.viewId, - fieldId: bloc.cellController.fieldId, - rowId: bloc.cellController.rowId, - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - ).positioned(right: 0, bottom: 8), - ], - ), - ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: padding, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + TranslateMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return TranslateCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: compactMode ? 4 : 8), + ], + ), + ), + ); + }, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart index 17a3519d3d..935716e686 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart @@ -21,6 +21,7 @@ class DesktopGridURLSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -28,28 +29,36 @@ class DesktopGridURLSkin extends IEditableURLCellSkin { ) { return BlocSelector( selector: (state) => state.wrap, - builder: (context, wrap) => TextField( - controller: textEditingController, - focusNode: focusNode, - maxLines: wrap ? null : 1, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, + builder: (context, wrap) => ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + decoration: InputDecoration( + contentPadding: padding, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + isDense: true, ), - decoration: InputDecoration( - contentPadding: GridSize.cellContentInsets, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - hintStyle: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Theme.of(context).hintColor), - isDense: true, - ), - onTapOutside: (_) => focusNode.unfocus(), + onTapOutside: (_) => focusNode.unfocus(), + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart index bb15cd5d9f..1e56c5160e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart index d7e1d64fc3..ab0533819a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; @@ -16,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../editable_cell_skeleton/checklist.dart'; @@ -24,6 +23,7 @@ class DesktopRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { @@ -200,19 +200,16 @@ class _ChecklistItems extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), proxyDecorator: (child, index, _) => Material( color: Colors.transparent, - child: Stack( - children: [ - BlocProvider.value( + child: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( value: context.read(), child: child, ), - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), - ), - ], + ), ), ), buildDefaultDragHandles: false, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart index f10f9a9279..f1b5f14975 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -13,6 +13,7 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, 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 index 34d8a18ad8..6e648eb187 100644 --- 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 @@ -202,7 +202,7 @@ class _FilePreviewFeedback extends StatelessWidget { decoration: BoxDecoration( boxShadow: [ BoxShadow( - color: const Color(0xFF1F2329).withOpacity(.2), + color: const Color(0xFF1F2329).withValues(alpha: .2), blurRadius: 6, offset: const Offset(0, 3), ), @@ -431,7 +431,8 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> { Positioned.fill( child: DecoratedBox( position: DecorationPosition.foreground, - decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)), + decoration: + BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: child, ), ), @@ -543,7 +544,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> { setState(() => isSelected = true); controller.show(); }, - fillColor: Colors.black.withOpacity(0.4), + fillColor: Colors.black.withValues(alpha: 0.4), width: 18, radius: BorderRadius.circular(4), icon: const FlowySvg( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart index 97f8f80569..e90fc85549 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart index b3096d49e4..d760d3ac29 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -1,7 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -15,10 +16,12 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { + final userWorkspaceBloc = context.read(); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, @@ -27,8 +30,11 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { asBarrier: true, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: userWorkspaceBloc), + BlocProvider.value(value: bloc), + ], child: const RelationCellEditor(), ); }, 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 3d41a824ce..ff84744c27 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,5 +1,3 @@ -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'; @@ -8,6 +6,7 @@ import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart' import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/select_option.dart'; @@ -18,6 +17,7 @@ class DesktopRowDetailSelectOptionCellSkin Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart index d8a8902a8c..30cd54832d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart @@ -10,6 +10,7 @@ class DesktopRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart index b1e10e4da3..9511c2f871 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart index af212c6dfd..6fc534f313 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; @@ -10,6 +10,7 @@ class DesktopRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart index 00d7372027..ee9d7e7300 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart @@ -1,8 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -14,6 +14,7 @@ class DesktopRowDetailURLSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart index 1c7bab9f92..a374417b3d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart @@ -10,6 +10,7 @@ class DesktopRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart index 4b7bd2c442..ab421b8925 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,6 +27,7 @@ abstract class IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ); @@ -71,6 +72,7 @@ class _CheckboxCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart index 4cdebee36d..fbed429642 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +28,7 @@ abstract class IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ); @@ -72,6 +73,7 @@ class GridChecklistCellState extends GridCellState { child: widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, _popover, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart index 877b4c6dfb..e61c759f48 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart @@ -1,12 +1,12 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; @@ -33,6 +33,7 @@ abstract class IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, @@ -79,6 +80,7 @@ class _DateCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, _popover, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart index b218c78195..4d2bfdf627 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,6 +29,7 @@ abstract class IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -89,6 +90,7 @@ class _NumberCellState extends GridEditableTextCell { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart index 4e39900abf..67ca6275a6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +28,7 @@ abstract class IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, @@ -74,6 +75,7 @@ class _RelationCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, _popover, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart index b45018f4f5..f7e8b6f435 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; @@ -31,6 +31,7 @@ abstract class IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ); @@ -79,6 +80,7 @@ class _SelectOptionCellState extends GridCellState { child: widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, _popover, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart index d3b43b0d17..7a086b2a35 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.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/database/application/cell/bloc/summary_cell_bloc.dart'; @@ -22,6 +19,8 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableSummaryCellSkin { @@ -39,6 +38,7 @@ abstract class IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -98,6 +98,7 @@ class _SummaryCellState extends GridEditableTextCell { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart index 7666919c49..3ea622374e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,6 +29,7 @@ abstract class IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -92,6 +93,7 @@ class _TextCellState extends GridEditableTextCell { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart index 6c00e8b4b4..2fc9d049cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +28,7 @@ abstract class IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ); @@ -74,6 +75,7 @@ class _TimestampCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart index b4ff26d946..b273419aed 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.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/database/application/cell/bloc/translate_cell_bloc.dart'; @@ -22,6 +19,8 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableTranslateCellSkin { @@ -39,6 +38,7 @@ abstract class IEditableTranslateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -98,6 +98,7 @@ class _TranslateCellState extends GridEditableTextCell { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart index 0b4afc086e..39616dbcf8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart @@ -4,13 +4,13 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -40,6 +40,7 @@ abstract class IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -121,6 +122,7 @@ class _GridURLCellState extends GridEditableTextCell { child: widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, @@ -201,7 +203,7 @@ class MobileURLEditor extends StatelessWidget { ClipboardData(text: textEditingController.text), ); Fluttertoast.showToast( - msg: LocaleKeys.grid_url_copiedNotification.tr(), + msg: LocaleKeys.message_copy_success.tr(), gravity: ToastGravity.BOTTOM, ); context.pop(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart index 8859372c2a..e9ac19c874 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; @@ -10,6 +10,7 @@ class MobileGridCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart index ff9f83319f..c56d28e1a7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart @@ -1,9 +1,9 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -15,6 +15,7 @@ class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart index 43b6b7f347..5686e09295 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart @@ -1,18 +1,18 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; class MobileGridDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart index c02cf6aa8d..310c0b5692 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/number.dart'; @@ -9,6 +9,7 @@ class MobileGridNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart index 0951e2fb0d..69e9b20104 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileGridRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart index 61f67fec4f..010974e49a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart @@ -1,8 +1,8 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -16,6 +16,7 @@ class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart index 0da8d6bc64..e48c56d74d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart @@ -12,6 +12,7 @@ class MobileGridSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 40e8c35319..43a4fe49d7 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 @@ -11,6 +11,7 @@ class MobileGridTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart index d9e020eece..68209e7e05 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -10,6 +10,7 @@ class MobileGridTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart index 3a7b44cbc5..4288136734 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart @@ -12,6 +12,7 @@ class MobileGridTranslateCellSkin extends IEditableTranslateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart index cddb821943..0dbe5474c7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart @@ -13,6 +13,7 @@ class MobileGridURLCellSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 279e790913..ade82e8c5c 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,9 +1,8 @@ -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'; @@ -12,6 +11,7 @@ class MobileRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart index daf085e2ce..75eee9a560 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart @@ -17,6 +17,7 @@ class MobileRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart index 8671bacd8f..0256ee25cf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart @@ -2,9 +2,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -14,6 +14,7 @@ class MobileRowDetailDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart index 6e32fbbbdc..430044fb5c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileRowDetailNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart index 61a39a867a..c3e8b82867 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart @@ -1,7 +1,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -10,6 +10,7 @@ class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart index 9dafb6afe0..7d4eb71f9d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -19,6 +19,7 @@ class MobileRowDetailSelectOptionCellSkin Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart index 1e709bdeb9..9974220b96 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart @@ -9,6 +9,7 @@ class MobileRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart index 1cdde84c27..fc8f816103 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileRowDetailTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart index 7ddda492c9..f3f800e994 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -12,6 +12,7 @@ class MobileRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart index a1e4b4bf29..c2d84b3d2e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart @@ -9,6 +9,7 @@ class MobileRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart index f87b225492..9bb91255aa 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart @@ -4,9 +4,9 @@ import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.da import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flowy_infra/theme_extension.dart'; import '../editable_cell_skeleton/url.dart'; @@ -15,6 +15,7 @@ class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 b788d6bd38..9853f9c1bd 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 @@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; import 'checklist_cell_textfield.dart'; @@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget { shrinkWrap: true, proxyDecorator: (child, index, _) => Material( color: Colors.transparent, - child: Stack( - children: [ - BlocProvider.value( + child: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( value: context.read(), child: child, ), - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), - ), - ], + ), ), ), buildDefaultDragHandles: false, 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 3a739cc69c..7f6960de9d 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 @@ -6,6 +6,7 @@ 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/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; @@ -112,8 +113,11 @@ class _RelationCellEditorContentState @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: bloc), + BlocProvider.value(value: context.read()), + ], child: BlocBuilder( buildWhen: (previous, current) => !listEquals(previous.filteredRows, current.filteredRows), @@ -252,7 +256,7 @@ class _CellEditorTitle extends StatelessWidget { } void _openRelatedDatbase(BuildContext context) { - FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId)) + FolderEventGetView(ViewIdPB(value: databaseMeta.viewId)) .send() .then((result) { result.fold( @@ -316,13 +320,16 @@ class _SearchField extends StatelessWidget { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { - return RelatedRowDetailPage( - databaseId: context - .read() - .state - .relatedDatabaseMeta! - .databaseId, - rowId: row.rowId, + return BlocProvider.value( + value: context.read(), + child: RelatedRowDetailPage( + databaseId: context + .read() + .state + .relatedDatabaseMeta! + .databaseId, + rowId: row.rowId, + ), ); }, ); @@ -391,13 +398,17 @@ class _RowListItem extends StatelessWidget { ), child: GestureDetector( onTap: () { + final userWorkspaceBloc = context.read(); if (isSelected) { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { - return RelatedRowDetailPage( - databaseId: databaseId, - rowId: row.rowId, + return BlocProvider.value( + value: userWorkspaceBloc, + child: RelatedRowDetailPage( + databaseId: databaseId, + rowId: row.rowId, + ), ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart index c979ee0829..a218e1ed68 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart @@ -3,17 +3,25 @@ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class DatabaseViewWidget extends StatefulWidget { const DatabaseViewWidget({ super.key, required this.view, this.shrinkWrap = true, + required this.showActions, + required this.node, + this.actionBuilder, }); final ViewPB view; final bool shrinkWrap; + final BlockComponentActionBuilder? actionBuilder; + final bool showActions; + final Node node; @override State createState() => _DatabaseViewWidgetState(); @@ -50,14 +58,26 @@ class _DatabaseViewWidgetState extends State { @override Widget build(BuildContext context) { + double? horizontalPadding = 0.0; + final databasePluginWidgetBuilderSize = + Provider.of(context); + if (view.layout == ViewLayoutPB.Grid || view.layout == ViewLayoutPB.Board) { + horizontalPadding = 40.0; + } + if (databasePluginWidgetBuilderSize != null) { + horizontalPadding = databasePluginWidgetBuilderSize.horizontalPadding; + } + return ValueListenableBuilder( valueListenable: _layoutTypeChangeNotifier, builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( shrinkWrap: widget.shrinkWrap, context: PluginContext(), data: { - kDatabasePluginWidgetBuilderHorizontalPadding: - view.layout == ViewLayoutPB.Grid ? 40.0 : 0.0, + kDatabasePluginWidgetBuilderHorizontalPadding: horizontalPadding, + kDatabasePluginWidgetBuilderActionBuilder: widget.actionBuilder, + kDatabasePluginWidgetBuilderShowActions: widget.showActions, + kDatabasePluginWidgetBuilderNode: widget.node, }, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart index 7a888b96ed..f1486094bf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart @@ -57,25 +57,28 @@ class DatabaseGroupList extends StatelessWidget { final children = [ if (showHideUngroupedToggle) ...[ - SizedBox( - height: GridSize.popoverItemHeight, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - children: [ - Expanded( - child: FlowyText( - LocaleKeys.board_showUngrouped.tr(), - ), - ), - Toggle( - value: !state.layoutSettings.hideUngroupedColumn, - onChanged: (value) => - _updateLayoutSettings(state.layoutSettings, !value), - padding: EdgeInsets.zero, - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + resetHoverOnRebuild: false, + text: FlowyText( + LocaleKeys.board_showUngrouped.tr(), + lineHeight: 1.0, + ), + onTap: () { + _updateLayoutSettings( + state.layoutSettings, + !state.layoutSettings.hideUngroupedColumn, + ); + }, + rightIcon: Toggle( + value: !state.layoutSettings.hideUngroupedColumn, + onChanged: (value) => + _updateLayoutSettings(state.layoutSettings, !value), + padding: EdgeInsets.zero, + ), ), ), ), 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 2e1260fe3d..333ff0fe96 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,6 +1,5 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; - import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -58,7 +57,7 @@ class CellContainer extends StatelessWidget { } }, child: Container( - constraints: BoxConstraints(maxWidth: width, minHeight: 36), + constraints: BoxConstraints(maxWidth: width, minHeight: 32), decoration: _makeBoxDecoration(context, isFocus), child: container, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart index 256de6bc3c..1260641fdf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,14 +23,17 @@ class RelatedRowDetailPage extends StatelessWidget { initialRowId: rowId, ), child: BlocBuilder( - builder: (context, state) { + builder: (_, state) { return state.when( loading: () => const SizedBox.shrink(), ready: (databaseController, rowController) { - return RowDetailPage( - databaseController: databaseController, - rowController: rowController, - allowOpenAsFullPage: false, + return BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: databaseController, + rowController: rowController, + allowOpenAsFullPage: false, + ), ); }, ); 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 243eb5f027..e2f470e0d3 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,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/mobile/application/page_style/document_page_style_bloc.dart'; @@ -24,13 +21,15 @@ import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; @@ -70,8 +69,7 @@ class RowBanner extends StatefulWidget { class _RowBannerState extends State { final _isHovering = ValueNotifier(false); late final isLocalMode = - (widget.userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + (widget.userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; @override void dispose() { @@ -278,8 +276,10 @@ class _RowCoverState extends State { onPressed: () => popoverController.show(), hoverColor: Theme.of(context).colorScheme.surface, textColor: Theme.of(context).colorScheme.tertiary, - fillColor: - Theme.of(context).colorScheme.surface.withOpacity(0.5), + fillColor: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.5), title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), ), @@ -623,6 +623,7 @@ class _TitleSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart index 7b39335ec7..8bd181b427 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart @@ -1,7 +1,3 @@ -import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; -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/database_controller.dart'; @@ -10,6 +6,7 @@ import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database_document/database_document_plugin.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; @@ -17,11 +14,12 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../cell/editable_cell_builder.dart'; - import 'row_banner.dart'; import 'row_property.dart'; @@ -87,39 +85,51 @@ class _RowDetailPageState extends State { ], child: BlocBuilder( builder: (context, state) => Stack( + fit: StackFit.expand, children: [ - ListView( - controller: scrollController, - physics: const ClampingScrollPhysics(), - children: [ - RowBanner( - databaseController: widget.databaseController, - rowController: widget.rowController, - cellBuilder: cellBuilder, - allowOpenAsFullPage: widget.allowOpenAsFullPage, - userProfile: widget.userProfile, - ), - const VSpace(16), - Padding( - padding: const EdgeInsets.only(left: 40, right: 60), - child: RowPropertyList( - cellBuilder: cellBuilder, - viewId: widget.databaseController.viewId, - fieldController: - widget.databaseController.fieldController, - ), - ), - const VSpace(20), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 60), - child: Divider(height: 1.0), - ), - const VSpace(20), - RowDocument( + Positioned.fill( + child: NestedScrollView( + controller: scrollController, + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Column( + children: [ + RowBanner( + databaseController: widget.databaseController, + rowController: widget.rowController, + cellBuilder: cellBuilder, + allowOpenAsFullPage: widget.allowOpenAsFullPage, + userProfile: widget.userProfile, + ), + const VSpace(16), + Padding( + padding: + const EdgeInsets.only(left: 40, right: 60), + child: RowPropertyList( + cellBuilder: cellBuilder, + viewId: widget.databaseController.viewId, + fieldController: + widget.databaseController.fieldController, + ), + ), + const VSpace(20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 60), + child: Divider(height: 1.0), + ), + const VSpace(20), + ], + ), + ), + ]; + }, + body: RowDocument( viewId: widget.rowController.viewId, rowId: widget.rowController.rowId, ), - ], + ), ), Positioned( top: calculateActionsOffset( 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 e3d30249ac..436dbd085d 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,13 +1,16 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -70,9 +73,16 @@ class _RowEditor extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => - DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => DocumentBloc(documentId: view.id) + ..add(const DocumentEvent.initial()), + ), + BlocProvider( + create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + ), + ], child: BlocConsumer( listenWhen: (previous, current) => previous.isDocumentEmpty != current.isDocumentEmpty, @@ -102,16 +112,18 @@ class _RowEditor extends StatelessWidget { return BlocProvider( create: (context) => ViewInfoBloc(view: view), - child: IntrinsicHeight( - child: Container( - constraints: const BoxConstraints(minHeight: 300), - child: Provider( - create: (_) { - final context = SharedEditorContext(); - context.isInDatabaseRowPage = true; - return context; - }, - dispose: (_, editorContext) => editorContext.dispose(), + child: Container( + constraints: const BoxConstraints(minHeight: 300), + child: Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: AiWriterScrollWrapper( + viewId: view.id, + editorState: editorState, child: EditorDropHandler( viewId: view.id, editorState: editorState, @@ -120,18 +132,23 @@ class _RowEditor extends StatelessWidget { child: EditorTransactionService( viewId: view.id, editorState: editorState, - child: AppFlowyEditorPage( - shrinkWrap: true, - autoFocus: false, - editorState: editorState, - styleCustomizer: EditorStyleCustomizer( - context: context, - padding: const EdgeInsets.only(left: 16, right: 54), + child: Provider( + create: (context) => DatabasePluginWidgetBuilderSize( + horizontalPadding: 0, + ), + child: AppFlowyEditorPage( + shrinkWrap: true, + autoFocus: false, + editorState: editorState, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: const EdgeInsets.only(left: 16, right: 54), + ), + showParagraphPlaceholder: (editorState, _) => + editorState.document.isEmpty, + placeholderText: (_) => + LocaleKeys.cardDetails_notesPlaceholder.tr(), ), - showParagraphPlaceholder: (editorState, _) => - editorState.document.isEmpty, - placeholderText: (_) => - LocaleKeys.cardDetails_notesPlaceholder.tr(), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart index c8c23365de..b4ee4134c9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart @@ -1,35 +1,33 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/layout/layout_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../grid/presentation/layout/sizes.dart'; - -class DatabaseLayoutSelector extends StatefulWidget { +class DatabaseLayoutSelector extends StatelessWidget { const DatabaseLayoutSelector({ super.key, required this.viewId, - required this.currentLayout, + required this.databaseController, }); final String viewId; - final DatabaseLayoutPB currentLayout; + final DatabaseController databaseController; - @override - State createState() => _DatabaseLayoutSelectorState(); -} - -class _DatabaseLayoutSelectorState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseLayoutBloc( - viewId: widget.viewId, - databaseLayout: widget.currentLayout, + viewId: viewId, + databaseLayout: databaseController.databaseLayout, )..add(const DatabaseLayoutEvent.initial()), child: BlocBuilder( builder: (context, state) { @@ -44,14 +42,57 @@ class _DatabaseLayoutSelectorState extends State { ), ) .toList(); - - return ListView.separated( - shrinkWrap: true, - itemCount: cells.length, + return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), - itemBuilder: (_, int index) => cells[index], - separatorBuilder: (_, __) => - VSpace(GridSize.typeOptionSeparatorHeight), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + padding: EdgeInsets.zero, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ), + Container( + height: 1, + margin: EdgeInsets.fromLTRB(8, 4, 8, 0), + color: AFThemeExtension.of(context).borderColor, + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + child: SizedBox( + height: 30, + child: FlowyButton( + resetHoverOnRebuild: false, + text: FlowyText( + LocaleKeys.grid_settings_compactMode.tr(), + lineHeight: 1.0, + ), + onTap: () { + databaseController.setCompactMode( + !databaseController.compactModeNotifier.value, + ); + }, + rightIcon: ValueListenableBuilder( + valueListenable: databaseController.compactModeNotifier, + builder: (context, compactMode, child) { + return Toggle( + value: compactMode, + duration: Duration.zero, + onChanged: (value) => + databaseController.setCompactMode(value), + padding: EdgeInsets.zero, + ); + }, + ), + ), + ), + ), + ], + ), ); }, ), @@ -76,7 +117,7 @@ class DatabaseViewLayoutCell extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: SizedBox( - height: GridSize.popoverItemHeight, + height: 30, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart index 409454848a..c7bc286371 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart @@ -3,8 +3,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; import 'package:appflowy/plugins/database/widgets/group/database_group.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -24,11 +24,11 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { case DatabaseSettingAction.showProperties: return FlowySvgs.multiselect_s; case DatabaseSettingAction.showLayout: - return FlowySvgs.database_layout_m; + return FlowySvgs.database_layout_s; case DatabaseSettingAction.showGroup: return FlowySvgs.group_s; case DatabaseSettingAction.showCalendarLayout: - return FlowySvgs.calendar_layout_m; + return FlowySvgs.calendar_layout_s; } } @@ -53,7 +53,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { final popover = switch (this) { DatabaseSettingAction.showLayout => DatabaseLayoutSelector( viewId: databaseController.viewId, - currentLayout: databaseController.databaseLayout, + databaseController: databaseController, ), DatabaseSettingAction.showGroup => DatabaseGroupList( viewId: databaseController.viewId, @@ -88,6 +88,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { iconData(), color: Theme.of(context).iconTheme.color, ), + rightIcon: FlowySvg(FlowySvgs.database_settings_arrow_right_s), ), ), popupBuilder: (context) => popover, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart index 4d31e5a79d..36a6436b2a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart @@ -1,13 +1,11 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; class SettingButton extends StatefulWidget { const SettingButton({super.key, required this.databaseController}); @@ -29,15 +27,17 @@ class _SettingButtonState extends State { direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, - child: FlowyTextButton( - LocaleKeys.settings_title.tr(), - fontColor: Theme.of(context).hintColor, - fontSize: FontSizes.s12, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: _popoverController.show, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.settings_title.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.settings_s), + onPressed: _popoverController.show, + ), ), popupBuilder: (_) => DatabaseSettingsList(databaseController: widget.databaseController), 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 eaa82b22e9..ee52be8c26 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,3 +1,4 @@ +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'; @@ -7,8 +8,10 @@ import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; -import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; @@ -18,8 +21,12 @@ import 'package:appflowy/workspace/application/action_navigation/navigation_acti import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../../workspace/application/view/view_bloc.dart'; // This widget is largely copied from `plugins/document/document_page.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. @@ -46,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget { class _DatabaseDocumentPageState extends State { EditorState? editorState; - @override - void initState() { - super.initState(); - EditorNotification.addListener(_onEditorNotification); - } - - @override - void dispose() { - EditorNotification.removeListener(_onEditorNotification); - super.dispose(); - } - @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -72,6 +67,10 @@ class _DatabaseDocumentPageState extends State { documentId: widget.documentId, )..add(const DocumentEvent.initial()), ), + BlocProvider( + create: (_) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + ), ], child: BlocBuilder( builder: (context, state) { @@ -98,7 +97,11 @@ class _DatabaseDocumentPageState extends State { return BlocListener( listener: _onNotificationAction, listenWhen: (_, curr) => curr.action != null, - child: _buildEditorPage(context, state), + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, + child: _buildEditorPage(context, state), + ), ); }, ), @@ -115,21 +118,34 @@ class _DatabaseDocumentPageState extends State { styleCustomizer: EditorStyleCustomizer( context: context, padding: EditorStyleCustomizer.documentPadding, + editorState: state.editorState!, ), header: _buildDatabaseDataContent(context, state.editorState!), initialSelection: widget.initialSelection, useViewInfoBloc: false, + placeholderText: (node) => + node.type == ParagraphBlockKeys.type && !node.isInTable + ? LocaleKeys.editor_slashPlaceHolder.tr() + : '', ), ); - return EditorTransactionService( - viewId: widget.view.id, - editorState: state.editorState!, - child: Column( - children: [ - if (state.isDeleted) _buildBanner(context), - Expanded(child: appflowyEditorPage), - ], + return Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: EditorTransactionService( + viewId: widget.view.id, + editorState: state.editorState!, + child: Column( + children: [ + if (state.isDeleted) _buildBanner(context), + Expanded(child: appflowyEditorPage), + ], + ), ), ); } @@ -202,20 +218,6 @@ class _DatabaseDocumentPageState extends State { ); } - void _onEditorNotification(EditorNotificationType type) { - final editorState = this.editorState; - if (editorState == null) { - return; - } - if (type == EditorNotificationType.undo) { - undoCommand.execute(editorState); - } else if (type == EditorNotificationType.redo) { - redoCommand.execute(editorState); - } else if (type == EditorNotificationType.exitEditing) { - editorState.selection = null; - } - } - void _onNotificationAction( BuildContext context, ActionNavigationState state, diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart index 07c2a4b5dc..fd238271b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart @@ -1,4 +1,4 @@ -library document_plugin; +library; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; 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 788834d074..7f4493a999 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 @@ -141,6 +141,7 @@ class _TitleSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 f7ec313471..ac03fe5308 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/document/application/document_awareness_metadat import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; +import 'package:appflowy/plugins/document/application/document_rules.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; @@ -26,13 +27,7 @@ import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' - show - AppFlowyEditorLogLevel, - EditorState, - Position, - Selection, - TransactionTime, - paragraphNode; + show AppFlowyEditorLogLevel, EditorState, TransactionTime; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -90,6 +85,8 @@ class DocumentBloc extends Bloc { documentService: _documentService, ); + late final DocumentRules _documentRules; + StreamSubscription? _transactionSubscription; bool isClosing = false; @@ -104,8 +101,8 @@ class DocumentBloc extends Bloc { bool get isLocalMode { final userProfilePB = state.userProfilePB; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; - return type == AuthenticatorPB.Local; + final type = userProfilePB?.authType ?? AuthTypePB.Local; + return type == AuthTypePB.Local; } @override @@ -262,24 +259,27 @@ class DocumentBloc extends Bloc { final editorState = EditorState(document: document); _documentCollabAdapter = DocumentCollabAdapter(editorState, documentId); + _documentRules = DocumentRules(editorState: editorState); // subscribe to the document change from the editor _transactionSubscription = editorState.transactionStream.listen( - (event) async { - final time = event.$1; - final transaction = event.$2; - final options = event.$3; + (value) async { + final time = value.$1; + final transaction = value.$2; + final options = value.$3; if (time != TransactionTime.before) { return; } if (options.inMemoryUpdate) { - Log.info('skip transaction for in-memory update'); + if (enableDocumentInternalLog) { + Log.trace('skip transaction for in-memory update'); + } return; } if (enableDocumentInternalLog) { - Log.debug( + Log.trace( '[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}', ); } @@ -288,10 +288,10 @@ class DocumentBloc extends Bloc { await _transactionAdapter.apply(transaction, editorState); // check if the document is empty. - await _applyRules(); + await _documentRules.applyRules(value: value); if (enableDocumentInternalLog) { - Log.debug( + Log.trace( '[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}', ); } @@ -319,28 +319,6 @@ class DocumentBloc extends Bloc { return editorState; } - Future _applyRules() async { - await Future.wait([ - _ensureAtLeastOneParagraphExists(), - ]); - } - - Future _ensureAtLeastOneParagraphExists() async { - final editorState = state.editorState; - if (editorState == null) { - return; - } - final document = editorState.document; - if (document.root.children.isEmpty) { - final transaction = editorState.transaction; - transaction.insertNode([0], paragraphNode()); - transaction.afterSelection = Selection.collapsed( - Position(path: [0]), - ); - await editorState.apply(transaction); - } - } - Future _onDocumentStateUpdate(DocEventPB docEvent) async { if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { return; @@ -399,7 +377,7 @@ class DocumentBloc extends Bloc { final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), - selectionColor: basicColor.withOpacity(0.6).toHexString(), + selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); @@ -422,7 +400,7 @@ class DocumentBloc extends Bloc { final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), - selectionColor: basicColor.withOpacity(0.6).toHexString(), + selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); @@ -464,7 +442,6 @@ class DocumentBloc extends Bloc { final context = AppGlobals.rootNavKey.currentContext; if (context != null && context.mounted) { showToastNotification( - context, message: 'document integrity check failed', type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart index 74a6199b89..682f600f0a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart @@ -31,8 +31,7 @@ class DocumentCollaboratorsBloc final userProfile = result.fold((s) => s, (f) => null); emit( state.copyWith( - shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + shouldShowIndicator: userProfile?.authType == AuthTypePB.Server, ), ); final deviceId = ApplicationInfo.deviceId; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart index f9a80864f1..38bf2bcd14 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -13,12 +13,10 @@ import 'package:appflowy_editor/appflowy_editor.dart' NodeIterator, NodeExternalValues, HeadingBlockKeys, - QuoteBlockKeys, NumberedListBlockKeys, BulletedListBlockKeys, blockComponentDelta; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; -import 'package:collection/collection.dart'; import 'package:nanoid/nanoid.dart'; class ExternalValues extends NodeExternalValues { @@ -105,7 +103,7 @@ extension DocumentDataPBFromTo on DocumentDataPB { final children = []; if (childrenIds != null && childrenIds.isNotEmpty) { - children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); + children.addAll(childrenIds.map((e) => buildNode(e)).nonNulls); } final node = block?.toNode( diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart new file mode 100644 index 0000000000..f530b1ef8d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Apply rules to the document +/// +/// 1. ensure there is at least one paragraph in the document, otherwise the user will be blocked from typing +/// 2. remove columns block if its children are empty +class DocumentRules { + DocumentRules({ + required this.editorState, + }); + + final EditorState editorState; + + Future applyRules({ + required EditorTransactionValue value, + }) async { + await Future.wait([ + _ensureAtLeastOneParagraphExists(value: value), + _removeColumnIfItIsEmpty(value: value), + ]); + } + + Future _ensureAtLeastOneParagraphExists({ + required EditorTransactionValue value, + }) async { + final document = editorState.document; + if (document.root.children.isEmpty) { + final transaction = editorState.transaction; + transaction + ..insertNode([0], paragraphNode()) + ..afterSelection = Selection.collapsed( + Position(path: [0]), + ); + await editorState.apply(transaction); + } + } + + Future _removeColumnIfItIsEmpty({ + required EditorTransactionValue value, + }) async { + final transaction = value.$2; + final options = value.$3; + + if (options.inMemoryUpdate) { + return; + } + + for (final operation in transaction.operations) { + final deleteColumnsTransaction = editorState.transaction; + if (operation is DeleteOperation) { + final path = operation.path; + final column = editorState.document.nodeAtPath(path.parent); + if (column != null && column.type == SimpleColumnBlockKeys.type) { + // check if the column is empty + final children = column.children; + if (children.isEmpty) { + // delete the column or the columns + final columns = column.parent; + + if (columns != null && + columns.type == SimpleColumnsBlockKeys.type) { + final nonEmptyColumnCount = columns.children.fold( + 0, + (p, c) => c.children.isEmpty ? p : p + 1, + ); + + // Example: + // columns + // - column 1 + // - paragraph 1-1 + // - paragraph 1-2 + // - column 2 + // - paragraph 2 + // - column 3 + // - paragraph 3 + // + // case 1: delete the paragraph 3 from column 3. + // because there is only one child in column 3, we should delete the column 3 as well. + // the result should be: + // columns + // - column 1 + // - paragraph 1-1 + // - paragraph 1-2 + // - column 2 + // - paragraph 2 + // + // case 2: delete the paragraph 3 from column 3 and delete the paragraph 2 from column 2. + // in this case, there will be only one column left, so we should delete the columns block and flatten the children. + // the result should be: + // paragraph 1-1 + // paragraph 1-2 + + // if there is only one empty column left, delete the columns block and flatten the children + if (nonEmptyColumnCount <= 1) { + // move the children in columns out of the column + final children = columns.children + .map((e) => e.children) + .expand((e) => e) + .map((e) => e.deepCopy()) + .toList(); + deleteColumnsTransaction.insertNodes(columns.path, children); + deleteColumnsTransaction.deleteNode(columns); + } else { + // otherwise, delete the column + deleteColumnsTransaction.deleteNode(column); + + final deletedColumnRatio = + column.attributes[SimpleColumnBlockKeys.ratio]; + if (deletedColumnRatio != null) { + // update the ratio of the columns + final columnsNode = column.columnsParent; + if (columnsNode != null) { + final length = columnsNode.children.length; + for (final columnNode in columnsNode.children) { + final ratio = + columnNode.attributes[SimpleColumnBlockKeys.ratio] ?? + 1.0 / length; + if (ratio != null) { + deleteColumnsTransaction.updateNode(columnNode, { + ...columnNode.attributes, + SimpleColumnBlockKeys.ratio: + ratio + deletedColumnRatio / (length - 1), + }); + } + } + } + } + } + } + } + } + } + + if (deleteColumnsTransaction.operations.isNotEmpty) { + await editorState.apply(deleteColumnsTransaction); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart index 0fae90920d..2ba50fc6c4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart @@ -30,8 +30,7 @@ class DocumentSyncBloc extends Bloc { ); emit( state.copyWith( - shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + shouldShowIndicator: userProfile?.authType == AuthTypePB.Server, ), ); _syncStateListener.start( diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart index bbe671a822..2094462d6d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart @@ -4,7 +4,7 @@ import 'dart:convert'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ask_ai_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -113,7 +113,7 @@ class TransactionAdapter { ) { return transaction.operations .map((op) => op.toBlockAction(editorState, documentId)) - .whereNotNull() + .nonNulls .expand((element) => element) .toList(growable: false); // avoid lazy evaluation } @@ -163,7 +163,7 @@ extension on InsertOperation { Path currentPath = path; final List actions = []; for (final node in nodes) { - if (node.type == AskAIBlockKeys.type) { + if (node.type == AiWriterBlockKeys.type) { continue; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 38236b008b..4ebc6f1b47 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -1,4 +1,4 @@ -library document_plugin; +library; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 583af8af20..8716bb7ae2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,9 +1,11 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; @@ -16,9 +18,11 @@ import 'package:appflowy/workspace/application/action_navigation/action_navigati import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; @@ -63,6 +67,7 @@ class _DocumentPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); documentBloc.close(); + super.dispose(); } @@ -82,31 +87,65 @@ class _DocumentPageState extends State providers: [ BlocProvider.value(value: getIt()), BlocProvider.value(value: documentBloc), + BlocProvider.value( + value: ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + BlocProvider( + create: (context) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + lazy: false, + ), ], - child: BlocBuilder( - buildWhen: shouldRebuildDocument, - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator.adaptive()); + child: BlocConsumer( + listenWhen: (prev, curr) => curr.isLocked != prev.isLocked, + listener: (context, lockStatusState) { + if (lockStatusState.isLoadingLockStatus) { + return; } + editorState?.editable = !lockStatusState.isLocked; + }, + builder: (context, lockStatusState) { + return BlocBuilder( + buildWhen: shouldRebuildDocument, + builder: (context, state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } - final editorState = state.editorState; - this.editorState = editorState; - final error = state.error; - if (error != null || editorState == null) { - Log.error(error); - return Center(child: AppFlowyErrorPage(error: error)); - } + final editorState = state.editorState; + this.editorState = editorState; + final error = state.error; + if (error != null || editorState == null) { + Log.error(error); + return Center(child: AppFlowyErrorPage(error: error)); + } - if (state.forceClose) { - widget.onDeleted(); - return const SizedBox.shrink(); - } + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } - return BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: onNotificationAction, - child: buildEditorPage(context, state), + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) => + editorState.editable = !state.isLocked, + ), + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: onNotificationAction, + ), + ], + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, + child: buildEditorPage(context, state), + ), + ); + }, ); }, ), @@ -138,6 +177,7 @@ class _DocumentPageState extends State context: context, width: width, padding: EditorStyleCustomizer.documentPadding, + editorState: editorState, ), header: buildCoverAndIcon(context, state), initialSelection: initialSelection, @@ -156,9 +196,14 @@ class _DocumentPageState extends State context: context, width: width, padding: EditorStyleCustomizer.documentPadding, + editorState: editorState, ), header: buildCoverAndIcon(context, state), initialSelection: initialSelection, + placeholderText: (node) => + node.type == ParagraphBlockKeys.type && !node.isInTable + ? LocaleKeys.editor_slashPlaceHolder.tr() + : '', ), ); } @@ -225,7 +270,7 @@ class _DocumentPageState extends State editorState: editorState, view: widget.view, onIconChanged: (icon) async => ViewBackendService.updateViewIcon( - viewId: widget.view.id, + view: widget.view, viewIcon: icon, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart index 8fa15af8b2..1be5a41d81 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart @@ -46,7 +46,7 @@ class CollaboratorAvatarStack extends StatelessWidget { width: width, child: WidgetStack( positions: settings, - buildInfoWidget: (value) => plusWidgetBuilder(value, border), + buildInfoWidget: (value, _) => plusWidgetBuilder(value, border), stackedWidgets: avatars .map( (avatar) => CircleAvatar( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart new file mode 100644 index 0000000000..eaee989bbc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart @@ -0,0 +1,13 @@ +import 'package:event_bus/event_bus.dart'; + +EventBus compactModeEventBus = EventBus(); + +class CompactModeEvent { + CompactModeEvent({ + required this.id, + required this.enable, + }); + + final String id; + final bool enable; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart index 42d05c0f9e..d4a6815e32 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart @@ -1,13 +1,13 @@ import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_collaborators_bloc.dart'; import 'package:appflowy/plugins/document/presentation/collaborator_avater_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:avatar_stack/avatar_stack.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; class DocumentCollaborators extends StatelessWidget { const DocumentCollaborators({ @@ -93,39 +93,14 @@ class _UserAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final Widget child; - if (isURL(user.userAvatar)) { - child = _buildUrlAvatar(context); - } else { - child = _buildNameAvatar(context); - } return FlowyTooltip( message: user.userName, - child: child, - ); - } - - Widget _buildNameAvatar(BuildContext context) { - return CircleAvatar( - backgroundColor: user.cursorColor.tryToColor(), - child: FlowyText( - user.userName.characters.firstOrNull ?? ' ', - fontSize: fontSize, - color: Colors.black, - ), - ); - } - - Widget _buildUrlAvatar(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(width), - child: CircleAvatar( - backgroundColor: user.cursorColor.tryToColor(), - child: Image.network( - user.userAvatar, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - _buildNameAvatar(context), + child: IgnorePointer( + child: UserAvatar( + iconUrl: user.userAvatar, + name: user.userName, + size: 30.0, + fontSize: fontSize ?? (UniversalPlatform.isMobile ? 14 : 12), ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 8d911ebfb5..5e7eefc24e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -6,7 +6,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mo import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; @@ -15,6 +16,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'editor_plugins/link_preview/custom_link_preview_block_component.dart'; +import 'editor_plugins/page_block/custom_page_block_component.dart'; + +/// A global configuration for the editor. +class EditorGlobalConfiguration { + /// Whether to enable the drag menu in the editor. + /// + /// Case 1, resizing the columns block in the desktop, then the drag menu will be disabled. + static ValueNotifier enableDragMenu = ValueNotifier(true); +} + /// The node types that support slash menu. final Set supportSlashMenuNodeTypes = { ParagraphBlockKeys.type, @@ -26,11 +38,16 @@ final Set supportSlashMenuNodeTypes = { NumberedListBlockKeys.type, QuoteBlockKeys.type, ToggleListBlockKeys.type, + CalloutBlockKeys.type, // Simple table SimpleTableBlockKeys.type, SimpleTableRowBlockKeys.type, SimpleTableCellBlockKeys.type, + + // Columns + SimpleColumnsBlockKeys.type, + SimpleColumnBlockKeys.type, }; /// Build the block component builders. @@ -107,10 +124,19 @@ BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) { }, indentPadding: (node, textDirection) { double padding = 26.0; + // only add indent padding for the top level node to align the children - if (UniversalPlatform.isMobile && node.path.length == 1) { - padding += EditorStyleCustomizer.nodeHorizontalPadding; + if (UniversalPlatform.isMobile && node.level == 1) { + padding += EditorStyleCustomizer.nodeHorizontalPadding - 4; } + + // in the quote block, we reduce the indent padding for the first level block. + // So we have to add more padding for the second level to avoid the drag menu overlay the quote icon. + if (node.parent?.type == QuoteBlockKeys.type && + UniversalPlatform.isDesktop) { + padding += 22; + } + return textDirection == TextDirection.ltr ? EdgeInsets.only(left: padding) : EdgeInsets.only(right: padding); @@ -189,6 +215,16 @@ void _customBlockOptionActions( ), ); + builder.actionTrailingBuilder = (context, state) { + if (context.node.parent?.type == QuoteBlockKeys.type) { + return const SizedBox( + width: 24, + height: 24, + ); + } + return const SizedBox.shrink(); + }; + builder.actionBuilder = (context, state) { double top = builder.configuration.padding(context.node).top; final type = context.node.type; @@ -203,24 +239,45 @@ void _customBlockOptionActions( } else { top += 2.0; } - return Padding( - padding: EdgeInsets.only(top: top), - child: BlockActionList( - blockComponentContext: context, - blockComponentState: state, - editorState: editorState, - blockComponentBuilder: builders, - actions: actions, - showSlashMenu: slashMenuItemsBuilder != null - ? () => customAppFlowySlashCommand( - itemsBuilder: slashMenuItemsBuilder, - shouldInsertSlash: false, - deleteKeywordsByDefault: true, - style: styleCustomizer.selectionMenuStyleBuilder(), - supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, - ).handler.call(editorState) - : () {}, - ), + if (overflowTypes.contains(type)) { + top = top / 2; + } + return ValueListenableBuilder( + valueListenable: EditorGlobalConfiguration.enableDragMenu, + builder: (_, enableDragMenu, child) { + return ValueListenableBuilder( + valueListenable: editorState.editableNotifier, + builder: (_, editable, child) { + return IgnorePointer( + ignoring: !editable, + child: Opacity( + opacity: editable && enableDragMenu ? 1.0 : 0.0, + child: Padding( + padding: EdgeInsets.only(top: top), + child: BlockActionList( + blockComponentContext: context, + blockComponentState: state, + editorState: editorState, + blockComponentBuilder: builders, + actions: actions, + showSlashMenu: slashMenuItemsBuilder != null + ? () => customAppFlowySlashCommand( + itemsBuilder: slashMenuItemsBuilder, + shouldInsertSlash: false, + deleteKeywordsByDefault: true, + style: styleCustomizer + .selectionMenuStyleBuilder(), + supportSlashMenuNodeTypes: + supportSlashMenuNodeTypes, + ).handler.call(editorState) + : () {}, + ), + ), + ), + ); + }, + ); + }, ); }; } @@ -238,7 +295,7 @@ Map _buildBlockComponentBuilderMap( bool alwaysDistributeSimpleTableColumnWidths = false, }) { final customBlockComponentBuilderMap = { - PageBlockKeys.type: PageBlockComponentBuilder(), + PageBlockKeys.type: CustomPageBlockComponentBuilder(), ParagraphBlockKeys.type: _buildParagraphBlockComponentBuilder( context, configuration, @@ -313,11 +370,7 @@ Map _buildBlockComponentBuilderMap( configuration, styleCustomizer, ), - AIWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder( - context, - configuration, - ), - AskAIBlockKeys.type: _buildAskAIBlockComponentBuilder( + AiWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder( context, configuration, ), @@ -336,6 +389,11 @@ Map _buildBlockComponentBuilderMap( context, configuration, ), + // Flutter doesn't support the video widget, so we forward the video block to the link preview block + VideoBlockKeys.type: _buildLinkPreviewBlockComponentBuilder( + context, + configuration, + ), FileBlockKeys.type: _buildFileBlockComponentBuilder( context, configuration, @@ -363,6 +421,14 @@ Map _buildBlockComponentBuilderMap( configuration, alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, ), + SimpleColumnsBlockKeys.type: _buildSimpleColumnsBlockComponentBuilder( + context, + configuration, + ), + SimpleColumnBlockKeys.type: _buildSimpleColumnBlockComponentBuilder( + context, + configuration, + ), }; final builders = { @@ -548,6 +614,19 @@ QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder( node: node, configuration: configuration, ), + indentPadding: (node, textDirection) { + if (UniversalPlatform.isMobile) { + return configuration.indentPadding(node, textDirection); + } + + if (node.isInTable) { + return textDirection == TextDirection.ltr + ? EdgeInsets.only(left: 24) + : EdgeInsets.only(right: 24); + } + + return EdgeInsets.zero; + }, ), ); } @@ -661,7 +740,14 @@ TableBlockComponentBuilder _buildTableBlockComponentBuilder( BlockComponentConfiguration configuration, ) { return TableBlockComponentBuilder( - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + menuBuilder: ( + node, + editorState, + position, + dir, + onBuild, + onClose, + ) => TableMenu( node: node, editorState: editorState, @@ -688,7 +774,14 @@ TableCellBlockComponentBuilder _buildTableCellBlockComponentBuilder( } return buildEditorCustomizedColor(context, node, colorString); }, - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + menuBuilder: ( + node, + editorState, + position, + dir, + onBuild, + onClose, + ) => TableMenu( node: node, editorState: editorState, @@ -740,8 +833,14 @@ CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder( configuration: configuration, textSpan: textSpan, ), + indentPadding: (node, _) => EdgeInsets.only(left: 42), ), - inlinePadding: const EdgeInsets.symmetric(vertical: 8.0), + inlinePadding: (node) { + if (node.children.isEmpty) { + return const EdgeInsets.symmetric(vertical: 8.0); + } + return EdgeInsets.only(top: 8.0, bottom: 2.0); + }, defaultColor: calloutBGColor, ); } @@ -793,13 +892,6 @@ AIWriterBlockComponentBuilder _buildAIWriterBlockComponentBuilder( return AIWriterBlockComponentBuilder(); } -AskAIBlockComponentBuilder _buildAskAIBlockComponentBuilder( - BuildContext context, - BlockComponentConfiguration configuration, -) { - return AskAIBlockComponentBuilder(); -} - ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, @@ -878,11 +970,11 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder( ); } -LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( +CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { - return LinkPreviewBlockComponentBuilder( + return CustomLinkPreviewBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { @@ -891,21 +983,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( return const EdgeInsets.symmetric(vertical: 10); }, ), - cache: LinkPreviewDataCache(), - showMenu: true, - menuBuilder: (context, node, state) => Positioned( - top: 10, - right: 0, - child: LinkPreviewMenu(node: node, state: state), - ), - builder: (_, node, url, title, description, imageUrl) => - CustomLinkPreviewWidget( - node: node, - url: url, - title: title, - description: description, - imageUrl: imageUrl, - ), ); } @@ -937,6 +1014,34 @@ SubPageBlockComponentBuilder _buildSubPageBlockComponentBuilder( ); } +SimpleColumnsBlockComponentBuilder _buildSimpleColumnsBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleColumnsBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + + return EdgeInsets.zero; + }, + ), + ); +} + +SimpleColumnBlockComponentBuilder _buildSimpleColumnBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleColumnBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => EdgeInsets.zero, + ), + ); +} + TextStyle _buildTextStyleInTableCell( BuildContext context, { required Node node, @@ -945,6 +1050,11 @@ TextStyle _buildTextStyleInTableCell( }) { TextStyle textStyle = configuration.textStyle(node, textSpan: textSpan); + textStyle = textStyle.copyWith( + fontFamily: textSpan?.style?.fontFamily, + fontSize: textSpan?.style?.fontSize, + ); + if (node.isInHeaderColumn || node.isInHeaderRow || node.isInBoldColumn || diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart index f39f87b75e..62810545dd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart @@ -66,12 +66,20 @@ class EditorDropHandler extends StatelessWidget { return true; }, onAcceptWithDetails: _onDragViewDone, - builder: (context, _, __) => DropTarget( - enable: dropState.isDropEnabled, - onDragExited: (_) => editorState.selectionService.removeDropTarget(), - onDragUpdated: (details) => _onDragUpdated(details.globalPosition), - onDragDone: _onDragDone, - child: child, + builder: (context, _, __) => ValueListenableBuilder( + valueListenable: enableDocumentDragNotifier, + builder: (context, value, _) { + final enableDocumentDrag = value; + return DropTarget( + enable: dropState.isDropEnabled && enableDocumentDrag, + onDragExited: (_) => + editorState.selectionService.removeDropTarget(), + onDragUpdated: (details) => + _onDragUpdated(details.globalPosition), + onDragDone: _onDragDone, + child: child, + ); + }, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart index 728dee766d..8b59809f3b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart @@ -15,3 +15,5 @@ class EditorDropManagerState extends ChangeNotifier { bool get isDropEnabled => _draggedTypes.isEmpty; } + +final enableDocumentDragNotifier = ValueNotifier(true); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index d83bd055ce..edb19232be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,5 +1,6 @@ import 'dart:ui' as ui; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart'; @@ -14,9 +15,12 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; @@ -24,6 +28,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'editor_plugins/toolbar_item/custom_format_toolbar_items.dart'; +import 'editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/text_heading_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; + /// Wrapper for the appflowy editor. class AppFlowyEditorPage extends StatefulWidget { const AppFlowyEditorPage({ @@ -79,22 +94,25 @@ class _AppFlowyEditorPageState extends State ]; final List toolbarItems = [ - askAIItem..isActive = onlyShowInTextType, - paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - headingsToolbarItem - ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - ...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType), - quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - bulletedListItem - ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - numberedListItem - ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - inlineMathEquationItem, - linkItem, - alignToolbarItem, - buildTextColorItem()..isActive = showInAnyTextType, - buildHighlightColorItem()..isActive = showInAnyTextType, - customizeFontToolbarItem..isActive = showInAnyTextType, + improveWritingItem, + group0PaddingItem, + aiWriterItem, + customTextHeadingItem, + buildPaddingPlaceholderItem( + 1, + isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, + ), + ...customMarkdownFormatItems, + group1PaddingItem, + customTextColorItem, + group1PaddingItem, + customHighlightColorItem, + customInlineCodeItem, + suggestionsItem, + customLinkItem, + group4PaddingItem, + customTextAlignItem, + moreOptionItem, ]; List get characterShortcutEvents { @@ -111,6 +129,7 @@ class _AppFlowyEditorPageState extends State } EditorStyleCustomizer get styleCustomizer => widget.styleCustomizer; + DocumentBloc get documentBloc => context.read(); late final EditorScrollController editorScrollController; @@ -150,8 +169,24 @@ class _AppFlowyEditorPageState extends State InlineMathEquationKeys.formula, ]); - indentableBlockTypes.add(ToggleListBlockKeys.type); - convertibleBlockTypes.add(ToggleListBlockKeys.type); + indentableBlockTypes.addAll([ + ToggleListBlockKeys.type, + CalloutBlockKeys.type, + QuoteBlockKeys.type, + ]); + convertibleBlockTypes.addAll([ + ToggleListBlockKeys.type, + CalloutBlockKeys.type, + QuoteBlockKeys.type, + ]); + + editorLaunchUrl = (url) { + if (url != null) { + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + } + + return Future.value(true); + }; effectiveScrollController = widget.scrollController ?? ScrollController(); // disable the color parse in the HTML decoder. @@ -314,11 +349,16 @@ class _AppFlowyEditorPageState extends State ); final isViewDeleted = context.read().state.isDeleted; + final isLocked = + context.read()?.state.isLocked ?? false; + final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( editorState: widget.editorState, - editable: !isViewDeleted, + editable: !isViewDeleted && !isLocked, + disableSelectionService: UniversalPlatform.isMobile && isLocked, + disableKeyboardService: UniversalPlatform.isMobile && isLocked, editorScrollController: editorScrollController, // setup the auto focus parameters autoFocus: widget.autoFocus ?? autoFocus, @@ -344,6 +384,9 @@ class _AppFlowyEditorPageState extends State contextMenuItems: customContextMenuItems, // customize the header and footer. header: widget.header, + autoScrollEdgeOffset: UniversalPlatform.isDesktopOrWeb + ? 250 + : appFlowyEditorAutoScrollEdgeOffset, footer: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () async { @@ -352,11 +395,11 @@ class _AppFlowyEditorPageState extends State }, child: SizedBox( width: double.infinity, - height: UniversalPlatform.isDesktopOrWeb ? 200 : 400, + height: UniversalPlatform.isDesktopOrWeb ? 600 : 400, ), ), dropTargetStyle: AppFlowyDropTargetStyle( - color: Theme.of(context).colorScheme.primary.withOpacity(0.8), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.8), margin: const EdgeInsets.only(left: 44), ), ), @@ -382,26 +425,51 @@ class _AppFlowyEditorPageState extends State anchor: anchor, closeToolbar: closeToolbar, ), + floatingToolbarHeight: 32, child: editor, ), ); } - + final appTheme = AppFlowyTheme.of(context); return Center( - child: FloatingToolbar( - style: styleCustomizer.floatingToolbarStyleBuilder(), - items: toolbarItems, - editorState: editorState, - editorScrollController: editorScrollController, - textDirection: textDirection, - tooltipBuilder: (context, id, message, child) => - widget.styleCustomizer.buildToolbarItemTooltip( - context, - id, - message, - child, + child: BlocProvider.value( + value: context.read(), + child: FloatingToolbar( + floatingToolbarHeight: 40, + padding: EdgeInsets.symmetric(horizontal: 6), + style: FloatingToolbarStyle( + backgroundColor: Theme.of(context).cardColor, + toolbarActiveColor: Color(0xffe0f8fd), + ), + items: toolbarItems, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(appTheme.borderRadius.l), + color: appTheme.surfaceColorScheme.primary, + boxShadow: appTheme.shadow.small, + ), + toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => + BlocProvider.value( + value: context.read(), + child: DesktopFloatingToolbar( + editorState: editorState, + onDismiss: onDismiss, + enableAnimation: !isMetricsChanged, + child: child, + ), + ), + placeHolderBuilder: (_) => customPlaceholderItem, + editorState: editorState, + editorScrollController: editorScrollController, + textDirection: textDirection, + tooltipBuilder: (context, id, message, child) => + widget.styleCustomizer.buildToolbarItemTooltip( + context, + id, + message, + child, + ), + child: editor, ), - child: editor, ), ); } @@ -412,11 +480,13 @@ class _AppFlowyEditorPageState extends State }) { final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; + final view = context.read().state.view; return slashMenuItemsBuilder( editorState: editorState, node: node, isLocalMode: isLocalMode, documentBloc: documentBloc, + view: view, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart index aed2de9abf..9e0b241ab3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart @@ -1,8 +1,7 @@ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BlockActionButton extends StatelessWidget { @@ -23,15 +22,17 @@ class BlockActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - Widget child = MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grab, - child: IgnoreParentGestureWidget( - onPress: onPointerDown, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.deferToChild, + return FlowyTooltip( + richMessage: showTooltip ? richMessage : null, + child: FlowyIconButton( + width: 18.0, + hoverColor: Colors.transparent, + iconColorOnHover: Theme.of(context).iconTheme.color, + onPressed: onTap, + icon: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, child: FlowySvg( svg, size: const Size.square(18.0), @@ -40,16 +41,5 @@ class BlockActionButton extends StatelessWidget { ), ), ); - - if (showTooltip) { - child = FlowyTooltip( - richMessage: richMessage, - child: child, - ); - } - - return Align( - child: child, - ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart index fcbd7ea6ca..6323f675cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart @@ -42,7 +42,7 @@ class BlockActionList extends StatelessWidget { editorState: editorState, blockComponentBuilder: blockComponentBuilder, ), - const HSpace(8.0), + const HSpace(5.0), ], ); } 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 29dabd0da1..4efb1a55b2 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 @@ -50,7 +50,6 @@ class _BlockOptionButtonState extends State { child: BlocBuilder( builder: (context, _) => PopoverActionList( actions: _buildPopoverActions(context), - popoverMutex: PopoverMutex(), animationDuration: Durations.short3, slideDistance: 5, beginScaleFactor: 1.0, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart index 52b518a718..abed98136d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -10,7 +10,8 @@ import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:flutter_bloc/flutter_bloc.dart'; class BlockActionOptionState {} @@ -258,11 +259,13 @@ class BlockActionOptionCubit extends Cubit { emit(BlockActionOptionState()); // Emit a new state to trigger UI update } - Future turnIntoBlock( + static Future turnIntoBlock( String type, - Node node, { + Node node, + EditorState editorState, { int? level, String? currentViewId, + bool keepSelection = false, }) async { final selection = editorState.selection; if (selection == null) { @@ -286,6 +289,8 @@ class BlockActionOptionCubit extends Cubit { type: toType, selectedNodes: selectedNodes, level: level, + editorState: editorState, + afterSelection: keepSelection ? selection : null, )) { return true; } @@ -297,6 +302,7 @@ class BlockActionOptionCubit extends Cubit { selectedNodes: selectedNodes, selection: selection, currentViewId: currentViewId, + editorState: editorState, )) { return true; } @@ -321,9 +327,8 @@ class BlockActionOptionCubit extends Cubit { }, ); - // heading block and callout block should not have children - if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type] - .contains(toType)) { + // heading block should not have children + if ([HeadingBlockKeys.type].contains(toType)) { afterNode = afterNode.copyWith(children: []); afterNode = await _handleSubPageNode(afterNode, node); insertedNode.add(afterNode); @@ -344,6 +349,7 @@ class BlockActionOptionCubit extends Cubit { insertedNode, ); transaction.deleteNodes(selectedNodes); + if (keepSelection) transaction.afterSelection = selection; await editorState.apply(transaction); return true; @@ -353,7 +359,7 @@ class BlockActionOptionCubit extends Cubit { /// /// Returns the altered [Node] with the delta as the Views' name. /// - Future _handleSubPageNode(Node node, Node subPageNode) async { + static Future _handleSubPageNode(Node node, Node subPageNode) async { if (subPageNode.type != SubPageBlockKeys.type) { return node; } @@ -370,7 +376,7 @@ class BlockActionOptionCubit extends Cubit { /// Returns the [Delta] from a SubPage [Node], where the /// [Delta] is the views' name. /// - Future _deltaFromSubPageNode(Node node) async { + static Future _deltaFromSubPageNode(Node node) async { if (node.type != SubPageBlockKeys.type) { return null; } @@ -400,9 +406,10 @@ class BlockActionOptionCubit extends Cubit { // - paragraph 1 // - paragraph 2 // when turning "Toggle Heading 1" into toggle heading, the bulleted items will be moved into the toggle heading - Future turnIntoSingleToggleHeading({ + static Future turnIntoSingleToggleHeading({ required String type, required List selectedNodes, + required EditorState editorState, int? level, Delta? delta, Selection? afterSelection, @@ -462,7 +469,7 @@ class BlockActionOptionCubit extends Cubit { blockComponentDelta: newDelta.toJson(), }, children: [ - ...node.children, + ...node.children.map((e) => e.deepCopy()), ...insertedNodes.map((e) => e.deepCopy()), ], ); @@ -492,11 +499,12 @@ class BlockActionOptionCubit extends Cubit { return true; } - Future turnIntoPage({ + static Future turnIntoPage({ required String type, required List selectedNodes, required Selection selection, required String currentViewId, + required EditorState editorState, }) async { if (type != SubPageBlockKeys.type || selectedNodes.isEmpty) { return false; @@ -552,7 +560,7 @@ class BlockActionOptionCubit extends Cubit { return true; } - Future _extractNameFromNodes(List? nodes) async { + static Future _extractNameFromNodes(List? nodes) async { if (nodes == null || nodes.isEmpty) { return ''; } @@ -602,7 +610,7 @@ class BlockActionOptionCubit extends Cubit { return name.substring(0, name.length > 30 ? 30 : name.length); } - List _extractChildViewIds(List nodes) { + static List _extractChildViewIds(List nodes) { final List viewIds = []; for (final node in nodes) { if (node.type == SubPageBlockKeys.type) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart index 8ab16aea4c..7bc1fba8d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; @@ -9,7 +10,6 @@ import 'draggable_option_button_feedback.dart'; import 'option_button.dart'; // this flag is used to disable the tooltip of the block when it is dragged -@visibleForTesting ValueNotifier isDraggingAppFlowyEditorBlock = ValueNotifier(false); class DraggableOptionButton extends StatefulWidget { @@ -82,8 +82,40 @@ class _DraggableOptionButtonState extends State { void _onDragUpdate(DragUpdateDetails details) { isDraggingAppFlowyEditorBlock.value = true; + final offset = details.globalPosition; + widget.editorState.selectionService.renderDropTargetForOffset( - details.globalPosition, + offset, + interceptor: (context, targetNode) { + // if the cursor node is in a columns block or a column block, + // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. + final parentColumnNode = targetNode.columnParent; + if (parentColumnNode != null) { + final position = getDragAreaPosition( + context, + targetNode, + offset, + ); + + if (position != null && position.$2 == HorizontalPosition.right) { + return parentColumnNode; + } + + if (position != null && + position.$2 == HorizontalPosition.left && + position.$1 == VerticalPosition.middle) { + return parentColumnNode; + } + } + + // return simple table block if the target node is in a simple table block + final parentSimpleTableNode = targetNode.parentTableNode; + if (parentSimpleTableNode != null) { + return parentSimpleTableNode; + } + + return targetNode; + }, builder: (context, data) { return VisualDragArea( editorState: widget.editorState, @@ -112,7 +144,38 @@ class _DraggableOptionButtonState extends State { final data = widget.editorState.selectionService.getDropTargetRenderData( globalPosition!, + interceptor: (context, targetNode) { + // if the cursor node is in a columns block or a column block, + // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. + final parentColumnNode = targetNode.columnParent; + if (parentColumnNode != null) { + final position = getDragAreaPosition( + context, + targetNode, + globalPosition!, + ); + + if (position != null && position.$2 == HorizontalPosition.right) { + return parentColumnNode; + } + + if (position != null && + position.$2 == HorizontalPosition.left && + position.$1 == VerticalPosition.middle) { + return parentColumnNode; + } + } + + // return simple table block if the target node is in a simple table block + final parentSimpleTableNode = targetNode.parentTableNode; + if (parentSimpleTableNode != null) { + return parentSimpleTableNode; + } + + return targetNode; + }, ); + dragToMoveNode( context, node: widget.blockComponentContext.node, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart index fcafe9f8de..ca99491b94 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart @@ -1,6 +1,7 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +9,15 @@ enum HorizontalPosition { left, center, right } enum VerticalPosition { top, middle, bottom } +List nodeTypesThatCanContainChildNode = [ + ParagraphBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + TodoListBlockKeys.type, + ToggleListBlockKeys.type, +]; + Future dragToMoveNode( BuildContext context, { required Node node, @@ -26,6 +36,15 @@ Future dragToMoveNode( return; } + if (shouldIgnoreDragTarget( + editorState: editorState, + dragNode: node, + targetPath: acceptedPath, + )) { + Log.info('Drop ignored: node($node, ${node.path}), path($acceptedPath)'); + return; + } + final position = getDragAreaPosition(context, targetNode, dragOffset); if (position == null) { Log.info('position is null'); @@ -35,17 +54,111 @@ Future dragToMoveNode( final (verticalPosition, horizontalPosition, _) = position; Path newPath = targetNode.path; + // if the horizontal position is right, creating a column block to contain the target node and the drag node + if (horizontalPosition == HorizontalPosition.right) { + // 1. if the targetNode is a column block, it means we should create a column block to contain the node and insert the column node to the target node's parent + // 2. if the targetNode is not a column block, it means we should create a columns block to contain the target node and the drag node + final transaction = editorState.transaction; + final targetNodeParent = targetNode.columnsParent; + + if (targetNodeParent != null) { + final length = targetNodeParent.children.length; + final ratios = targetNodeParent.children + .map( + (e) => + e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length, + ) + .map((e) => e * length / (length + 1)) + .toList(); + + final columnNode = simpleColumnNode( + children: [node.deepCopy()], + ratio: 1.0 / (length + 1), + ); + for (final (index, column) in targetNodeParent.children.indexed) { + transaction.updateNode(column, { + ...column.attributes, + SimpleColumnBlockKeys.ratio: ratios[index], + }); + } + + transaction.insertNode(targetNode.path.next, columnNode); + transaction.deleteNode(node); + } else { + final columnsNode = simpleColumnsNode( + children: [ + simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), + simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), + ], + ); + + transaction.insertNode(newPath, columnsNode); + transaction.deleteNode(targetNode); + transaction.deleteNode(node); + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + return; + } else if (horizontalPosition == HorizontalPosition.left && + verticalPosition == VerticalPosition.middle) { + // 1. if the target node is a column block, we should create a column block to contain the node and insert the column node to the target node's parent + // 2. if the target node is not a column block, we should create a columns block to contain the target node and the drag node + final transaction = editorState.transaction; + final targetNodeParent = targetNode.columnsParent; + if (targetNodeParent != null) { + // find the previous sibling node of the target node + final length = targetNodeParent.children.length; + final ratios = targetNodeParent.children + .map( + (e) => + e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length, + ) + .map((e) => e * length / (length + 1)) + .toList(); + final columnNode = simpleColumnNode( + children: [node.deepCopy()], + ratio: 1.0 / (length + 1), + ); + + for (final (index, column) in targetNodeParent.children.indexed) { + transaction.updateNode(column, { + ...column.attributes, + SimpleColumnBlockKeys.ratio: ratios[index], + }); + } + + transaction.insertNode(targetNode.path.previous, columnNode); + transaction.deleteNode(node); + } else { + final columnsNode = simpleColumnsNode( + children: [ + simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), + simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), + ], + ); + + transaction.insertNode(newPath, columnsNode); + transaction.deleteNode(targetNode); + transaction.deleteNode(node); + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + return; + } // Determine the new path based on drop position // For VerticalPosition.top, we keep the target node's path if (verticalPosition == VerticalPosition.bottom) { if (horizontalPosition == HorizontalPosition.left) { newPath = newPath.next; - final node = editorState.document.nodeAtPath(newPath); - if (node == null) { - // if node is null, it means the node is the last one of the document. - newPath = targetNode.path; - } - } else { + } else if (horizontalPosition == HorizontalPosition.center && + nodeTypesThatCanContainChildNode.contains(targetNode.type)) { + // check if the target node can contain a child node newPath = newPath.child(0); } } @@ -103,18 +216,32 @@ Future dragToMoveNode( HorizontalPosition horizontalPosition = HorizontalPosition.left; VerticalPosition verticalPosition; - // Horizontal position + // | ----------------------------- block ----------------------------- | + // | 1. -- 88px --| 2. ---------------------------- | 3. ---- 1/5 ---- | + // 1. drag the node under the block as a sibling node + // 2. drag the node inside the block as a child node + // 3. create a column block to contain the node and the drag node + + // Horizontal position, please refer to the diagram above + // 88px is a hardcoded value, it can be changed based on the project's design if (dragOffset.dx < globalBlockRect.left + 88) { horizontalPosition = HorizontalPosition.left; - } else if (indentableBlockTypes.contains(dragTargetNode.type)) { - // For indentable blocks, it means the block can contain a child block. - // ignore the middle here, it's not used in this example + } else if (dragOffset.dx > globalBlockRect.right * 4.0 / 5.0) { horizontalPosition = HorizontalPosition.right; + } else if (nodeTypesThatCanContainChildNode.contains(dragTargetNode.type)) { + horizontalPosition = HorizontalPosition.center; } + // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is top + // | ----------------------------- block ----------------------------- | <- if the drag position is in this area, the vertical position is middle + // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is bottom + // Vertical position - if (dragOffset.dy < globalBlockRect.top + globalBlockRect.height / 2) { + final heightThird = globalBlockRect.height / 3; + if (dragOffset.dy < globalBlockRect.top + heightThird) { verticalPosition = VerticalPosition.top; + } else if (dragOffset.dy < globalBlockRect.top + heightThird * 2) { + verticalPosition = VerticalPosition.middle; } else { verticalPosition = VerticalPosition.bottom; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart index b4b4ffa904..2be8710a8a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart @@ -58,7 +58,36 @@ class VisualDragArea extends StatelessWidget { color: Theme.of(context).colorScheme.primary, ); + // if the horizontal position is right, we need to show the indicator on the right side of the target node + // which represent moving the target node and drag node inside the column block. + if (horizontalPosition == HorizontalPosition.left && + verticalPosition == VerticalPosition.middle) { + return Positioned( + top: globalBlockRect.top, + height: globalBlockRect.height, + left: globalBlockRect.left + indicatorWidth, + child: Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + if (horizontalPosition == HorizontalPosition.right) { + return Positioned( + top: globalBlockRect.top, + height: globalBlockRect.height, + left: globalBlockRect.right - 2, + child: Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + // If the horizontal position is center, we need to show two indicators + //which represent moving the block as the child of the target node. + if (horizontalPosition == HorizontalPosition.center) { const breakWidth = 22.0; const padding = 8.0; child = Row( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart index 539dd313b1..a04190f8af 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart @@ -110,7 +110,6 @@ class MobileBlockActionButtons extends StatelessWidget { ), ); break; - default: } if (transaction.operations.isNotEmpty) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart index 14ec6773a6..571cb4baa0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart @@ -1,7 +1,8 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart index ac3774a511..c927fcf85f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -2,10 +2,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -148,213 +148,134 @@ class TurnIntoOptionMenu extends StatelessWidget { @override Widget build(BuildContext context) { + if (hasNonSupportedTypes) { + return buildItem( + pateItem, + textSuggestionItem, + context.read().editorState, + ); + } + + return _buildTurnIntoOptions(context, node); + } + + Widget _buildTurnIntoOptions(BuildContext context, Node node) { + final editorState = context.read().editorState; + SuggestionItem currentSuggestionItem = textSuggestionItem; + final List suggestionItems = suggestions.sublist(0, 4); + final List turnIntoItems = + suggestions.sublist(4, suggestions.length); + final textColor = Color(0xff99A1A8); + + void refreshSuggestions() { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) return; + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) return; + final nodeType = node.type; + SuggestionType? suggestionType; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) { + suggestionType = SuggestionType.h1; + } else if (level == 2) { + suggestionType = SuggestionType.h2; + } else if (level == 3) { + suggestionType = SuggestionType.h3; + } + } else if (nodeType == ToggleListBlockKeys.type) { + final level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + suggestionType = SuggestionType.toggle; + } else if (level == 1) { + suggestionType = SuggestionType.toggleH1; + } else if (level == 2) { + suggestionType = SuggestionType.toggleH2; + } else if (level == 3) { + suggestionType = SuggestionType.toggleH3; + } + } else { + suggestionType = nodeType2SuggestionType[nodeType]; + } + if (suggestionType == null) return; + suggestionItems.clear(); + turnIntoItems.clear(); + for (final item in suggestions) { + if (item.type.group == suggestionType.group && + item.type != suggestionType) { + suggestionItems.add(item); + } else { + turnIntoItems.add(item); + } + } + currentSuggestionItem = + suggestions.where((item) => item.type == suggestionType).first; + } + + refreshSuggestions(); + return Column( mainAxisSize: MainAxisSize.min, - children: _buildTurnIntoOptions(context, node), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildSubTitle( + LocaleKeys.document_toolbar_suggestions.tr(), + textColor, + ), + ...List.generate(suggestionItems.length, (index) { + return buildItem( + suggestionItems[index], + currentSuggestionItem, + editorState, + ); + }), + buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), + ...List.generate(turnIntoItems.length, (index) { + return buildItem( + turnIntoItems[index], + currentSuggestionItem, + editorState, + ); + }), + ], ); } - List _buildTurnIntoOptions(BuildContext context, Node node) { - final children = []; + Widget buildSubTitle(String text, Color color) { + return Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + text, + color: color, + figmaLineHeight: 16, + ), + ), + ); + } - if (hasNonSupportedTypes) { - return children - ..add( - _TurnInfoButton( - type: SubPageBlockKeys.type, - node: node, - ), - ); - } - - for (final type in EditorOptionActionType.turnInto.supportTypes) { - if (type == ToggleListBlockKeys.type) { - // toggle list block and toggle heading block are the same type, - // but they have different attributes. - - // toggle list block - children.add( - _TurnInfoButton( - type: type, - node: node, - ), - ); - - // toggle heading block - for (final i in [1, 2, 3]) { - children.add( - _TurnInfoButton( - type: type, - node: node, - level: i, - ), - ); - } - } else if (type != HeadingBlockKeys.type) { - children.add( - _TurnInfoButton( - type: type, - node: node, - ), - ); - } else { - for (final i in [1, 2, 3]) { - children.add( - _TurnInfoButton( - type: type, - node: node, - level: i, - ), - ); - } - } - } - - return children; - } -} - -class _TurnInfoButton extends StatelessWidget { - const _TurnInfoButton({ - required this.type, - required this.node, - this.level, - }); - - final String type; - final Node node; - final int? level; - - @override - Widget build(BuildContext context) { - final name = _buildLocalization(type, level: level); - final leftIcon = _buildLeftIcon(type, level: level); - final rightIcon = _buildRightIcon(type, node, level: level); - - return HoverButton( - name: name, - leftIcon: FlowySvg(leftIcon), - rightIcon: rightIcon, - itemHeight: ActionListSizes.itemHeight, - onTap: () => context.read().turnIntoBlock( - type, - node, - level: level, - currentViewId: getIt().latestOpenView?.id, - ), - ); - } - - Widget? _buildRightIcon(String type, Node node, {int? level}) { - if (type != node.type) { - return null; - } - - if (node.type == HeadingBlockKeys.type) { - final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1; - if (level != nodeLevel) { - return null; - } - } - - if (node.type == ToggleListBlockKeys.type) { - final nodeLevel = node.attributes[ToggleListBlockKeys.level]; - if (level != nodeLevel) { - return null; - } - } - - return const FlowySvg( - FlowySvgs.workspace_selected_s, - blendMode: null, - ); - } - - FlowySvgData _buildLeftIcon(String type, {int? level}) { - if (type == ParagraphBlockKeys.type) { - return FlowySvgs.slash_menu_icon_text_s; - } else if (type == HeadingBlockKeys.type) { - switch (level) { - case 1: - return FlowySvgs.slash_menu_icon_h1_s; - case 2: - return FlowySvgs.slash_menu_icon_h2_s; - case 3: - return FlowySvgs.slash_menu_icon_h3_s; - default: - return FlowySvgs.slash_menu_icon_text_s; - } - } else if (type == QuoteBlockKeys.type) { - return FlowySvgs.slash_menu_icon_quote_s; - } else if (type == BulletedListBlockKeys.type) { - return FlowySvgs.slash_menu_icon_bulleted_list_s; - } else if (type == NumberedListBlockKeys.type) { - return FlowySvgs.slash_menu_icon_numbered_list_s; - } else if (type == TodoListBlockKeys.type) { - return FlowySvgs.slash_menu_icon_checkbox_s; - } else if (type == CalloutBlockKeys.type) { - return FlowySvgs.slash_menu_icon_callout_s; - } else if (type == SubPageBlockKeys.type) { - return FlowySvgs.icon_document_s; - } else if (type == ToggleListBlockKeys.type) { - switch (level) { - case 1: - return FlowySvgs.toggle_heading1_s; - case 2: - return FlowySvgs.toggle_heading2_s; - case 3: - return FlowySvgs.toggle_heading3_s; - default: - return FlowySvgs.slash_menu_icon_toggle_s; - } - } - - throw UnimplementedError('Unsupported block type: $type'); - } - - String _buildLocalization( - String type, { - int? level, - }) { - switch (type) { - case ParagraphBlockKeys.type: - return LocaleKeys.document_slashMenu_name_text.tr(); - case HeadingBlockKeys.type: - switch (level) { - case 1: - return LocaleKeys.document_slashMenu_name_heading1.tr(); - case 2: - return LocaleKeys.document_slashMenu_name_heading2.tr(); - case 3: - return LocaleKeys.document_slashMenu_name_heading3.tr(); - default: - return LocaleKeys.document_slashMenu_name_text.tr(); - } - case QuoteBlockKeys.type: - return LocaleKeys.document_slashMenu_name_quote.tr(); - case BulletedListBlockKeys.type: - return LocaleKeys.document_slashMenu_name_bulletedList.tr(); - case NumberedListBlockKeys.type: - return LocaleKeys.document_slashMenu_name_numberedList.tr(); - case TodoListBlockKeys.type: - return LocaleKeys.document_slashMenu_name_todoList.tr(); - case CalloutBlockKeys.type: - return LocaleKeys.document_slashMenu_name_callout.tr(); - case SubPageBlockKeys.type: - return LocaleKeys.editor_page.tr(); - case ToggleListBlockKeys.type: - switch (level) { - case 1: - return LocaleKeys.document_slashMenu_name_toggleHeading1.tr(); - case 2: - return LocaleKeys.document_slashMenu_name_toggleHeading2.tr(); - case 3: - return LocaleKeys.document_slashMenu_name_toggleHeading3.tr(); - default: - return LocaleKeys.document_slashMenu_name_toggleList.tr(); - } - } - - throw UnimplementedError('Unsupported block type: $type'); + Widget buildItem( + SuggestionItem item, + SuggestionItem currentSuggestionItem, + EditorState state, + ) { + final isSelected = item.type == currentSuggestionItem.type; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(item.svg), + iconPadding: 12, + text: FlowyText( + item.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () => item.onTap.call(state, false), + ), + ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index 51e8cf0276..f78f7d35fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -1,49 +1,52 @@ -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy/ai/service/error.dart'; +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/prelude.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; -import 'widgets/ai_limit_dialog.dart'; -import 'widgets/ai_writer_block_operations.dart'; -import 'widgets/ai_writer_block_widgets.dart'; -import 'widgets/discard_dialog.dart'; -import 'widgets/barrier_dialog.dart'; +import 'operations/ai_writer_cubit.dart'; +import 'operations/ai_writer_entities.dart'; +import 'operations/ai_writer_node_extension.dart'; +import 'widgets/ai_writer_suggestion_actions.dart'; +import 'widgets/ai_writer_prompt_input_more_button.dart'; -class AIWriterBlockKeys { - const AIWriterBlockKeys._(); +class AiWriterBlockKeys { + const AiWriterBlockKeys._(); static const String type = 'ai_writer'; - static const String prompt = 'prompt'; - static const String startSelection = 'start_selection'; - static const String generationCount = 'generation_count'; - static String getRewritePrompt(String previousOutput, String prompt) { - return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.'; - } + static const String isInitialized = 'is_initialized'; + static const String selection = 'selection'; + static const String command = 'command'; + + /// Sample usage: + /// + /// `attributes: { + /// 'ai_writer_delta_suggestion': 'original' + /// }` + static const String suggestion = 'ai_writer_delta_suggestion'; + static const String suggestionOriginal = 'original'; + static const String suggestionReplacement = 'replacement'; } Node aiWriterNode({ - String prompt = '', - required Selection start, + required Selection? selection, + required AiWriterCommand command, }) { return Node( - type: AIWriterBlockKeys.type, + type: AiWriterBlockKeys.type, attributes: { - AIWriterBlockKeys.prompt: prompt, - AIWriterBlockKeys.startSelection: start.toJson(), - AIWriterBlockKeys.generationCount: 0, + AiWriterBlockKeys.isInitialized: false, + AiWriterBlockKeys.selection: selection?.toJson(), + AiWriterBlockKeys.command: command.index, }, ); } @@ -54,7 +57,7 @@ class AIWriterBlockComponentBuilder extends BlockComponentBuilder { @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; - return AIWriterBlockComponent( + return AiWriterBlockComponent( key: node.key, node: node, showActions: showActions(node), @@ -62,71 +65,57 @@ class AIWriterBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty && - node.attributes[AIWriterBlockKeys.prompt] is String && - node.attributes[AIWriterBlockKeys.startSelection] is Map; + node.attributes[AiWriterBlockKeys.isInitialized] is bool && + node.attributes[AiWriterBlockKeys.selection] is Map? && + node.attributes[AiWriterBlockKeys.command] is int; } -class AIWriterBlockComponent extends BlockComponentStatefulWidget { - const AIWriterBlockComponent({ +class AiWriterBlockComponent extends BlockComponentStatefulWidget { + const AiWriterBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override - State createState() => _AIWriterBlockComponentState(); + State createState() => _AIWriterBlockComponentState(); } -class _AIWriterBlockComponentState extends State { - final controller = TextEditingController(); - final textFieldFocusNode = FocusNode(); +class _AIWriterBlockComponentState extends State { + final textController = TextEditingController(); + final overlayController = OverlayPortalController(); + final layerLink = LayerLink(); + final focusNode = FocusNode(); late final editorState = context.read(); - late final SelectionGestureInterceptor interceptor; - late final aiWriterOperations = AIWriterBlockOperations( - editorState: editorState, - aiWriterNode: widget.node, - ); - - String get prompt => widget.node.attributes[AIWriterBlockKeys.prompt]; - int get generationCount => - widget.node.attributes[AIWriterBlockKeys.generationCount] ?? 0; - Selection? get startSelection { - final selection = widget.node.attributes[AIWriterBlockKeys.startSelection]; - if (selection != null) { - return Selection.fromJson(selection); - } - return null; - } - - bool isGenerating = false; @override void initState() { super.initState(); - _subscribeSelectionGesture(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - editorState.selection = null; - textFieldFocusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + overlayController.show(); + context.read().register(widget.node); }); } @override void dispose() { - _onExit(); - _unsubscribeSelectionGesture(); - controller.dispose(); - textFieldFocusNode.dispose(); - + textController.dispose(); + focusNode.dispose(); super.dispose(); } @@ -136,277 +125,477 @@ class _AIWriterBlockComponentState extends State { return const SizedBox.shrink(); } - final child = Focus( - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) { - return KeyEventResult.ignored; - } - if (event.logicalKey == LogicalKeyboardKey.enter) { - if (!isGenerating) { - _onGenerate(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Card( - elevation: 5, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - color: Theme.of(context).colorScheme.surface, - child: Container( - margin: const EdgeInsets.all(10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AIWriterBlockHeader(), - const Space(0, 10), - if (prompt.isEmpty && generationCount < 1) ...[ - _buildInputWidget(context), - const Space(0, 10), - AIWriterBlockInputField( - onGenerate: _onGenerate, - onExit: _onExit, + final documentId = context.read()?.documentId; + + return BlocProvider( + create: (_) => AIPromptInputBloc( + predefinedFormat: null, + objectId: documentId ?? editorState.document.root.id, + ), + child: LayoutBuilder( + builder: (context, constraints) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return Center( + child: CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + child: Container( + padding: const EdgeInsets.only( + left: 40.0, + bottom: 16.0, + ), + width: constraints.maxWidth, + child: Focus( + focusNode: focusNode, + child: OverlayContent( + editorState: editorState, + node: widget.node, + textController: textController, + ), + ), + ), ), - ] else ...[ - AIWriterBlockFooter( - onKeep: _onExit, - onRewrite: _onRewrite, - onDiscard: _onDiscard, - ), - ], - ], - ), - ), + ); + }, + child: CompositedTransformTarget( + link: layerLink, + child: BlocBuilder( + builder: (context, state) { + return SizedBox( + width: double.infinity, + height: 1.0, + ); + }, + ), + ), + ); + }, ), ); - - return Padding( - padding: const EdgeInsets.only(left: 40), - child: child, - ); - } - - Widget _buildInputWidget(BuildContext context) { - return FlowyTextField( - hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(), - controller: controller, - maxLines: 5, - focusNode: textFieldFocusNode, - autoFocus: false, - hintTextConstraints: const BoxConstraints(), - ); - } - - Future _onExit() async { - await aiWriterOperations.removeAIWriterNode(widget.node); - } - - Future _onGenerate() async { - if (isGenerating) { - return; - } - - isGenerating = true; - - await aiWriterOperations.updatePromptText(controller.text); - - if (!_isAIWriterEnabled) { - Log.error('AI Writer is not enabled'); - return; - } - - final markdownTextRobot = MarkdownTextRobot( - editorState: editorState, - ); - - BarrierDialog? barrierDialog; - - final aiRepository = AppFlowyAIService(); - final objectId = - editorState.document.root.context?.read().documentId ?? - ""; - - await aiRepository.streamCompletion( - objectId: objectId, - text: controller.text, - completionType: CompletionTypePB.ContinueWriting, - onStart: () async { - if (mounted) { - barrierDialog = BarrierDialog(context); - barrierDialog?.show(); - await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode(); - markdownTextRobot.start(); - } - }, - onProcess: (text) async { - await markdownTextRobot.appendMarkdownText(text); - }, - onEnd: () async { - barrierDialog?.dismiss(); - await markdownTextRobot.stop(); - editorState.service.keyboardService?.enable(); - }, - onError: (error) async { - barrierDialog?.dismiss(); - _showAIWriterError(error); - }, - ); - - await aiWriterOperations.updateGenerationCount(generationCount + 1); - - isGenerating = false; - } - - Future _onDiscard() async { - await aiWriterOperations.discardCurrentResponse( - aiWriterNode: widget.node, - selection: startSelection, - ); - return _onExit(); - } - - Future _onRewrite() async { - if (isGenerating) { - return; - } - - isGenerating = true; - - final previousOutput = _getPreviousOutput(); - if (previousOutput == null) { - return; - } - - // discard the current response - await aiWriterOperations.discardCurrentResponse( - aiWriterNode: widget.node, - selection: startSelection, - ); - - if (!_isAIWriterEnabled) { - return; - } - - final markdownTextRobot = MarkdownTextRobot( - editorState: editorState, - ); - final aiService = AppFlowyAIService(); - final objectId = - editorState.document.root.context?.read().documentId ?? - ""; - await aiService.streamCompletion( - objectId: objectId, - text: AIWriterBlockKeys.getRewritePrompt(previousOutput, prompt), - completionType: CompletionTypePB.ContinueWriting, - onStart: () async { - await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode(); - - markdownTextRobot.start(); - }, - onProcess: (text) async { - await markdownTextRobot.appendMarkdownText(text); - }, - onEnd: () async { - await markdownTextRobot.stop(); - }, - onError: (error) { - _showAIWriterError(error); - }, - ); - - await aiWriterOperations.updateGenerationCount(generationCount + 1); - - isGenerating = false; - } - - String? _getPreviousOutput() { - final startSelection = this.startSelection; - if (startSelection != null) { - final end = widget.node.previous?.path; - - if (end != null) { - final result = editorState - .getNodesInSelection( - startSelection.copyWith(end: Position(path: end)), - ) - .fold( - '', - (previousValue, element) { - final delta = element.delta; - if (delta != null) { - return "$previousValue\n${delta.toPlainText()}"; - } else { - return previousValue; - } - }, - ); - return result.trim(); - } - } - return null; - } - - void _subscribeSelectionGesture() { - interceptor = SelectionGestureInterceptor( - key: AIWriterBlockKeys.type, - canTap: (details) { - if (!context.isOffsetInside(details.globalPosition)) { - if (prompt.isNotEmpty || controller.text.isNotEmpty) { - // show dialog - showDialog( - context: context, - builder: (_) => DiscardDialog( - onConfirm: _onDiscard, - onCancel: () {}, - ), - ); - } else if (controller.text.isEmpty) { - _onExit(); - } - } - editorState.service.keyboardService?.disable(); - return false; - }, - ); - editorState.service.selectionService.registerGestureInterceptor( - interceptor, - ); - } - - void _unsubscribeSelectionGesture() { - editorState.service.selectionService.unregisterGestureInterceptor( - AIWriterBlockKeys.type, - ); - } - - void _showAIWriterError(AIError error) { - if (mounted) { - if (error.isLimitExceeded) { - showAILimitDialog(context, error.message); - } else { - showToastNotification( - context, - message: error.message, - type: ToastificationType.error, - ); - } - } - } - - bool get _isAIWriterEnabled { - final userProfile = context.read().state.userProfilePB; - final isAIWriterEnabled = userProfile != null; - - if (!isAIWriterEnabled) { - showToastNotification( - context, - message: LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(), - type: ToastificationType.error, - ); - } - - return isAIWriterEnabled; + } +} + +class OverlayContent extends StatefulWidget { + const OverlayContent({ + super.key, + required this.editorState, + required this.node, + required this.textController, + }); + + final EditorState editorState; + final Node node; + final TextEditingController textController; + + @override + State createState() => _OverlayContentState(); +} + +class _OverlayContentState extends State { + final showCommandsToggle = ValueNotifier(false); + + @override + void dispose() { + showCommandsToggle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is IdleAiWriterState || + state is DocumentContentEmptyAiWriterState) { + return const SizedBox.shrink(); + } + + final command = (state as RegisteredAiWriter).command; + + final selection = widget.node.aiWriterSelection; + final hasSelection = selection != null && !selection.isCollapsed; + + final markdownText = switch (state) { + final ReadyAiWriterState ready => ready.markdownText, + final GeneratingAiWriterState generating => generating.markdownText, + _ => '', + }; + + final showSuggestedActions = + state is ReadyAiWriterState && !state.isFirstRun; + final isInitialReadyState = + state is ReadyAiWriterState && state.isFirstRun; + final showSuggestedActionsPopup = + showSuggestedActions && markdownText.isEmpty || + (markdownText.isNotEmpty && command != AiWriterCommand.explain); + final showSuggestedActionsWithin = showSuggestedActions && + markdownText.isNotEmpty && + command == AiWriterCommand.explain; + + final borderColor = Theme.of(context).isLightMode + ? Color(0x1F1F2329) + : Color(0xFF505469); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSuggestedActionsPopup) ...[ + Container( + padding: EdgeInsets.all(4.0), + decoration: _getModalDecoration( + context, + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderColor: borderColor, + ), + child: SuggestionActionBar( + currentCommand: command, + hasSelection: hasSelection, + onTap: (action) { + _onSelectSuggestionAction(context, action); + }, + ), + ), + const VSpace(4.0 + 1.0), + ], + Container( + decoration: _getModalDecoration( + context, + color: null, + borderColor: borderColor, + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + constraints: BoxConstraints(maxHeight: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (markdownText.isNotEmpty) ...[ + Flexible( + child: DecoratedBox( + decoration: _secondaryContentDecoration(context), + child: SecondaryContentArea( + markdownText: markdownText, + onSelectSuggestionAction: (action) { + _onSelectSuggestionAction(context, action); + }, + command: command, + showSuggestionActions: showSuggestedActionsWithin, + hasSelection: hasSelection, + ), + ), + ), + Divider(height: 1.0), + ], + DecoratedBox( + decoration: markdownText.isNotEmpty + ? _mainContentDecoration(context) + : _getSingleChildDeocoration(context), + child: MainContentArea( + textController: widget.textController, + isDocumentEmpty: _isDocumentEmpty(), + isInitialReadyState: isInitialReadyState, + showCommandsToggle: showCommandsToggle, + ), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, child) { + if (!value || !isInitialReadyState) { + return const SizedBox.shrink(); + } + return Align( + alignment: AlignmentDirectional.centerEnd, + child: MoreAiWriterCommands( + hasSelection: hasSelection, + editorState: widget.editorState, + onSelectCommand: (command) { + final state = context.read().state; + final showPredefinedFormats = state.showPredefinedFormats; + final predefinedFormat = state.predefinedFormat; + final text = widget.textController.text; + + context.read().runCommand( + command, + text, + showPredefinedFormats ? predefinedFormat : null, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ); + } + + BoxDecoration _getModalDecoration( + BuildContext context, { + required Color? color, + required Color borderColor, + required BorderRadius borderRadius, + }) { + return BoxDecoration( + color: color, + border: Border.all( + color: borderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: borderRadius, + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, + ); + } + + BoxDecoration _getSingleChildDeocoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ); + } + + BoxDecoration _secondaryContentDecoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)), + ); + } + + BoxDecoration _mainContentDecoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)), + ); + } + + void _onSelectSuggestionAction( + BuildContext context, + SuggestionAction action, + ) { + final predefinedFormat = + context.read().state.predefinedFormat; + context.read().runResponseAction( + action, + predefinedFormat, + ); + } + + bool _isDocumentEmpty() { + if (widget.editorState.isEmptyForContinueWriting()) { + final documentContext = widget.editorState.document.root.context; + if (documentContext == null) { + return true; + } + final view = documentContext.read().state.view; + if (view.name.isEmpty) { + return true; + } + } + return false; + } +} + +class SecondaryContentArea extends StatelessWidget { + const SecondaryContentArea({ + super.key, + required this.command, + required this.markdownText, + required this.showSuggestionActions, + required this.hasSelection, + required this.onSelectSuggestionAction, + }); + + final AiWriterCommand command; + final String markdownText; + final bool showSuggestionActions; + final bool hasSelection; + final void Function(SuggestionAction) onSelectSuggestionAction; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(8.0), + Container( + height: 24.0, + padding: EdgeInsets.symmetric(horizontal: 14.0), + alignment: AlignmentDirectional.centerStart, + child: FlowyText( + command.i18n, + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF666D76), + ), + ), + const VSpace(4.0), + Flexible( + child: SingleChildScrollView( + physics: ClampingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 14.0), + child: AIMarkdownText( + markdown: markdownText, + ), + ), + ), + if (showSuggestionActions) ...[ + const VSpace(4.0), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: SuggestionActionBar( + currentCommand: command, + hasSelection: hasSelection, + onTap: onSelectSuggestionAction, + ), + ), + ], + const VSpace(8.0), + ], + ), + ); + } +} + +class MainContentArea extends StatelessWidget { + const MainContentArea({ + super.key, + required this.textController, + required this.isInitialReadyState, + required this.isDocumentEmpty, + required this.showCommandsToggle, + }); + + final TextEditingController textController; + final bool isInitialReadyState; + final bool isDocumentEmpty; + final ValueNotifier showCommandsToggle; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + + if (state is ReadyAiWriterState) { + return DesktopPromptInput( + isStreaming: false, + hideDecoration: true, + hideFormats: [ + AiWriterCommand.fixSpellingAndGrammar, + AiWriterCommand.improveWriting, + AiWriterCommand.makeLonger, + AiWriterCommand.makeShorter, + ].contains(state.command), + textController: textController, + onSubmitted: (message, format, _) { + cubit.runCommand(state.command, message, format); + }, + onStopStreaming: () => cubit.stopStream(), + selectedSourcesNotifier: cubit.selectedSourcesNotifier, + onUpdateSelectedSources: (sources) { + cubit.selectedSourcesNotifier.value = [ + ...sources, + ]; + }, + extraBottomActionButton: isInitialReadyState + ? ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, _) { + return AiWriterPromptMoreButton( + isEnabled: !isDocumentEmpty, + isSelected: value, + onTap: () => showCommandsToggle.value = !value, + ); + }, + ) + : null, + ); + } + if (state is GeneratingAiWriterState) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const HSpace(6.0), + Expanded( + child: AILoadingIndicator( + text: state.command == AiWriterCommand.explain + ? LocaleKeys.ai_analyzing.tr() + : LocaleKeys.ai_editing.tr(), + ), + ), + const HSpace(8.0), + PromptInputSendButton( + state: SendButtonState.streaming, + onSendPressed: () {}, + onStopStreaming: () => cubit.stopStream(), + ), + ], + ), + ); + } + if (state is ErrorAiWriterState) { + return Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_error_filled_s, + blendMode: null, + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + state.error.message, + maxLines: null, + ), + ), + const HSpace(8.0), + FlowyIconButton( + width: 32, + hoverColor: Colors.transparent, + icon: FlowySvg( + FlowySvgs.toast_close_s, + size: Size.square(20), + ), + onPressed: () => cubit.exit(), + ), + ], + ), + ); + } + if (state is LocalAIStreamingAiWriterState) { + final text = switch (state.state) { + LocalAIStreamingState.notReady => + LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(), + LocalAIStreamingState.disabled => + LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(), + }; + return Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + const HSpace(8.0), + Opacity( + opacity: 0.5, + child: FlowyText(text), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart new file mode 100644 index 0000000000..70d627d327 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -0,0 +1,249 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'operations/ai_writer_entities.dart'; + +const _improveWritingToolbarItemId = 'appflowy.editor.ai_improve_writing'; +const _aiWriterToolbarItemId = 'appflowy.editor.ai_writer'; + +final ToolbarItem improveWritingItem = ToolbarItem( + id: _improveWritingToolbarItemId, + group: 0, + isActive: onlyShowInTextTypeAndExcludeTable, + builder: (context, editorState, _, __, tooltipBuilder) => + ImproveWritingButton( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ), +); + +final ToolbarItem aiWriterItem = ToolbarItem( + id: _aiWriterToolbarItemId, + group: 0, + isActive: onlyShowInTextTypeAndExcludeTable, + builder: (context, editorState, _, __, tooltipBuilder) => + AiWriterToolbarActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ), +); + +class AiWriterToolbarActionList extends StatefulWidget { + const AiWriterToolbarActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + State createState() => + _AiWriterToolbarActionListState(); +} + +class _AiWriterToolbarActionListState extends State { + final popoverController = PopoverController(); + bool isSelected = false; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildPopoverContent() { + return SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: [ + actionWrapper(AiWriterCommand.improveWriting), + actionWrapper(AiWriterCommand.userQuestion), + actionWrapper(AiWriterCommand.fixSpellingAndGrammar), + // actionWrapper(AiWriterCommand.summarize), + actionWrapper(AiWriterCommand.explain), + divider(), + actionWrapper(AiWriterCommand.makeLonger), + actionWrapper(AiWriterCommand.makeShorter), + ], + ); + } + + Widget actionWrapper(AiWriterCommand command) { + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.icon), + iconPadding: 12, + text: FlowyText( + command.i18n, + figmaLineHeight: 20, + ), + onTap: () { + popoverController.close(); + _insertAiNode(widget.editorState, command); + }, + ), + ); + } + + Widget divider() { + return const Divider( + thickness: 1.0, + height: 1.0, + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_ai_writer_m, + size: Size.square(20), + color: iconScheme.primary, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconScheme.primary, + ), + ], + ), + onPressed: () { + if (_isAIWriterEnabled(widget.editorState)) { + keepEditorFocusNotifier.increase(); + popoverController.show(); + setState(() { + isSelected = true; + }); + } else { + showToastNotification( + message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + ); + } + }, + ); + + return widget.tooltipBuilder?.call( + context, + _aiWriterToolbarItemId, + _isAIWriterEnabled(widget.editorState) + ? LocaleKeys.document_plugins_aiWriter_userQuestion.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ) ?? + child; + } +} + +class ImproveWritingButton extends StatelessWidget { + const ImproveWritingButton({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_ai_improve_writing_m, + size: Size.square(20.0), + color: theme.iconColorScheme.primary, + ), + onPressed: () { + if (_isAIWriterEnabled(editorState)) { + keepEditorFocusNotifier.increase(); + _insertAiNode(editorState, AiWriterCommand.improveWriting); + } else { + showToastNotification( + message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + ); + } + }, + ); + + return tooltipBuilder?.call( + context, + _aiWriterToolbarItemId, + _isAIWriterEnabled(editorState) + ? LocaleKeys.document_plugins_aiWriter_improveWriting.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ) ?? + child; + } +} + +void _insertAiNode(EditorState editorState, AiWriterCommand command) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + final transaction = editorState.transaction + ..insertNode( + selection.end.path.next, + aiWriterNode( + selection: selection, + command: command, + ), + ) + ..selectionExtraInfo = {selectionExtraInfoDisableToolbar: true}; + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, + ); +} + +bool _isAIWriterEnabled(EditorState editorState) { + return true; +} + +bool onlyShowInTextTypeAndExcludeTable( + EditorState editorState, +) { + return onlyShowInTextType(editorState) && notShowInTable(editorState); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_block_component.dart deleted file mode 100644 index df3724f90e..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_block_component.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy/ai/service/error.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/ai/service/ai_client.dart'; -import 'package:appflowy/plugins/document/application/document_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_limit_dialog.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_block_widgets.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:universal_platform/universal_platform.dart'; - -class AskAIBlockKeys { - const AskAIBlockKeys._(); - - static const type = 'ask_ai'; - - /// The instruction of the smart edit. - /// - /// It is a [AskAIAction] value. - static const action = 'action'; - - /// The input of the smart edit. - /// - /// The content is a string that using '\n\n' as separator. - static const content = 'content'; -} - -Node askAINode({ - required AskAIAction action, - required String content, -}) { - return Node( - type: AskAIBlockKeys.type, - attributes: { - AskAIBlockKeys.action: action.index, - AskAIBlockKeys.content: content, - }, - ); -} - -class AskAIBlockComponentBuilder extends BlockComponentBuilder { - AskAIBlockComponentBuilder(); - - @override - BlockComponentWidget build(BlockComponentContext blockComponentContext) { - final node = blockComponentContext.node; - return AskAIBlockComponentWidget( - key: node.key, - node: node, - showActions: showActions(node), - actionBuilder: (context, state) => actionBuilder( - blockComponentContext, - state, - ), - ); - } - - @override - BlockComponentValidate get validate => (node) => - node.attributes[AskAIBlockKeys.action] is int && - node.attributes[AskAIBlockKeys.content] is String; -} - -class AskAIBlockComponentWidget extends BlockComponentStatefulWidget { - const AskAIBlockComponentWidget({ - super.key, - required super.node, - super.showActions, - super.actionBuilder, - super.configuration = const BlockComponentConfiguration(), - }); - - @override - State createState() => - _AskAIBlockComponentWidgetState(); -} - -class _AskAIBlockComponentWidgetState extends State { - final popoverController = PopoverController(); - - late final editorState = context.read(); - late final action = - AskAIAction.values[widget.node.attributes[AskAIBlockKeys.action] as int]; - late AskAIActionBloc askAIBloc; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - popoverController.show(); - }); - - final objectId = - editorState.document.root.context?.read().documentId ?? - ""; - - askAIBloc = AskAIActionBloc( - objectId: objectId, - node: widget.node, - editorState: editorState, - action: action, - )..add(AskAIEvent.initial(getIt.getAsync())); - } - - @override - void dispose() { - askAIBloc.close(); - - super.dispose(); - } - - @override - void reassemble() { - super.reassemble(); - - _removeNode(); - } - - @override - Widget build(BuildContext context) { - if (UniversalPlatform.isMobile) { - return const SizedBox.shrink(); - } - - final width = _getEditorWidth(); - - return BlocProvider.value( - value: askAIBloc, - child: BlocListener( - listener: _onListen, - child: AppFlowyPopover( - controller: popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - offset: const Offset(40, 0), // align the editor block - windowPadding: EdgeInsets.zero, - constraints: BoxConstraints(maxWidth: width), - canClose: () async { - final completer = Completer(); - final state = askAIBloc.state; - if (state.result.isEmpty) { - completer.complete(true); - } else { - await showCancelAndConfirmDialog( - context: context, - title: LocaleKeys.document_plugins_discardResponse.tr(), - description: '', - confirmLabel: LocaleKeys.button_discard.tr(), - onConfirm: () => completer.complete(true), - onCancel: () => completer.complete(false), - ); - } - return completer.future; - }, - onClose: _removeNode, - popupBuilder: (BuildContext popoverContext) { - return BlocProvider.value( - // request the result when opening the popover - value: askAIBloc..add(const AskAIEvent.started()), - child: const AskAIInputContent(), - ); - }, - child: const SizedBox( - width: double.infinity, - ), - ), - ), - ); - } - - double _getEditorWidth() { - var width = double.infinity; - try { - final editorSize = editorState.renderBox?.size; - final editorWidth = - editorSize?.width.clamp(0, editorState.editorStyle.maxWidth ?? width); - final padding = editorState.editorStyle.padding; - if (editorWidth != null) { - width = editorWidth - padding.left - padding.right; - } - } catch (_) {} - return width; - } - - void _removeNode() { - final transaction = editorState.transaction..deleteNode(widget.node); - editorState.apply(transaction); - } - - void _onListen(BuildContext context, AskAIState state) { - final error = state.requestError; - if (error != null) { - if (error.isLimitExceeded) { - showAILimitDialog(context, error.message); - } else { - showToastNotification( - context, - message: error.message, - type: ToastificationType.error, - ); - } - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_toolbar_item.dart deleted file mode 100644 index 723b7f4ffb..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ask_ai_toolbar_item.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/util/ask_ai_node_extension.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'widgets/ask_ai_action.dart'; -import 'ask_ai_block_component.dart'; - -const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai'; - -final ToolbarItem askAIItem = ToolbarItem( - id: _kAskAIToolbarItemId, - group: 0, - isActive: onlyShowInSingleSelectionAndTextType, - builder: (context, editorState, _, __, tooltipBuilder) => AskAIActionList( - editorState: editorState, - tooltipBuilder: tooltipBuilder, - ), -); - -class AskAIActionList extends StatefulWidget { - const AskAIActionList({ - super.key, - required this.editorState, - this.tooltipBuilder, - }); - - final EditorState editorState; - final ToolbarTooltipBuilder? tooltipBuilder; - - @override - State createState() => _AskAIActionListState(); -} - -class _AskAIActionListState extends State { - late bool isAIEnabled; - - EditorState get editorState => widget.editorState; - - @override - void initState() { - super.initState(); - _updateIsAIEnabled(); - } - - @override - Widget build(BuildContext context) { - return PopoverActionList( - offset: const Offset(-5, 5), - direction: PopoverDirection.bottomWithLeftAligned, - actions: AskAIAction.values - .map((action) => AskAIActionWrapper(action)) - .toList(), - onClosed: () => keepEditorFocusNotifier.decrease(), - buildChild: (controller) { - keepEditorFocusNotifier.increase(); - final child = FlowyButton( - text: FlowyText.regular( - LocaleKeys.document_plugins_smartEdit.tr(), - fontSize: 13.0, - figmaLineHeight: 16.0, - color: Colors.white, - ), - hoverColor: Colors.transparent, - useIntrinsicWidth: true, - leftIcon: const FlowySvg( - FlowySvgs.toolbar_item_ai_s, - size: Size.square(16.0), - color: Colors.white, - ), - onTap: () { - if (isAIEnabled) { - keepEditorFocusNotifier.increase(); - controller.show(); - } else { - showToastNotification( - context, - message: - LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), - ); - } - }, - ); - - if (widget.tooltipBuilder != null) { - return widget.tooltipBuilder!( - context, - _kAskAIToolbarItemId, - isAIEnabled - ? LocaleKeys.document_plugins_smartEdit.tr() - : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), - child, - ); - } - - return child; - }, - onSelected: (action, controller) { - controller.close(); - _insertAskAINode(action); - }, - ); - } - - Future _insertAskAINode( - AskAIActionWrapper actionWrapper, - ) async { - final selection = editorState.selection?.normalized; - if (selection == null) { - return; - } - - final markdown = editorState.getMarkdownInSelection(selection); - - final transaction = editorState.transaction; - transaction.insertNode( - selection.normalized.end.path.next, - askAINode( - action: actionWrapper.inner, - content: markdown, - ), - ); - await editorState.apply( - transaction, - options: const ApplyOptions( - recordUndo: false, - inMemoryUpdate: true, - ), - withUpdateSelection: false, - ); - } - - void _updateIsAIEnabled() { - final documentContext = widget.editorState.document.root.context; - isAIEnabled = documentContext == null || - !documentContext.read().isLocalMode; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart new file mode 100644 index 0000000000..1b495a5b23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../ai_writer_block_component.dart'; +import 'ai_writer_entities.dart'; +import 'ai_writer_node_extension.dart'; + +Future setAiWriterNodeIsInitialized( + EditorState editorState, + Node node, +) async { + final transaction = editorState.transaction + ..updateNode(node, { + AiWriterBlockKeys.isInitialized: true, + }); + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, + ); + + final selection = node.aiWriterSelection; + if (selection != null && !selection.isCollapsed) { + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: {selectionExtraInfoDisableToolbar: true}, + ), + ); + } +} + +Future removeAiWriterNode( + EditorState editorState, + Node node, +) async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + withUpdateSelection: false, + ); +} + +Future formatSelection( + EditorState editorState, + Selection selection, + ApplySuggestionFormatType formatType, +) async { + final nodes = editorState.getNodesInSelection(selection).toList(); + if (nodes.isEmpty) { + return; + } + final transaction = editorState.transaction; + + if (nodes.length == 1) { + final node = nodes.removeAt(0); + if (node.delta != null) { + final delta = Delta() + ..retain(selection.start.offset) + ..retain( + selection.length, + attributes: formatType.attributes, + ); + transaction.addDeltaToComposeMap(node, delta); + } + } else { + final firstNode = nodes.removeAt(0); + final lastNode = nodes.removeLast(); + + if (firstNode.delta != null) { + final text = firstNode.delta!.toPlainText(); + final remainderLength = text.length - selection.start.offset; + final delta = Delta() + ..retain(selection.start.offset) + ..retain(remainderLength, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(firstNode, delta); + } + + if (lastNode.delta != null) { + final delta = Delta() + ..retain(selection.end.offset, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(lastNode, delta); + } + + for (final node in nodes) { + if (node.delta == null) { + continue; + } + final length = node.delta!.length; + if (length != 0) { + final delta = Delta() + ..retain(length, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(node, delta); + } + } + } + + transaction.compose(); + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + withUpdateSelection: false, + ); +} + +Future ensurePreviousNodeIsEmptyParagraph( + EditorState editorState, + Node aiWriterNode, +) async { + final previous = aiWriterNode.previous; + final needsEmptyParagraphNode = previous == null || + previous.type != ParagraphBlockKeys.type || + (previous.delta?.toPlainText().isNotEmpty ?? false); + + final Position position; + final transaction = editorState.transaction; + + if (needsEmptyParagraphNode) { + position = Position(path: aiWriterNode.path); + transaction.insertNode(aiWriterNode.path, paragraphNode()); + } else { + position = Position(path: previous.path); + } + transaction.afterSelection = Selection.collapsed(position); + + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + ); + + return position; +} + +extension SaveAIResponseExtension on EditorState { + Future insertBelow({ + required Node node, + required String markdownText, + }) async { + final selection = this.selection?.normalized; + if (selection == null) { + return; + } + + final nodes = customMarkdownToDocument( + markdownText, + tableWidth: 250.0, + ).root.children.map((e) => e.deepCopy()).toList(); + if (nodes.isEmpty) { + return; + } + + final insertedPath = selection.end.path.next; + final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0; + + final transaction = this.transaction + ..insertNodes(insertedPath, nodes) + ..afterSelection = Selection( + start: Position(path: insertedPath), + end: Position( + path: insertedPath.nextNPath(nodes.length - 1), + offset: lastDeltaLength, + ), + ); + + await apply(transaction); + } + + Future replace({ + required Selection selection, + required String text, + }) async { + final trimmedText = text.trim(); + if (trimmedText.isEmpty) { + return; + } + await switch (kdefaultReplacementType) { + AskAIReplacementType.markdown => + _replaceWithMarkdown(selection, trimmedText), + AskAIReplacementType.plainText => + _replaceWithPlainText(selection, trimmedText), + }; + } + + Future _replaceWithMarkdown( + Selection selection, + String markdownText, + ) async { + final nodes = customMarkdownToDocument(markdownText) + .root + .children + .map((e) => e.deepCopy()) + .toList(); + if (nodes.isEmpty) { + return; + } + + final nodesInSelection = getNodesInSelection(selection); + final newSelection = Selection( + start: selection.start, + end: Position( + path: selection.start.path.nextNPath(nodes.length - 1), + offset: nodes.lastOrNull?.delta?.length ?? 0, + ), + ); + + final transaction = this.transaction + ..insertNodes(selection.start.path, nodes) + ..deleteNodes(nodesInSelection) + ..afterSelection = newSelection; + await apply(transaction); + } + + Future _replaceWithPlainText( + Selection selection, + String plainText, + ) async { + final nodes = getNodesInSelection(selection); + if (nodes.isEmpty || nodes.any((element) => element.delta == null)) { + return; + } + + final replaceTexts = plainText.split('\n') + ..removeWhere((element) => element.isEmpty); + final transaction = this.transaction + ..replaceTexts( + nodes, + selection, + replaceTexts, + ); + await apply(transaction); + + int endOffset = replaceTexts.last.length; + if (replaceTexts.length == 1) { + endOffset += selection.start.offset; + } + final end = Position( + path: [selection.start.path.first + replaceTexts.length - 1], + offset: endOffset, + ); + this.selection = Selection( + start: selection.start, + end: end, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart new file mode 100644 index 0000000000..4bc13321b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -0,0 +1,794 @@ +import 'dart:async'; + +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import '../../base/markdown_text_robot.dart'; +import 'ai_writer_block_operations.dart'; +import 'ai_writer_entities.dart'; +import 'ai_writer_node_extension.dart'; + +/// Enable the debug log for the AiWriterCubit. +/// +/// This is useful for debugging the AI writer cubit. +const _aiWriterCubitDebugLog = true; + +class AiWriterCubit extends Cubit { + AiWriterCubit({ + required this.documentId, + required this.editorState, + this.onCreateNode, + this.onRemoveNode, + this.onAppendToDocument, + AppFlowyAIService? aiService, + }) : _aiService = aiService ?? AppFlowyAIService(), + _textRobot = MarkdownTextRobot(editorState: editorState), + selectedSourcesNotifier = ValueNotifier([documentId]), + super(IdleAiWriterState()); + + final String documentId; + final EditorState editorState; + final AppFlowyAIService _aiService; + final MarkdownTextRobot _textRobot; + final void Function()? onCreateNode; + final void Function()? onRemoveNode; + final void Function()? onAppendToDocument; + + Node? aiWriterNode; + + final List records = []; + final ValueNotifier> selectedSourcesNotifier; + + @override + Future close() async { + selectedSourcesNotifier.dispose(); + await super.close(); + } + + Future exit({ + bool withDiscard = true, + bool withUnformat = true, + }) async { + if (aiWriterNode == null) { + return; + } + if (withDiscard) { + await _textRobot.discard( + afterSelection: aiWriterNode!.aiWriterSelection, + ); + } + _textRobot.clear(); + _textRobot.reset(); + onRemoveNode?.call(); + records.clear(); + selectedSourcesNotifier.value = [documentId]; + emit(IdleAiWriterState()); + + if (withUnformat) { + final selection = aiWriterNode!.aiWriterSelection; + if (selection == null) { + return; + } + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + } + if (aiWriterNode != null) { + await removeAiWriterNode(editorState, aiWriterNode!); + aiWriterNode = null; + } + } + + void register(Node node) async { + if (node.isAiWriterInitialized) { + return; + } + if (aiWriterNode != null && node.id != aiWriterNode!.id) { + await removeAiWriterNode(editorState, node); + return; + } + + aiWriterNode = node; + onCreateNode?.call(); + + await setAiWriterNodeIsInitialized(editorState, node); + + final command = node.aiWriterCommand; + final (run, prompt) = await _addSelectionTextToRecords(command); + + _aiWriterCubitLog( + 'command: $command, run: $run, prompt: $prompt', + ); + + if (!run) { + await exit(); + return; + } + + runCommand(command, prompt, null); + } + + void runCommand( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + if (aiWriterNode == null) { + return; + } + + await _textRobot.discard(); + _textRobot.clear(); + + switch (command) { + case AiWriterCommand.continueWriting: + await _startContinueWriting( + command, + predefinedFormat, + ); + break; + case AiWriterCommand.fixSpellingAndGrammar: + case AiWriterCommand.improveWriting: + case AiWriterCommand.makeLonger: + case AiWriterCommand.makeShorter: + await _startSuggestingEdits(command, prompt, predefinedFormat); + break; + case AiWriterCommand.explain: + await _startInforming(command, prompt, predefinedFormat); + break; + case AiWriterCommand.userQuestion when prompt.isNotEmpty: + _startAskingQuestion(prompt, predefinedFormat); + break; + case AiWriterCommand.userQuestion: + emit( + ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true), + ); + break; + } + } + + void _retry({ + required PredefinedFormat? predefinedFormat, + }) async { + final lastQuestion = + records.lastWhereOrNull((record) => record.role == AiRole.user); + + if (lastQuestion != null && state is RegisteredAiWriter) { + runCommand( + (state as RegisteredAiWriter).command, + lastQuestion.content, + lastQuestion.format, + ); + } + } + + Future stopStream() async { + if (aiWriterNode == null) { + return; + } + + if (state is GeneratingAiWriterState) { + final generatingState = state as GeneratingAiWriterState; + + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + + if (_textRobot.hasAnyResult) { + records.add(AiWriterRecord.ai(content: _textRobot.markdownText)); + } + + await AIEventStopCompleteText( + CompleteTextTaskPB( + taskId: generatingState.taskId, + ), + ).send(); + + emit( + ReadyAiWriterState( + generatingState.command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + } + } + + void runResponseAction( + SuggestionAction action, [ + PredefinedFormat? predefinedFormat, + ]) async { + if (aiWriterNode == null) { + return; + } + + if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { + _retry(predefinedFormat: predefinedFormat); + return; + } + if (action case SuggestionAction.discard || SuggestionAction.close) { + await exit(); + return; + } + + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + + // Accept + // + // If the user clicks accept, we need to replace the selection with the AI's response + if (action case SuggestionAction.accept) { + // trim the markdown text to avoid extra new lines + final trimmedMarkdownText = _textRobot.markdownText.trim(); + + _aiWriterCubitLog( + 'trigger accept action, markdown text: $trimmedMarkdownText', + ); + + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + + await _textRobot.deleteAINodes(); + + await _textRobot.replace( + selection: selection, + markdownText: trimmedMarkdownText, + ); + + await exit(withDiscard: false, withUnformat: false); + + return; + } + + if (action case SuggestionAction.keep) { + await _textRobot.persist(); + await exit(withDiscard: false); + return; + } + + if (action case SuggestionAction.insertBelow) { + if (state is! ReadyAiWriterState) { + return; + } + final command = (state as ReadyAiWriterState).command; + final markdownText = (state as ReadyAiWriterState).markdownText; + if (command == AiWriterCommand.explain && markdownText.isNotEmpty) { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + await _textRobot.persist(markdownText: markdownText); + } else if (_textRobot.hasAnyResult) { + await _textRobot.persist(); + } + + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + await exit(withDiscard: false); + } + } + + bool hasUnusedResponse() { + return switch (state) { + ReadyAiWriterState( + isFirstRun: final isInitial, + markdownText: final markdownText, + ) => + !isInitial && (markdownText.isNotEmpty || _textRobot.hasAnyResult), + GeneratingAiWriterState() => true, + _ => false, + }; + } + + Future<(bool, String)> _addSelectionTextToRecords( + AiWriterCommand command, + ) async { + final node = aiWriterNode; + + // check the node is registered + if (node == null) { + return (false, ''); + } + + // check the selection is valid + final selection = node.aiWriterSelection?.normalized; + if (selection == null) { + return (false, ''); + } + + // if the command is continue writing, we don't need to get the selection text + if (command == AiWriterCommand.continueWriting) { + return (true, ''); + } + + // if the selection is collapsed, we don't need to get the selection text + if (selection.isCollapsed) { + return (true, ''); + } + + final selectionText = await editorState.getMarkdownInSelection(selection); + + if (command == AiWriterCommand.userQuestion) { + records.add( + AiWriterRecord.user(content: selectionText, format: null), + ); + + return (true, ''); + } else { + return (true, selectionText); + } + } + + Future _getDocumentContentFromTopToPosition(Position position) async { + final beginningToCursorSelection = Selection( + start: Position(path: [0]), + end: position, + ).normalized; + + final documentText = + (await editorState.getMarkdownInSelection(beginningToCursorSelection)) + .trim(); + + final view = await ViewBackendService.getView(documentId).toNullable(); + final viewName = view?.name ?? ''; + + return "$viewName\n$documentText".trim(); + } + + void _startAskingQuestion( + String prompt, + PredefinedFormat? format, + ) async { + if (aiWriterNode == null) { + return; + } + final command = AiWriterCommand.userQuestion; + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + format: format, + history: records, + sourceIds: selectedSourcesNotifier.value, + completionType: command.toCompletionType(), + onStart: () async { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: prompt, + format: format, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + } + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + + if (stream != null) { + emit( + GeneratingAiWriterState( + command, + taskId: stream.$1, + ), + ); + } + } + + Future _startContinueWriting( + AiWriterCommand command, + PredefinedFormat? predefinedFormat, + ) async { + final position = aiWriterNode?.aiWriterSelection?.start; + if (position == null) { + return; + } + final text = await _getDocumentContentFromTopToPosition(position); + + if (text.isEmpty) { + final stateCopy = state; + emit(DocumentContentEmptyAiWriterState(command, onConfirm: exit)); + emit(stateCopy); + return; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: text, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, + onStart: () async { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: text, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + } + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + Future _startSuggestingEdits( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + format: predefinedFormat, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + onStart: () async { + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.original, + ); + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position, previousSelection: selection); + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + + _aiWriterCubitLog( + 'received message: $text', + ); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + + _aiWriterCubitLog( + 'received assist message: $text', + ); + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + + _aiWriterCubitLog( + 'returned response: ${_textRobot.markdownText}', + ); + } + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + Future _startInforming( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, + onStart: () async { + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + processAssistMessage: (_) async {}, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: generatingState.markdownText), + ); + } + }, + onError: (error) async { + if (state case final GeneratingAiWriterState generatingState) { + records.add( + AiWriterRecord.ai(content: generatingState.markdownText), + ); + } + emit(ErrorAiWriterState(command, error: error)); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + void _aiWriterCubitLog(String message) { + if (_aiWriterCubitDebugLog) { + Log.debug('[AiWriterCubit] $message'); + } + } +} + +mixin RegisteredAiWriter { + AiWriterCommand get command; +} + +sealed class AiWriterState { + const AiWriterState(); +} + +class IdleAiWriterState extends AiWriterState { + const IdleAiWriterState(); +} + +class ReadyAiWriterState extends AiWriterState with RegisteredAiWriter { + const ReadyAiWriterState( + this.command, { + required this.isFirstRun, + this.markdownText = '', + }); + + @override + final AiWriterCommand command; + + final bool isFirstRun; + final String markdownText; +} + +class GeneratingAiWriterState extends AiWriterState with RegisteredAiWriter { + const GeneratingAiWriterState( + this.command, { + required this.taskId, + this.progress = '', + this.markdownText = '', + }); + + @override + final AiWriterCommand command; + + final String taskId; + final String progress; + final String markdownText; +} + +class ErrorAiWriterState extends AiWriterState with RegisteredAiWriter { + const ErrorAiWriterState( + this.command, { + required this.error, + }); + + @override + final AiWriterCommand command; + + final AIError error; +} + +class DocumentContentEmptyAiWriterState extends AiWriterState + with RegisteredAiWriter { + const DocumentContentEmptyAiWriterState( + this.command, { + required this.onConfirm, + }); + + @override + final AiWriterCommand command; + + final void Function() onConfirm; +} + +class LocalAIStreamingAiWriterState extends AiWriterState + with RegisteredAiWriter { + const LocalAIStreamingAiWriterState( + this.command, { + required this.state, + }); + + @override + final AiWriterCommand command; + + final LocalAIStreamingState state; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart new file mode 100644 index 0000000000..f15c2e6d7f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart @@ -0,0 +1,159 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +import '../ai_writer_block_component.dart'; + +const kdefaultReplacementType = AskAIReplacementType.markdown; + +enum AskAIReplacementType { + markdown, + plainText, +} + +enum SuggestionAction { + accept, + discard, + close, + tryAgain, + rewrite, + keep, + insertBelow; + + String get i18n => switch (this) { + accept => LocaleKeys.suggestion_accept.tr(), + discard => LocaleKeys.suggestion_discard.tr(), + close => LocaleKeys.suggestion_close.tr(), + tryAgain => LocaleKeys.suggestion_tryAgain.tr(), + rewrite => LocaleKeys.suggestion_rewrite.tr(), + keep => LocaleKeys.suggestion_keep.tr(), + insertBelow => LocaleKeys.suggestion_insertBelow.tr(), + }; + + FlowySvg buildIcon(BuildContext context) { + final icon = switch (this) { + accept || keep => FlowySvgs.ai_fix_spelling_grammar_s, + discard || close => FlowySvgs.toast_close_s, + tryAgain || rewrite => FlowySvgs.ai_try_again_s, + insertBelow => FlowySvgs.suggestion_insert_below_s, + }; + + return FlowySvg( + icon, + size: Size.square(16.0), + color: switch (this) { + accept || keep => Color(0xFF278E42), + discard || close => Color(0xFFC40055), + _ => Theme.of(context).iconTheme.color, + }, + ); + } +} + +enum AiWriterCommand { + userQuestion, + explain, + // summarize, + continueWriting, + fixSpellingAndGrammar, + improveWriting, + makeShorter, + makeLonger; + + String defaultPrompt(String input) => switch (this) { + userQuestion => input, + explain => "Explain this phrase in a concise manner:\n\n$input", + // summarize => '$input\n\nTl;dr', + continueWriting => + 'Continue writing based on this existing text:\n\n$input', + fixSpellingAndGrammar => 'Correct this to standard English:\n\n$input', + improveWriting => 'Rewrite this in your own words:\n\n$input', + makeShorter => 'Make this text shorter:\n\n$input', + makeLonger => 'Make this text longer:\n\n$input', + }; + + String get i18n => switch (this) { + userQuestion => LocaleKeys.document_plugins_aiWriter_userQuestion.tr(), + explain => LocaleKeys.document_plugins_aiWriter_explain.tr(), + // summarize => LocaleKeys.document_plugins_aiWriter_summarize.tr(), + continueWriting => + LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), + fixSpellingAndGrammar => + LocaleKeys.document_plugins_aiWriter_fixSpelling.tr(), + improveWriting => + LocaleKeys.document_plugins_smartEditImproveWriting.tr(), + makeShorter => LocaleKeys.document_plugins_aiWriter_makeShorter.tr(), + makeLonger => LocaleKeys.document_plugins_aiWriter_makeLonger.tr(), + }; + + FlowySvgData get icon => switch (this) { + userQuestion => FlowySvgs.ai_sparks_s, + explain => FlowySvgs.ai_explain_m, + // summarize => FlowySvgs.ai_summarize_s, + continueWriting || improveWriting => FlowySvgs.ai_improve_writing_s, + fixSpellingAndGrammar => FlowySvgs.ai_fix_spelling_grammar_s, + makeShorter => FlowySvgs.ai_make_shorter_s, + makeLonger => FlowySvgs.ai_make_longer_s, + }; + + CompletionTypePB toCompletionType() => switch (this) { + userQuestion => CompletionTypePB.UserQuestion, + explain => CompletionTypePB.ExplainSelected, + // summarize => CompletionTypePB.Summarize, + continueWriting => CompletionTypePB.ContinueWriting, + fixSpellingAndGrammar => CompletionTypePB.SpellingAndGrammar, + improveWriting => CompletionTypePB.ImproveWriting, + makeShorter => CompletionTypePB.MakeShorter, + makeLonger => CompletionTypePB.MakeLonger, + }; +} + +enum ApplySuggestionFormatType { + original(AiWriterBlockKeys.suggestionOriginal), + replace(AiWriterBlockKeys.suggestionReplacement), + clear(null); + + const ApplySuggestionFormatType(this.value); + final String? value; + + Map get attributes => {AiWriterBlockKeys.suggestion: value}; +} + +enum AiRole { + user, + system, + ai, +} + +class AiWriterRecord extends Equatable { + const AiWriterRecord.user({ + required this.content, + required this.format, + }) : role = AiRole.user; + + const AiWriterRecord.ai({ + required this.content, + }) : role = AiRole.ai, + format = null; + + final AiRole role; + final String content; + final PredefinedFormat? format; + + @override + List get props => [role, content, format]; + + CompletionRecordPB toPB() { + return CompletionRecordPB( + content: content, + role: switch (role) { + AiRole.user => ChatMessageTypePB.User, + AiRole.system || AiRole.ai => ChatMessageTypePB.System, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart new file mode 100644 index 0000000000..881871b154 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../ai_writer_block_component.dart'; +import 'ai_writer_entities.dart'; + +extension AiWriterExtension on Node { + bool get isAiWriterInitialized { + return attributes[AiWriterBlockKeys.isInitialized]; + } + + Selection? get aiWriterSelection { + final selection = attributes[AiWriterBlockKeys.selection]; + if (selection == null) { + return null; + } + return Selection.fromJson(selection); + } + + AiWriterCommand get aiWriterCommand { + final index = attributes[AiWriterBlockKeys.command]; + return AiWriterCommand.values[index]; + } +} + +extension AiWriterNodeExtension on EditorState { + Future getMarkdownInSelection(Selection? selection) async { + selection ??= this.selection?.normalized; + if (selection == null || selection.isCollapsed) { + return ''; + } + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final List flattenNodes = getNodesInSelection(selection); + final List nodes = []; + + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + + final slicedDelta = delta.slice( + node == nodes.first ? selection.startIndex : 0, + node == nodes.last ? selection.endIndex : delta.length, + ); + + final copiedNode = node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: slicedDelta.toJson(), + }, + ); + + slicedNodes.add(copiedNode); + } + + for (final (i, node) in slicedNodes.indexed) { + final childNodesShouldBeDeleted = []; + for (final child in node.children) { + if (!child.path.inSelection(selection)) { + childNodesShouldBeDeleted.add(child); + } + } + for (final child in childNodesShouldBeDeleted) { + slicedNodes[i] = node.copyWith( + children: node.children.where((e) => e.id != child.id).toList(), + type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type, + ); + } + } + + // use \n\n as line break to improve the ai response + // using \n will cause the ai response treat the text as a single line + final markdown = await customDocumentToMarkdown( + Document.blank()..insert([0], slicedNodes), + lineBreak: '\n', + ); + + // trim the last \n if it exists + return markdown.trimRight(); + } + + List getPlainTextInSelection(Selection? selection) { + selection ??= this.selection?.normalized; + if (selection == null || selection.isCollapsed) { + return []; + } + + final res = []; + if (selection.isCollapsed) { + return res; + } + + final nodes = getNodesInSelection(selection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + final startIndex = node == nodes.first ? selection.startIndex : 0; + final endIndex = node == nodes.last ? selection.endIndex : delta.length; + res.add(delta.slice(startIndex, endIndex).toPlainText()); + } + + return res; + } + + /// Determines whether the document is empty up to the selection + /// + /// If empty and the title is also empty, the continue writing option will be disabled. + bool isEmptyForContinueWriting({ + Selection? selection, + }) { + if (selection != null && !selection.isCollapsed) { + return false; + } + + final effectiveSelection = Selection( + start: Position(path: [0]), + end: selection?.normalized.end ?? + this.selection?.normalized.end ?? + Position(path: getLastSelectable()?.$1.path ?? [0]), + ); + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final nodes = getNodesInSelection(effectiveSelection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + + final slicedDelta = delta.slice( + node == nodes.first ? effectiveSelection.startIndex : 0, + node == nodes.last ? effectiveSelection.endIndex : delta.length, + ); + + final copiedNode = node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: slicedDelta.toJson(), + }, + ); + + slicedNodes.add(copiedNode); + } + + // using less custom parsers to avoid futures + final markdown = documentToMarkdown( + Document.blank()..insert([0], slicedNodes), + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + const CustomParagraphNodeParser(), + const SubPageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); + + return markdown.trim().isEmpty; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/ask_ai_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/ask_ai_node_extension.dart deleted file mode 100644 index 933712217f..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/ask_ai_node_extension.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:appflowy/shared/markdown_to_document.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -extension AskAINodeExtension on EditorState { - String getMarkdownInSelection(Selection? selection) { - selection ??= this.selection?.normalized; - if (selection == null || selection.isCollapsed) { - return ''; - } - - // if the selected nodes are not entirely selected, slice the nodes - final slicedNodes = []; - final nodes = getNodesInSelection(selection); - - for (final node in nodes) { - final delta = node.delta; - if (delta == null) { - continue; - } - - final slicedDelta = delta.slice( - node == nodes.first ? selection.startIndex : 0, - node == nodes.last ? selection.endIndex : delta.length, - ); - - final copiedNode = node.copyWith( - attributes: { - ...node.attributes, - blockComponentDelta: slicedDelta.toJson(), - }, - ); - - slicedNodes.add(copiedNode); - } - - final markdown = customDocumentToMarkdown( - Document.blank()..insert([0], slicedNodes), - ); - - return markdown; - } - - List getPlainTextInSelection(Selection? selection) { - selection ??= this.selection?.normalized; - if (selection == null || selection.isCollapsed) { - return []; - } - - final res = []; - if (selection.isCollapsed) { - return res; - } - - final nodes = getNodesInSelection(selection); - - for (final node in nodes) { - final delta = node.delta; - if (delta == null) { - continue; - } - final startIndex = node == nodes.first ? selection.startIndex : 0; - final endIndex = node == nodes.last ? selection.endIndex : delta.length; - res.add(delta.slice(startIndex, endIndex).toPlainText()); - } - - return res; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/learn_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/learn_more_action.dart deleted file mode 100644 index 17e89b1bca..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/util/learn_more_action.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; - -const String learnMoreUrl = - 'https://docs.appflowy.io/docs/appflowy/product/appflowy-x-openai'; - -Future openLearnMorePage() async { - await afLaunchUrlString(learnMoreUrl); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_limit_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_limit_dialog.dart deleted file mode 100644 index 3a57a7f49c..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_limit_dialog.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:easy_localization/easy_localization.dart'; - -void showAILimitDialog(BuildContext context, String message) { - showConfirmDialog( - context: context, - title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), - description: message, - ); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_operations.dart deleted file mode 100644 index 0c2fabfb50..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_operations.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -/// Notes: All the operation related to the AI writer block will be applied -/// in memory. -class AIWriterBlockOperations { - AIWriterBlockOperations({ - required this.editorState, - required this.aiWriterNode, - }) : assert(aiWriterNode.type == AIWriterBlockKeys.type); - - final EditorState editorState; - final Node aiWriterNode; - - /// Update the prompt text in the node. - Future updatePromptText(String prompt) async { - final transaction = editorState.transaction; - transaction.updateNode( - aiWriterNode, - {AIWriterBlockKeys.prompt: prompt}, - ); - await editorState.apply( - transaction, - options: const ApplyOptions( - inMemoryUpdate: true, - recordUndo: false, - ), - ); - } - - /// Update the generation count in the node. - Future updateGenerationCount(int count) async { - final transaction = editorState.transaction; - transaction.updateNode( - aiWriterNode, - {AIWriterBlockKeys.generationCount: count}, - ); - await editorState.apply( - transaction, - options: const ApplyOptions(inMemoryUpdate: true), - ); - } - - /// Ensure the previous node is a empty paragraph node without any styles. - Future ensurePreviousNodeIsEmptyParagraphNode() async { - final previous = aiWriterNode.previous; - final Selection selection; - - // 1. previous node is null or - // 2. previous node is not a paragraph node or - // 3. previous node is a paragraph node but not empty - final isNotEmptyParagraphNode = previous == null || - previous.type != ParagraphBlockKeys.type || - (previous.delta?.toPlainText().isNotEmpty ?? false); - - if (isNotEmptyParagraphNode) { - final path = aiWriterNode.path; - final transaction = editorState.transaction; - selection = Selection.collapsed(Position(path: path)); - transaction - ..insertNode( - path, - paragraphNode(), - ) - ..afterSelection = selection; - await editorState.apply(transaction); - } else { - selection = Selection.collapsed(Position(path: previous.path)); - } - - final transaction = editorState.transaction; - transaction.updateNode(aiWriterNode, { - AIWriterBlockKeys.startSelection: selection.toJson(), - }); - transaction.afterSelection = selection; - await editorState.apply( - transaction, - options: const ApplyOptions(inMemoryUpdate: true), - ); - } - - /// Discard the current response and delete the previous node. - Future discardCurrentResponse({ - required Node aiWriterNode, - Selection? selection, - }) async { - if (selection != null) { - final start = selection.start.path; - final end = aiWriterNode.previous?.path; - if (end != null) { - final transaction = editorState.transaction; - transaction.deleteNodesAtPath( - start, - end.last - start.last + 1, - ); - await editorState.apply(transaction); - await ensurePreviousNodeIsEmptyParagraphNode(); - } - } - } - - /// Remove the ai writer node from the editor. - Future removeAIWriterNode(Node aiWriterNode) async { - final transaction = editorState.transaction; - transaction.deleteNode(aiWriterNode); - await editorState.apply( - transaction, - options: const ApplyOptions(inMemoryUpdate: true, recordUndo: false), - withUpdateSelection: false, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_widgets.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_widgets.dart deleted file mode 100644 index b6681ed7fd..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_block_widgets.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -class AIWriterBlockHeader extends StatelessWidget { - const AIWriterBlockHeader({super.key}); - - @override - Widget build(BuildContext context) { - return FlowyText.medium( - LocaleKeys.document_plugins_autoGeneratorTitleName.tr(), - fontSize: 14, - ); - } -} - -class AIWriterBlockInputField extends StatelessWidget { - const AIWriterBlockInputField({ - super.key, - required this.onGenerate, - required this.onExit, - }); - - final VoidCallback onGenerate; - final VoidCallback onExit; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - PrimaryRoundedButton( - text: LocaleKeys.button_generate.tr(), - margin: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 10.0, - ), - radius: 8.0, - onTap: onGenerate, - ), - const Space(10, 0), - OutlinedRoundedButton( - text: LocaleKeys.button_cancel.tr(), - margin: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 10.0, - ), - onTap: onExit, - ), - Flexible( - child: Container( - alignment: Alignment.centerRight, - child: FlowyText.regular( - LocaleKeys.document_plugins_warning.tr(), - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - fontSize: 12, - ), - ), - ), - ], - ); - } -} - -class AIWriterBlockFooter extends StatelessWidget { - const AIWriterBlockFooter({ - super.key, - required this.onKeep, - required this.onRewrite, - required this.onDiscard, - }); - - final VoidCallback onKeep; - final VoidCallback onRewrite; - final VoidCallback onDiscard; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - PrimaryRoundedButton( - text: LocaleKeys.button_keep.tr(), - margin: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 9.0, - ), - onTap: onKeep, - ), - const HSpace(10), - OutlinedRoundedButton( - text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), - onTap: onRewrite, - ), - const HSpace(10), - OutlinedRoundedButton( - text: LocaleKeys.button_discard.tr(), - onTap: onDiscard, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart new file mode 100644 index 0000000000..8a691acdfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart @@ -0,0 +1,39 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class AiWriterGestureDetector extends StatelessWidget { + const AiWriterGestureDetector({ + super.key, + required this.behavior, + required this.onPointerEvent, + this.child, + }); + + final HitTestBehavior behavior; + final void Function() onPointerEvent; + final Widget? child; + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: behavior, + gestures: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (instance) => instance + ..onTapDown = ((_) => onPointerEvent()) + ..onSecondaryTapDown = ((_) => onPointerEvent()) + ..onTertiaryTapDown = ((_) => onPointerEvent()), + ), + ImmediateMultiDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + ImmediateMultiDragGestureRecognizer>( + () => ImmediateMultiDragGestureRecognizer(), + (instance) => instance.onStart = (offset) => null, + ), + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart new file mode 100644 index 0000000000..72b8d9560b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -0,0 +1,151 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +import '../operations/ai_writer_entities.dart'; + +class AiWriterPromptMoreButton extends StatelessWidget { + const AiWriterPromptMoreButton({ + super.key, + required this.isEnabled, + required this.isSelected, + required this.onTap, + }); + + final bool isEnabled; + final bool isSelected; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !isEnabled, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + isSelected: () => isSelected, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.ai_more.tr(), + fontSize: 12, + figmaLineHeight: 16, + color: isEnabled + ? Theme.of(context).hintColor + : Theme.of(context).disabledColor, + ), + const HSpace(2.0), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class MoreAiWriterCommands extends StatelessWidget { + const MoreAiWriterCommands({ + super.key, + required this.hasSelection, + required this.editorState, + required this.onSelectCommand, + }); + + final EditorState editorState; + final bool hasSelection; + final void Function(AiWriterCommand) onSelectCommand; + + @override + Widget build(BuildContext context) { + return Container( + // add one here to take into account the border of the main message box. + // It is configured to be on the outside to hide some graphical + // artifacts. + margin: EdgeInsets.only(top: 4.0 + 1.0), + padding: EdgeInsets.all(8.0), + constraints: BoxConstraints(minWidth: 240.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? ColorSchemeConstants.lightBorderColor + : ColorSchemeConstants.darkBorderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, + ), + child: IntrinsicWidth( + child: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: _getCommands( + hasSelection: hasSelection, + ), + ), + ), + ); + } + + List _getCommands({required bool hasSelection}) { + if (hasSelection) { + return [ + _bottomButton(AiWriterCommand.improveWriting), + _bottomButton(AiWriterCommand.fixSpellingAndGrammar), + _bottomButton(AiWriterCommand.explain), + const Divider(height: 1.0, thickness: 1.0), + _bottomButton(AiWriterCommand.makeLonger), + _bottomButton(AiWriterCommand.makeShorter), + ]; + } else { + return [ + _bottomButton(AiWriterCommand.continueWriting), + ]; + } + } + + Widget _bottomButton(AiWriterCommand command) { + return Builder( + builder: (context) { + return FlowyButton( + leftIcon: FlowySvg( + command.icon, + color: Theme.of(context).iconTheme.color, + ), + leftIconSize: const Size.square(20), + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: FlowyText( + command.i18n, + figmaLineHeight: 20, + ), + onTap: () => onSelectCommand(command), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart new file mode 100644 index 0000000000..ef8ee81219 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -0,0 +1,242 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/throttle.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../operations/ai_writer_cubit.dart'; +import 'ai_writer_gesture_detector.dart'; + +class AiWriterScrollWrapper extends StatefulWidget { + const AiWriterScrollWrapper({ + super.key, + required this.viewId, + required this.editorState, + required this.child, + }); + + final String viewId; + final EditorState editorState; + final Widget child; + + @override + State createState() => _AiWriterScrollWrapperState(); +} + +class _AiWriterScrollWrapperState extends State { + final overlayController = OverlayPortalController(); + late final throttler = Throttler(); + late final aiWriterCubit = AiWriterCubit( + documentId: widget.viewId, + editorState: widget.editorState, + onCreateNode: () { + aiWriterRegistered = true; + widget.editorState.service.keyboardService?.disableShortcuts(); + }, + onRemoveNode: () { + aiWriterRegistered = false; + widget.editorState.service.keyboardService?.enableShortcuts(); + widget.editorState.service.keyboardService?.enable(); + }, + onAppendToDocument: onAppendToDocument, + ); + + bool userHasScrolled = false; + bool aiWriterRegistered = false; + bool dialogShown = false; + + @override + void initState() { + super.initState(); + overlayController.show(); + } + + @override + void dispose() { + aiWriterCubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: aiWriterCubit, + child: NotificationListener( + onNotification: handleScrollNotification, + child: Focus( + autofocus: true, + onKeyEvent: handleKeyEvent, + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state is DocumentContentEmptyAiWriterState) { + showConfirmDialog( + context: context, + title: + LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), + description: LocaleKeys + .ai_continueWritingEmptyDocumentDescription + .tr(), + onConfirm: state.onConfirm, + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous is GeneratingAiWriterState && + current is ReadyAiWriterState, + listener: (context, state) { + widget.editorState.updateSelectionWithReason(null); + }, + ), + ], + child: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return BlocBuilder( + builder: (context, state) { + return AiWriterGestureDetector( + behavior: state is RegisteredAiWriter + ? HitTestBehavior.translucent + : HitTestBehavior.deferToChild, + onPointerEvent: () => onTapOutside(context), + ); + }, + ); + }, + child: widget.child, + ), + ), + ), + ), + ); + } + + bool handleScrollNotification(ScrollNotification notification) { + if (!aiWriterRegistered) { + return false; + } + + if (notification is UserScrollNotification) { + debounceResetUserHasScrolled(); + userHasScrolled = true; + throttler.cancel(); + } + + return false; + } + + void debounceResetUserHasScrolled() { + Debounce.debounce( + 'user_has_scrolled', + const Duration(seconds: 3), + () => userHasScrolled = false, + ); + } + + void onTapOutside(BuildContext context) { + final aiWriterCubit = context.read(); + + if (aiWriterCubit.hasUnusedResponse()) { + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: stopAndExit, + onCancel: () {}, + ); + } else { + stopAndExit(); + } + } + + KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + if (!aiWriterRegistered) { + return KeyEventResult.ignored; + } + if (dialogShown) { + return KeyEventResult.handled; + } + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + switch (event.logicalKey) { + case LogicalKeyboardKey.escape: + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } else if (aiWriterCubit.hasUnusedResponse()) { + dialogShown = true; + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: stopAndExit, + onCancel: () {}, + ).then((_) => dialogShown = false); + } else { + stopAndExit(); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.keyC + when HardwareKeyboard.instance.isControlPressed: + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } + return KeyEventResult.handled; + default: + break; + } + + return KeyEventResult.ignored; + } + + void onAppendToDocument() { + if (!aiWriterRegistered || userHasScrolled) { + return; + } + + throttler.call(() { + if (aiWriterCubit.aiWriterNode != null) { + final path = aiWriterCubit.aiWriterNode!.path; + + if (path.isEmpty) { + return; + } + + if (path.previous.isNotEmpty) { + final node = widget.editorState.getNodeAtPath(path.previous); + if (node != null && node.delta != null && node.delta!.isNotEmpty) { + widget.editorState.updateSelectionWithReason( + Selection.collapsed( + Position(path: path, offset: node.delta!.length), + ), + ); + return; + } + } + + widget.editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); + } + }); + } + + void stopAndExit() { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart new file mode 100644 index 0000000000..d39ede2608 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart @@ -0,0 +1,110 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../operations/ai_writer_entities.dart'; + +class SuggestionActionBar extends StatelessWidget { + const SuggestionActionBar({ + super.key, + required this.currentCommand, + required this.hasSelection, + required this.onTap, + }); + + final AiWriterCommand currentCommand; + final bool hasSelection; + final void Function(SuggestionAction) onTap; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4.0), + children: _getSuggestedActions() + .map( + (action) => SuggestionActionButton( + action: action, + onTap: () => onTap(action), + ), + ) + .toList(), + ); + } + + List _getSuggestedActions() { + if (hasSelection) { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + AiWriterCommand.fixSpellingAndGrammar || + AiWriterCommand.improveWriting || + AiWriterCommand.makeShorter || + AiWriterCommand.makeLonger => + [ + SuggestionAction.accept, + SuggestionAction.discard, + SuggestionAction.insertBelow, + SuggestionAction.rewrite, + ], + }; + } else { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + _ => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + }; + } + } +} + +class SuggestionActionButton extends StatelessWidget { + const SuggestionActionButton({ + super.key, + required this.action, + required this.onTap, + }); + + final SuggestionAction action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: FlowyButton( + text: FlowyText( + action.i18n, + figmaLineHeight: 20, + ), + leftIcon: action.buildIcon(context), + iconPadding: 4.0, + margin: const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 4.0, + ), + onTap: onTap, + useIntrinsicWidth: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action.dart deleted file mode 100644 index dfcec0318e..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:flutter/material.dart'; - -class AskAIActionWrapper extends ActionCell { - AskAIActionWrapper(this.inner); - - final AskAIAction inner; - - Widget? icon(Color iconColor) => null; - - @override - String get name => inner.name; -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart deleted file mode 100644 index 1fcd8d65ed..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/ai/service/ai_client.dart'; -import 'package:appflowy/ai/service/error.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/shared/markdown_to_document.dart'; -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'ask_ai_action_bloc.freezed.dart'; - -enum AskAIReplacementType { - markdown, - plainText, -} - -const _defaultReplacementType = AskAIReplacementType.markdown; - -class AskAIActionBloc extends Bloc { - AskAIActionBloc({ - required this.node, - required this.editorState, - required this.action, - required this.objectId, - this.enableLogging = true, - }) : super( - AskAIState.initial(action), - ) { - on((event, emit) async { - await event.when( - initial: (aiRepositoryProvider) async { - aiRepository = await aiRepositoryProvider; - aiRepositoryCompleter.complete(); - }, - started: () async { - await _requestCompletions(); - }, - rewrite: () async { - await _requestCompletions(rewrite: true); - }, - replace: () async { - await _replace(); - await _exit(); - }, - insertBelow: () async { - await _insertBelow(); - await _exit(); - }, - cancel: () async { - isCanceled = true; - await _exit(); - }, - update: (result, isLoading, aiError) { - emit( - state.copyWith( - result: result, - loading: isLoading, - requestError: aiError, - ), - ); - }, - ); - }); - } - - final Node node; - final EditorState editorState; - final AskAIAction action; - final bool enableLogging; - // used to wait for the aiRepository to be initialized - final aiRepositoryCompleter = Completer(); - late final AIRepository aiRepository; - final String objectId; - - bool isCanceled = false; - - Future _requestCompletions({ - bool rewrite = false, - }) async { - await aiRepositoryCompleter.future; - - if (rewrite) { - add(const AskAIEvent.update('', true, null)); - } - - if (enableLogging) { - Log.info('[smart_edit] request completions'); - } - - final content = node.attributes[AskAIBlockKeys.content] as String; - await aiRepository.streamCompletion( - objectId: objectId, - text: content, - completionType: completionTypeFromInt(state.action), - onStart: () async { - if (isCanceled) { - return; - } - if (enableLogging) { - Log.info('[smart_edit] start generating'); - } - add(const AskAIEvent.update('', true, null)); - }, - onProcess: (text) async { - if (isCanceled) { - return; - } - // only display the log in debug mode - if (enableLogging) { - Log.debug('[smart_edit] onProcess: $text'); - } - final newResult = state.result + text; - add(AskAIEvent.update(newResult, false, null)); - }, - onEnd: () async { - if (isCanceled) { - return; - } - if (enableLogging) { - Log.info('[smart_edit] end generating'); - } - add(AskAIEvent.update('${state.result}\n', false, null)); - }, - onError: (error) async { - if (isCanceled) { - return; - } - if (enableLogging) { - Log.info('[smart_edit] onError: $error'); - } - add(AskAIEvent.update('', false, error)); - await _exit(); - await _clearSelection(); - }, - ); - } - - Future _insertBelow() async { - // check the selection is not empty - final selection = editorState.selection?.normalized; - if (selection == null) { - return; - } - final nodes = customMarkdownToDocument(state.result) - .root - .children - .map((e) => e.deepCopy()) - .toList(); - final insertedPath = selection.end.path.next; - final transaction = editorState.transaction; - transaction.insertNodes( - insertedPath, - nodes, - ); - final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0; - transaction.afterSelection = Selection( - start: Position(path: insertedPath), - end: Position( - path: insertedPath.nextNPath(nodes.length - 1), - offset: lastDeltaLength, - ), - ); - await editorState.apply(transaction); - } - - Future _replace() async { - switch (_defaultReplacementType) { - case AskAIReplacementType.markdown: - await _replaceWithMarkdown(); - case AskAIReplacementType.plainText: - await _replaceWithPlainText(); - } - } - - Future _replaceWithMarkdown() async { - final selection = editorState.selection?.normalized; - if (selection == null) { - return; - } - - final nodes = customMarkdownToDocument(state.result) - .root - .children - .map((e) => e.deepCopy()) - .toList(); - if (nodes.isEmpty) { - return; - } - - final nodesInSelection = editorState.getNodesInSelection(selection); - final transaction = editorState.transaction; - transaction.insertNodes( - selection.start.path, - nodes, - ); - transaction.deleteNodes(nodesInSelection); - transaction.afterSelection = Selection( - start: selection.start, - end: Position( - path: selection.start.path.nextNPath(nodes.length - 1), - offset: nodes.lastOrNull?.delta?.length ?? 0, - ), - ); - await editorState.apply(transaction); - } - - Future _replaceWithPlainText() async { - final result = state.result.trim(); - if (result.isEmpty) { - return; - } - - final selection = editorState.selection?.normalized; - if (selection == null) { - return; - } - final nodes = editorState.getNodesInSelection(selection); - if (nodes.isEmpty || !nodes.every((element) => element.delta != null)) { - return; - } - - final replaceTexts = result.split('\n') - ..removeWhere((element) => element.isEmpty); - final transaction = editorState.transaction; - transaction.replaceTexts( - nodes, - selection, - replaceTexts, - ); - await editorState.apply(transaction); - - int endOffset = replaceTexts.last.length; - if (replaceTexts.length == 1) { - endOffset += selection.start.offset; - } - final end = Position( - path: [selection.start.path.first + replaceTexts.length - 1], - offset: endOffset, - ); - editorState.selection = Selection( - start: selection.start, - end: end, - ); - } - - Future _exit() async { - final transaction = editorState.transaction..deleteNode(node); - await editorState.apply( - transaction, - options: const ApplyOptions( - recordUndo: false, - ), - ); - } - - Future _clearSelection() async { - final selection = editorState.selection; - if (selection == null) { - return; - } - editorState.selection = null; - } -} - -@freezed -class AskAIEvent with _$AskAIEvent { - const factory AskAIEvent.initial( - Future aiRepositoryProvider, - ) = _Initial; - const factory AskAIEvent.started() = _Started; - const factory AskAIEvent.rewrite() = _Rewrite; - const factory AskAIEvent.replace() = _Replace; - const factory AskAIEvent.insertBelow() = _InsertBelow; - const factory AskAIEvent.cancel() = _Cancel; - const factory AskAIEvent.update( - String result, - bool isLoading, - AIError? error, - ) = _Update; -} - -@freezed -class AskAIState with _$AskAIState { - const factory AskAIState({ - required bool loading, - required String result, - required AskAIAction action, - @Default(null) AIError? requestError, - }) = _AskAIState; - - factory AskAIState.initial(AskAIAction action) => AskAIState( - loading: true, - action: action, - result: '', - ); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_block_widgets.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_block_widgets.dart deleted file mode 100644 index d28f416d1b..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_block_widgets.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class AskAIInputContent extends StatelessWidget { - const AskAIInputContent({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Card( - elevation: 5, - color: Theme.of(context).colorScheme.surface, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: Container( - margin: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.medium( - state.action.name, - fontSize: 14, - ), - const VSpace(16), - state.loading - ? _buildLoadingWidget(context) - : _buildResultWidget(context, state), - const VSpace(16), - const AskAIFooter(), - ], - ), - ), - ); - }, - ); - } - - Widget _buildResultWidget(BuildContext context, AskAIState state) { - return Flexible( - child: AIMarkdownText( - markdown: state.result, - ), - ); - } - - Widget _buildLoadingWidget(BuildContext context) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 4.0), - child: SizedBox.square( - dimension: 14, - child: CircularProgressIndicator( - strokeWidth: 2.0, - ), - ), - ); - } -} - -class AskAIFooter extends StatelessWidget { - const AskAIFooter({super.key}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - OutlinedRoundedButton( - text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), - onTap: () => - context.read().add(const AskAIEvent.rewrite()), - ), - const HSpace(10), - OutlinedRoundedButton( - text: LocaleKeys.button_replace.tr(), - onTap: () => - context.read().add(const AskAIEvent.replace()), - ), - const HSpace(10), - OutlinedRoundedButton( - text: LocaleKeys.button_insertBelow.tr(), - onTap: () => context - .read() - .add(const AskAIEvent.insertBelow()), - ), - const HSpace(10), - OutlinedRoundedButton( - text: LocaleKeys.button_cancel.tr(), - onTap: () => - context.read().add(const AskAIEvent.cancel()), - ), - Expanded( - child: Container( - alignment: Alignment.centerRight, - child: Text( - LocaleKeys.document_plugins_warning.tr(), - style: TextStyle(color: Theme.of(context).hintColor), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/barrier_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/barrier_dialog.dart deleted file mode 100644 index 69f453dcfc..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/barrier_dialog.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -class BarrierDialog { - BarrierDialog(this.context); - - late BuildContext loadingContext; - final BuildContext context; - - void show() => showDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.transparent, - builder: (context) { - loadingContext = context; - return const SizedBox.shrink(); - }, - ); - - void dismiss() => Navigator.of(loadingContext).pop(); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/discard_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/discard_dialog.dart deleted file mode 100644 index 4c0c2bc91a..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/discard_dialog.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; - -import 'package:flutter/material.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; - -import 'package:easy_localization/easy_localization.dart'; - -class DiscardDialog extends StatelessWidget { - const DiscardDialog({ - super.key, - required this.onConfirm, - required this.onCancel, - }); - - final VoidCallback onConfirm; - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) { - return NavigatorOkCancelDialog( - message: LocaleKeys.document_plugins_discardResponse.tr(), - okTitle: LocaleKeys.button_discard.tr(), - cancelTitle: LocaleKeys.button_cancel.tr(), - onOkPressed: onConfirm, - onCancelPressed: onCancel, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart index 09bdc06057..cceac56c0d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart @@ -106,7 +106,7 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> { child: FlowyButton( useIntrinsicWidth: true, text: widget.child, - hoverColor: Colors.grey.withOpacity(0.3), + hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: () => controller.show(), ), ); @@ -167,7 +167,7 @@ class _AlignButton extends StatelessWidget { Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, - hoverColor: Colors.grey.withOpacity(0.3), + hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: onTap, text: FlowyTooltip( message: tooltips, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart index 9fe78591c3..bc3f5cffa1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; final List customTextAlignCommands = [ customTextLeftAlignCommand, @@ -26,8 +25,8 @@ final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( handler: (editorState) => _textAlignHandler(editorState, leftAlignmentKey), ); -/// Windows / Linux : ctrl + shift + e -/// macOS : ctrl + shift + e +/// Windows / Linux : ctrl + shift + c +/// macOS : ctrl + shift + c /// Allows the user to align text to the center /// /// - support @@ -36,7 +35,7 @@ final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( /// final CommandShortcutEvent customTextCenterAlignCommand = CommandShortcutEvent( key: 'Align text to the center', - command: 'ctrl+shift+e', + command: 'ctrl+shift+c', getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignCenter.tr, handler: (editorState) => _textAlignHandler(editorState, centerAlignmentKey), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index 9f25e4745d..090ecdce78 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -1,18 +1,13 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class BuiltInPageWidget extends StatefulWidget { const BuiltInPageWidget({ @@ -79,18 +74,14 @@ class _BuiltInPageWidgetState extends State { return MouseRegion( onEnter: (_) => widget.editorState.service.scrollService?.disable(), onExit: (_) => widget.editorState.service.scrollService?.enable(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMenu(context, viewPB), - Flexible(child: _buildPage(context, viewPB)), - ], - ), + child: _buildPage(context, viewPB), ); } Widget _buildPage(BuildContext context, ViewPB view) { + final verticalPadding = + context.read()?.verticalPadding ?? + 0.0; return Focus( focusNode: focusNode, onFocusChange: (value) { @@ -98,64 +89,10 @@ class _BuiltInPageWidgetState extends State { widget.editorState.service.selectionService.clearSelection(); } }, - child: widget.builder(view), - ); - } - - Widget _buildMenu(BuildContext context, ViewPB view) { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // information - FlowyIconButton( - tooltipText: LocaleKeys.tooltip_referencePage.tr( - namedArgs: {'name': view.layout.name}, - ), - width: 24, - height: 24, - iconPadding: const EdgeInsets.all(3), - icon: const FlowySvg( - FlowySvgs.information_s, - ), - ), - // setting - const Space(7, 0), - PopoverActionList<_ActionWrapper>( - direction: PopoverDirection.bottomWithCenterAligned, - actions: _ActionType.values - .map((action) => _ActionWrapper(action)) - .toList(), - buildChild: (controller) => FlowyIconButton( - tooltipText: LocaleKeys.tooltip_openMenu.tr(), - width: 24, - height: 24, - iconPadding: const EdgeInsets.all(3), - icon: const FlowySvg( - FlowySvgs.settings_s, - ), - onPressed: () => controller.show(), - ), - onSelected: (action, controller) async { - switch (action.inner) { - case _ActionType.viewDatabase: - getIt().add( - TabsEvent.openPlugin( - plugin: view.plugin(), - view: view, - ), - ); - break; - case _ActionType.delete: - final transaction = widget.editorState.transaction; - transaction.deleteNode(widget.node); - await widget.editorState.apply(transaction); - break; - } - controller.close(); - }, - ), - ], + child: Padding( + padding: EdgeInsets.symmetric(vertical: verticalPadding), + child: widget.builder(view), + ), ); } @@ -165,23 +102,3 @@ class _BuiltInPageWidgetState extends State { await widget.editorState.apply(transaction); } } - -enum _ActionType { viewDatabase, delete } - -class _ActionWrapper extends ActionCell { - _ActionWrapper(this.inner); - - final _ActionType inner; - - Widget? icon(Color iconColor) => null; - - @override - String get name { - switch (inner) { - case _ActionType.viewDatabase: - return LocaleKeys.tooltip_viewDataBase.tr(); - case _ActionType.delete: - return LocaleKeys.disclosureAction_delete.tr(); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart index e25710c137..93b45cf46a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart @@ -208,7 +208,7 @@ class _MobileEmojiPickerButton extends StatelessWidget { MobileEmojiPickerScreen.iconSelectedType: emoji.type.name, MobileEmojiPickerScreen.uploadDocumentId: documentId, MobileEmojiPickerScreen.selectTabs: - tabs.map((e) => e.name).toList(), + tabs.map((e) => e.name).toList().join('-'), }, ).toString(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart index 379c7c61ba..8548b9354c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart @@ -7,6 +7,9 @@ const _equals = '='; const _equalGreater = '⇒'; const _dashGreater = '→'; +const _hyphen = '-'; +const _emDash = '—'; // This is an em dash — not a single dash - !! + /// format '=' + '>' into an ⇒ /// /// - support @@ -43,6 +46,24 @@ final CharacterShortcutEvent customFormatDashGreater = CharacterShortcutEvent( ), ); +/// format two hyphens into an em dash +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent customFormatDoubleHyphenEmDash = + CharacterShortcutEvent( + key: 'format double hyphen into an em dash', + character: _hyphen, + handler: (editorState) async => _handleDoubleCharacterReplacement( + editorState: editorState, + character: _hyphen, + replacement: _emDash, + ), +); + /// If [prefixCharacter] is null or empty, [character] is used Future _handleDoubleCharacterReplacement({ required EditorState editorState, @@ -81,11 +102,29 @@ Future _handleDoubleCharacterReplacement({ return false; } + // insert the greater character first and convert it to the replacement character to support undo + final insert = editorState.transaction + ..insertText( + node, + selection.end.offset, + character, + ); + + await editorState.apply( + insert, + skipHistoryDebounce: true, + ); + + final afterSelection = editorState.selection; + if (afterSelection == null) { + return false; + } + final replace = editorState.transaction ..replaceText( node, - selection.end.offset - 1, - 1, + afterSelection.end.offset - 2, + 2, replacement, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart index af605972de..11aed036d2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart @@ -70,13 +70,12 @@ extension InsertDatabase on EditorState { node, selection.end.offset, 0, - r'$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart index ce4f44e72b..3f1440e100 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; InlineActionsMenuService? _actionsMenuService; + Future showLinkToPageMenu( EditorState editorState, SelectionMenuService menuService, { @@ -60,7 +61,7 @@ Future showLinkToPageMenu( startCharAmount: 0, ); - _actionsMenuService?.show(); + await _actionsMenuService?.show(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 0cc9a50e5c..259777db94 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -1,137 +1,566 @@ import 'dart:convert'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; import 'package:synchronized/synchronized.dart'; +const _enableDebug = false; + class MarkdownTextRobot { MarkdownTextRobot({ required this.editorState, - this.enableDebug = true, }); final EditorState editorState; - final bool enableDebug; - final Lock lock = Lock(); + final Lock _lock = Lock(); - // The selection before the text robot is ready. - Selection? _startSelection; + /// The text position where new nodes will be inserted + Position? _insertPosition; - // The markdown text to be inserted. + /// The markdown text to be inserted String _markdownText = ''; - // Only for debug. Enable by [enableDebug]. - @visibleForTesting - final List debugMarkdownTexts = []; + /// The nodes inserted in the previous refresh. + Iterable _insertedNodes = []; - // The nodes inserted in the previous refresh. - Iterable _previousInsertedNodes = []; + /// Only for debug via [_enableDebug]. + final List _debugMarkdownTexts = []; - /// Start the text robot. - /// - /// Must call this function before using the text robot. - void start() { - _startSelection = editorState.selection; + /// Selection before the refresh. + Selection? _previousSelection; - if (enableDebug) { + bool get hasAnyResult => _markdownText.isNotEmpty; + + String get markdownText => _markdownText; + + Selection? getInsertedSelection() { + final position = _insertPosition; + if (position == null) { + Log.error("Expected non-null insert markdown text position"); + return null; + } + + if (_insertedNodes.isEmpty) { + return Selection.collapsed(position); + } + return Selection( + start: position, + end: Position( + path: position.path.nextNPath(_insertedNodes.length - 1), + ), + ); + } + + List getInsertedNodes() { + final selection = getInsertedSelection(); + return selection == null ? [] : editorState.getNodesInSelection(selection); + } + + void start({ + Selection? previousSelection, + Position? position, + }) { + _insertPosition = position ?? editorState.selection?.start; + _previousSelection = previousSelection ?? editorState.selection; + + if (_enableDebug) { Log.info( - 'MarkdownTextRobot prepare, current selection: $_startSelection', + 'MarkdownTextRobot start with insert text position: $_insertPosition', ); } } - /// Append the markdown text to the text robot. - /// - /// The text will be inserted into document but not persisted until the text - /// robot is stopped. - Future appendMarkdownText(String text) async { + /// The text will be inserted into the document but only in memory + Future appendMarkdownText( + String text, { + bool updateSelection = true, + Map? attributes, + }) async { _markdownText += text; - await lock.synchronized(() async { - await _refresh(); - }); - - if (enableDebug) { - debugMarkdownTexts.add(text); - Log.info('debug markdown texts: ${jsonEncode(debugMarkdownTexts)}'); - } - } - - /// Stop the text robot. - /// - /// The text will be persisted into document. - Future stop() async { - // persist the markdown text - await lock.synchronized(() async { - await _refresh(inMemoryUpdate: false); - }); - - _markdownText = ''; - - if (enableDebug) { - Log.info( - 'debug markdown texts: ${jsonEncode(debugMarkdownTexts)}', + await _lock.synchronized(() async { + await _refresh( + inMemoryUpdate: true, + updateSelection: updateSelection, + attributes: attributes, + ); + }); + + if (_enableDebug) { + _debugMarkdownTexts.add(text); + Log.info( + 'MarkdownTextRobot receive markdown: ${jsonEncode(_debugMarkdownTexts)}', ); - debugMarkdownTexts.clear(); } } - /// Refreshes the editor state with the current markdown text by: - /// - /// 1. Converting markdown to document nodes - /// 2. Replacing previously inserted nodes with new nodes - /// 3. Updating selection position - Future _refresh({bool inMemoryUpdate = true}) async { - final start = _startSelection?.start; + Future stop({ + Map? attributes, + }) async { + await _lock.synchronized(() async { + await _refresh( + inMemoryUpdate: true, + attributes: attributes, + ); + }); + } + + /// Persist the text into the document + Future persist({ + String? markdownText, + }) async { + if (markdownText != null) { + _markdownText = markdownText; + } + + await _lock.synchronized(() async { + await _refresh(inMemoryUpdate: false, updateSelection: true); + }); + + if (_enableDebug) { + Log.info('MarkdownTextRobot stop'); + _debugMarkdownTexts.clear(); + } + } + + /// Replace the selected content with the AI's response + Future replace({ + required Selection selection, + required String markdownText, + }) async { + if (selection.isSingle) { + await _replaceInSameLine( + selection: selection, + markdownText: markdownText, + ); + } else { + await _replaceInMultiLines( + selection: selection, + markdownText: markdownText, + ); + } + } + + /// Delete the temporary inserted AI nodes + Future deleteAINodes() async { + final nodes = getInsertedNodes(); + final transaction = editorState.transaction..deleteNodes(nodes); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + } + + /// Discard the inserted content + Future discard({ + Selection? afterSelection, + }) async { + final start = _insertPosition; if (start == null) { return; } - - final transaction = editorState.transaction; - - // Convert markdown and deep copy nodes - final nodes = customMarkdownToDocument(_markdownText).root.children.map( - (node) => node.deepCopy(), - ); // deep copy the nodes to avoid the linked entities being changed. - - // Insert new nodes at selection start - transaction.insertNodes(start.path, nodes); - - // Remove previously inserted nodes if they exist - if (_previousInsertedNodes.isNotEmpty) { - // fallback to the calculated position if the selection is null. - final end = editorState.selection?.end ?? - Position( - path: start.path.nextNPath(_previousInsertedNodes.length - 1), - ); - final deletedNodes = editorState.getNodesInSelection( - Selection(start: start, end: end), - ); - transaction.deleteNodes(deletedNodes); + if (_insertedNodes.isEmpty) { + return; } - // Update selection to end of inserted content if it contains text - final lastDelta = nodes.lastOrNull?.delta; - if (lastDelta != null) { - transaction.afterSelection = Selection.collapsed( - Position( - path: start.path.nextNPath(nodes.length - 1), - offset: lastDelta.length, - ), - ); - } + afterSelection ??= Selection.collapsed(start); + + // fallback to the calculated position if the selection is null. + final end = Position( + path: start.path.nextNPath(_insertedNodes.length - 1), + ); + final deletedNodes = editorState.getNodesInSelection( + Selection(start: start, end: end), + ); + final transaction = editorState.transaction + ..deleteNodes(deletedNodes) + ..afterSelection = afterSelection; await editorState.apply( transaction, + options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true), + ); + + if (_enableDebug) { + Log.info('MarkdownTextRobot discard'); + } + } + + void clear() { + _markdownText = ''; + _insertedNodes = []; + } + + void reset() { + _insertPosition = null; + } + + Future _refresh({ + required bool inMemoryUpdate, + bool updateSelection = false, + Map? attributes, + }) async { + final position = _insertPosition; + if (position == null) { + Log.error("Expected non-null insert markdown text position"); + return; + } + + // Convert markdown and deep copy the nodes, prevent ing the linked + // entities from being changed + final documentNodes = customMarkdownToDocument( + _markdownText, + tableWidth: 250.0, + ).root.children; + + // check if the first selected node before the refresh is a numbered list node + final previousSelection = _previousSelection; + final previousSelectedNode = previousSelection == null + ? null + : editorState.getNodeAtPath(previousSelection.start.path); + final firstNodeIsNumberedList = previousSelectedNode != null && + previousSelectedNode.type == NumberedListBlockKeys.type; + + final newNodes = attributes == null + ? documentNodes + : documentNodes.mapIndexed((index, node) { + final n = _styleDelta(node: node, attributes: attributes); + n.externalValues = AINodeExternalValues( + isAINode: true, + ); + if (index == 0 && n.type == NumberedListBlockKeys.type) { + if (firstNodeIsNumberedList) { + final builder = NumberedListIndexBuilder( + editorState: editorState, + node: previousSelectedNode, + ); + final firstIndex = builder.indexInSameLevel; + n.updateAttributes({ + NumberedListBlockKeys.number: firstIndex, + }); + } + + n.externalValues = AINodeExternalValues( + isAINode: true, + isFirstNumberedListNode: true, + ); + } + return n; + }).toList(); + + if (newNodes.isEmpty) { + return; + } + + final deleteTransaction = editorState.transaction + ..deleteNodes(getInsertedNodes()); + + await editorState.apply( + deleteTransaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, recordUndo: false, ), ); - _previousInsertedNodes = nodes; + final insertTransaction = editorState.transaction + ..insertNodes(position.path, newNodes); + + final lastDelta = newNodes.lastOrNull?.delta; + if (lastDelta != null) { + insertTransaction.afterSelection = Selection.collapsed( + Position( + path: position.path.nextNPath(newNodes.length - 1), + offset: lastDelta.length, + ), + ); + } + + await editorState.apply( + insertTransaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + recordUndo: !inMemoryUpdate, + ), + withUpdateSelection: updateSelection, + ); + + _insertedNodes = newNodes; + } + + Node _styleDelta({ + required Node node, + required Map attributes, + }) { + if (node.delta != null) { + final delta = node.delta!; + final attributeDelta = Delta() + ..retain(delta.length, attributes: attributes); + final newDelta = delta.compose(attributeDelta); + final newAttributes = node.attributes; + newAttributes['delta'] = newDelta.toJson(); + node.updateAttributes(newAttributes); + } + + List? children; + if (node.children.isNotEmpty) { + children = node.children + .map((child) => _styleDelta(node: child, attributes: attributes)) + .toList(); + } + + return node.copyWith( + children: children, + ); + } + + /// If the selected content is in the same line, + /// keep the selected node and replace the delta. + Future _replaceInSameLine({ + required Selection selection, + required String markdownText, + }) async { + if (markdownText.isEmpty) { + assert(false, 'Expected non-empty markdown text'); + Log.error('Expected non-empty markdown text'); + return; + } + + selection = selection.normalized; + + // If the selection is not a single node, do nothing. + if (!selection.isSingle) { + assert(false, 'Expected single node selection'); + Log.error('Expected single node selection'); + return; + } + + final startIndex = selection.startIndex; + final endIndex = selection.endIndex; + final length = endIndex - startIndex; + + // Get the selected node. + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + assert(false, 'Expected non-null node and delta'); + Log.error('Expected non-null node and delta'); + return; + } + + // Convert the markdown text to delta. + // Question: Why we need to convert the markdown to document first? + // Answer: Because the markdown text may contain the list item, + // if we convert the markdown to delta directly, the list item will be + // treated as a normal text node, and the delta will be incorrect. + // For example, the markdown text is: + // ``` + // 1. item1 + // ``` + // if we convert the markdown to delta directly, the delta will be: + // ``` + // [ + // { + // "insert": "1. item1" + // } + // ] + // ``` + // if we convert the markdown to document first, the document will be: + // ``` + // [ + // { + // "type": "numbered_list", + // "children": [ + // { + // "insert": "item1" + // } + // ] + // } + // ] + final document = customMarkdownToDocument(markdownText); + final nodes = document.root.children; + final decoder = DeltaMarkdownDecoder(); + final markdownDelta = + nodes.firstOrNull?.delta ?? decoder.convert(markdownText); + + if (markdownDelta.isEmpty) { + assert(false, 'Expected non-empty markdown delta'); + Log.error('Expected non-empty markdown delta'); + return; + } + + // Replace the delta of the selected node. + final transaction = editorState.transaction; + + // it means the user selected the entire sentence, we just replace the node + if (startIndex == 0 && length == node.delta?.length) { + if (nodes.isNotEmpty && node.children.isNotEmpty) { + // merge the children of the selected node and the first node of the ai response + nodes[0] = nodes[0].copyWith( + children: [ + ...node.children.map((e) => e.deepCopy()), + ...nodes[0].children, + ], + ); + } + transaction + ..insertNodes(node.path.next, nodes) + ..deleteNode(node); + } else { + // it means the user selected a part of the sentence, we need to delete the + // selected part and insert the new delta. + transaction + ..deleteText(node, startIndex, length) + ..insertTextDelta(node, startIndex, markdownDelta); + + // Add the remaining nodes to the document. + final remainingNodes = nodes.skip(1); + if (remainingNodes.isNotEmpty) { + transaction.insertNodes( + node.path.next, + remainingNodes, + ); + } + } + + await editorState.apply(transaction); + } + + /// If the selected content is in multiple lines + Future _replaceInMultiLines({ + required Selection selection, + required String markdownText, + }) async { + selection = selection.normalized; + + // If the selection is a single node, do nothing. + if (selection.isSingle) { + assert(false, 'Expected multi-line selection'); + Log.error('Expected multi-line selection'); + return; + } + + final markdownNodes = customMarkdownToDocument( + markdownText, + tableWidth: 250.0, + ).root.children; + + // Get the selected nodes. + final flattenNodes = editorState.getNodesInSelection(selection); + final nodes = []; + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } + + // Note: Don't change its order, otherwise the delta will be incorrect. + // step 1. merge the first selected node and the first node from the ai response + // step 2. merge the last selected node and the last node from the ai response + // step 3. insert the middle nodes from the ai response + // step 4. delete the middle nodes + final transaction = editorState.transaction; + + // step 1 + final firstNode = nodes.firstOrNull; + final delta = firstNode?.delta; + final firstMarkdownNode = markdownNodes.firstOrNull; + final firstMarkdownDelta = firstMarkdownNode?.delta; + if (firstNode != null && + delta != null && + firstMarkdownNode != null && + firstMarkdownDelta != null) { + final startIndex = selection.startIndex; + final length = delta.length - startIndex; + + transaction + ..deleteText(firstNode, startIndex, length) + ..insertTextDelta(firstNode, startIndex, firstMarkdownDelta); + + // if the first markdown node has children, we need to insert the children + // and delete the children of the first node that are in the selection. + if (firstMarkdownNode.children.isNotEmpty) { + transaction.insertNodes( + firstNode.path.child(0), + firstMarkdownNode.children.map((e) => e.deepCopy()), + ); + } + + final nodesToDelete = + firstNode.children.where((e) => e.path.inSelection(selection)); + transaction.deleteNodes(nodesToDelete); + } + + // step 2 + bool handledLastNode = false; + final lastNode = nodes.lastOrNull; + final lastDelta = lastNode?.delta; + final lastMarkdownNode = markdownNodes.lastOrNull; + final lastMarkdownDelta = lastMarkdownNode?.delta; + if (lastNode != null && + lastDelta != null && + lastMarkdownNode != null && + lastMarkdownDelta != null && + firstNode?.id != lastNode.id) { + handledLastNode = true; + + final endIndex = selection.endIndex; + + transaction.deleteText(lastNode, 0, endIndex); + + // if the last node is same as the first node, it means we have replaced the + // selected text in the first node. + if (lastMarkdownNode.id != firstMarkdownNode?.id) { + transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta); + + if (lastMarkdownNode.children.isNotEmpty) { + transaction + ..insertNodes( + lastNode.path.child(0), + lastMarkdownNode.children.map((e) => e.deepCopy()), + ) + ..deleteNodes( + lastNode.children.where((e) => e.path.inSelection(selection)), + ); + } + } + } + + // step 3 + final insertedPath = selection.start.path.nextNPath(1); + final insertLength = handledLastNode ? 2 : 1; + if (markdownNodes.length > insertLength) { + transaction.insertNodes( + insertedPath, + markdownNodes + .skip(1) + .take(markdownNodes.length - insertLength) + .toList(), + ); + } + + // step 4 + final length = nodes.length - 2; + if (length > 0) { + final middleNodes = nodes.skip(1).take(length).toList(); + transaction.deleteNodes(middleNodes); + } + + await editorState.apply(transaction); } } + +class AINodeExternalValues extends NodeExternalValues { + const AINodeExternalValues({ + this.isAINode = false, + this.isFirstNumberedListNode = false, + }); + + final bool isAINode; + final bool isFirstNumberedListNode; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart index 079a062837..007e4ea298 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; @@ -47,6 +48,7 @@ CharacterShortcutEvent pageReferenceShortcutPlusSign( ); InlineActionsMenuService? selectionMenuService; + Future inlinePageReferenceCommandHandler( String character, BuildContext context, @@ -56,7 +58,7 @@ Future inlinePageReferenceCommandHandler( String? previousChar, }) async { final selection = editorState.selection; - if (UniversalPlatform.isMobile || selection == null) { + if (selection == null) { return false; } @@ -110,32 +112,48 @@ Future inlinePageReferenceCommandHandler( } if (context.mounted) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - startCharAmount: previousChar != null ? 2 : 1, - cancelBySpaceHandler: () { - if (character == _plusChar) { - final currentSelection = editorState.selection; - if (currentSelection == null) { - return false; - } - // check if the space is after the character - if (currentSelection.isCollapsed && - currentSelection.start.offset == - selection.start.offset + character.length) { - _cancelInlinePageReferenceMenu(editorState); - return true; - } - } - return false; - }, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + startCharAmount: previousChar != null ? 2 : 1, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + startCharAmount: previousChar != null ? 2 : 1, + cancelBySpaceHandler: () { + if (character == _plusChar) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + return false; + } + // check if the space is after the character + if (currentSelection.isCollapsed && + currentSelection.start.offset == + selection.start.offset + character.length) { + _cancelInlinePageReferenceMenu(editorState); + return true; + } + } + return false; + }, + ); + // disable the keyboard service + editorState.service.keyboardService?.disable(); - selectionMenuService?.show(); + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } return true; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart index 0b6382fc53..3c997bbdc4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart @@ -1,9 +1,9 @@ import 'dart:math'; -import 'package:flutter/material.dart'; - +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:flutter/material.dart'; class SelectableItemListMenu extends StatelessWidget { const SelectableItemListMenu({ diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart index ebbfb27db6..77245a9f95 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart @@ -1,5 +1,14 @@ +import 'dart:ui'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +bool _isTableType(String type) { + return [TableBlockKeys.type, SimpleTableBlockKeys.type].contains(type); +} + bool notShowInTable(EditorState editorState) { final selection = editorState.selection; if (selection == null) { @@ -7,12 +16,12 @@ bool notShowInTable(EditorState editorState) { } final nodes = editorState.getNodesInSelection(selection); return nodes.every((element) { - if (element.type == TableBlockKeys.type) { + if (_isTableType(element.type)) { return false; } var parent = element.parent; while (parent != null) { - if (parent.type == TableBlockKeys.type) { + if (_isTableType(parent.type)) { return false; } parent = parent.parent; @@ -27,3 +36,31 @@ bool onlyShowInSingleTextTypeSelectionAndExcludeTable( return onlyShowInSingleSelectionAndTextType(editorState) && notShowInTable(editorState); } + +bool enableSuggestions(EditorState editorState) { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return false; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return false; + } + if (isNarrowWindow(editorState)) return false; + + return (node.delta != null && suggestionsItemTypes.contains(node.type)) && + notShowInTable(editorState); +} + +bool isNarrowWindow(EditorState editorState) { + final editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize.width < 650) return true; + return false; +} + +final Set suggestionsItemTypes = { + ...toolbarItemWhiteList, + ToggleListBlockKeys.type, + TodoListBlockKeys.type, + CalloutBlockKeys.type, +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart index 43b84ddc1c..23b73e75a9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart @@ -42,10 +42,8 @@ class BulletedListIcon extends StatelessWidget { size: Size.square(size * 0.8), ); return Container( - constraints: BoxConstraints( - minWidth: size, - minHeight: size, - ), + width: size, + height: size, margin: const EdgeInsets.only(right: 8.0), alignment: Alignment.center, child: icon, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart index c81c2f3be6..a7fcccd186 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart @@ -1,7 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; -import 'package:appflowy_backend/log.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; @@ -80,7 +81,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { }); final Color defaultColor; - final EdgeInsets inlinePadding; + final EdgeInsets Function(Node node) inlinePadding; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { @@ -96,12 +97,15 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - BlockComponentValidate get validate => - (node) => node.delta != null && node.children.isEmpty; + BlockComponentValidate get validate => (node) => node.delta != null; } // the main widget for rendering the callout block @@ -111,13 +115,14 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.defaultColor, required this.inlinePadding, }); final Color defaultColor; - final EdgeInsets inlinePadding; + final EdgeInsets Function(Node node) inlinePadding; @override State createState() => @@ -132,7 +137,8 @@ class _CalloutBlockComponentWidgetState BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentAlignMixin, - BlockComponentBackgroundColorMixin { + BlockComponentBackgroundColorMixin, + NestedBlockComponentStatefulWidgetMixin { // the key used to forward focus to the richtext child @override final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); @@ -169,57 +175,110 @@ class _CalloutBlockComponentWidgetState EmojiIconData result = EmojiIconData.emoji('📌'); try { result = EmojiIconData(FlowyIconType.values.byName(type), icon); - } catch (e) { - Log.error( - 'get emoji error with icon:[$icon], type:[$type] within alloutBlockComponentWidget', - e, - ); - } + } catch (_) {} return result; } - // get access to the editor state via provider @override - late final editorState = Provider.of(context, listen: false); + Widget build(BuildContext context) { + Widget child = node.children.isEmpty + ? buildComponent(context) + : buildComponentWithChildren(context); + + if (UniversalPlatform.isDesktop) { + child = Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + child: child, + ); + } + + return child; + } + + @override + Widget buildComponentWithChildren(BuildContext context) { + Widget child = Stack( + children: [ + Positioned.fill( + left: UniversalPlatform.isMobile ? 0 : cachedLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + color: backgroundColor, + ), + ), + ), + NestedListWidget( + indentPadding: indentPadding.copyWith(bottom: 8), + child: buildComponent(context, withBackgroundColor: false), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ], + ); + + if (UniversalPlatform.isMobile) { + child = Padding( + padding: padding, + child: child, + ); + } + + return child; + } // build the callout block widget @override - Widget build(BuildContext context) { + Widget buildComponent( + BuildContext context, { + bool withBackgroundColor = true, + }) { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); final (emojiSize, emojiButtonSize) = calculateEmojiSize(); - + final documentId = context.read()?.documentId; Widget child = Container( decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + color: withBackgroundColor ? backgroundColor : null, ), - padding: widget.inlinePadding, + padding: widget.inlinePadding(widget.node), width: double.infinity, alignment: alignment, child: Row( - crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, textDirection: textDirection, children: [ - if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0), + const HSpace(6.0), // the emoji picker button for the note EmojiPickerButton( // force to refresh the popover state key: ValueKey(widget.node.id + emoji.emoji), enable: editorState.editable, title: '', + margin: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0) + : EdgeInsets.zero, emoji: emoji, emojiSize: emojiSize, showBorder: false, buttonSize: emojiButtonSize, + documentId: documentId, + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], onSubmitted: (r, controller) { setEmojiIconData(r.data); if (!r.keepOpen) controller?.close(); }, ), - if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0), + if (UniversalPlatform.isDesktopOrWeb) const HSpace(6.0), Flexible( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), @@ -231,17 +290,26 @@ class _CalloutBlockComponentWidgetState ), ); - child = Padding( - key: blockComponentKey, - padding: padding, - child: child, - ); + if (UniversalPlatform.isMobile && node.children.isEmpty) { + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + } else { + child = Container( + key: blockComponentKey, + padding: EdgeInsets.zero, + child: child, + ); + } child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, supportTypes: const [ BlockSelectionType.block, ], @@ -252,6 +320,7 @@ class _CalloutBlockComponentWidgetState child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart index 3c50661071..842f3f59fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart @@ -32,9 +32,22 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { await editorState.deleteSelection(selection); if (HardwareKeyboard.instance.isShiftPressed) { - await editorState.insertNewLine(); - } else { - await editorState.insertTextAtCurrentSelection('\n'); + // ignore the shift+enter event, fallback to the default behavior + return false; + } else if (node.children.isEmpty) { + // insert a new paragraph within the callout block + final path = node.path.child(0); + final transaction = editorState.transaction; + transaction.insertNode( + path, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + await editorState.apply(transaction); } return true; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart index cc80119cb9..645de3b2f8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart @@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget { if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart index 14b19a6927..c4c2e3e0ba 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart @@ -3,6 +3,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -10,7 +12,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:universal_platform/universal_platform.dart'; CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart new file mode 100644 index 0000000000..c426ad640f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart @@ -0,0 +1,221 @@ +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +Node simpleColumnNode({ + List? children, + double? ratio, +}) { + return Node( + type: SimpleColumnBlockKeys.type, + children: children ?? [paragraphNode()], + attributes: { + SimpleColumnBlockKeys.ratio: ratio, + }, + ); +} + +extension SimpleColumnBlockAttributes on Node { + // get the next column node of the current column node + // if the current column node is the last column node, return null + Node? get nextColumn { + final index = path.last; + final parent = this.parent; + if (parent == null || index == parent.children.length - 1) { + return null; + } + return parent.children[index + 1]; + } + + // get the previous column node of the current column node + // if the current column node is the first column node, return null + Node? get previousColumn { + final index = path.last; + final parent = this.parent; + if (parent == null || index == 0) { + return null; + } + return parent.children[index - 1]; + } +} + +class SimpleColumnBlockKeys { + const SimpleColumnBlockKeys._(); + + static const String type = 'simple_column'; + + /// @Deprecated Use [SimpleColumnBlockKeys.ratio] instead. + /// + /// This field is no longer used since v0.6.9 + @Deprecated('Use [SimpleColumnBlockKeys.ratio] instead.') + static const String width = 'width'; + + /// The ratio of the column width. + /// + /// The value is a double number between 0 and 1. + static const String ratio = 'ratio'; +} + +class SimpleColumnBlockComponentBuilder extends BlockComponentBuilder { + SimpleColumnBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleColumnBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isNotEmpty; +} + +class SimpleColumnBlockComponent extends BlockComponentStatefulWidget { + const SimpleColumnBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + SimpleColumnBlockComponentState(); +} + +class SimpleColumnBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + final columnKey = GlobalKey(); + + late final EditorState editorState = context.read(); + + @override + Widget build(BuildContext context) { + Widget child = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children.map( + (e) { + Widget child = Provider( + create: (_) => DatabasePluginWidgetBuilderSize( + verticalPadding: 0, + horizontalPadding: 0, + ), + child: editorState.renderer.build(context, e), + ); + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.blue, + ), + ), + child: child, + ); + } + return child; + }, + ).toList(), + ); + + child = Padding( + key: columnKey, + padding: padding, + child: child, + ); + + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = Container( + color: Colors.green.withValues( + alpha: 0.3, + ), + child: child, + ); + } + + // the column block does not support the block actions and selection + // because the column block is a layout wrapper, it does not have a content + return child; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + return getRectsInSelection(Selection.invalid()).first; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection( + Selection.collapsed(position), + shiftWithBaseOffset: shiftWithBaseOffset, + ); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = columnKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => + Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart new file mode 100644 index 0000000000..69bec33c61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart @@ -0,0 +1,164 @@ +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class SimpleColumnBlockWidthResizer extends StatefulWidget { + const SimpleColumnBlockWidthResizer({ + super.key, + required this.columnNode, + required this.editorState, + this.height, + }); + + final Node columnNode; + final EditorState editorState; + final double? height; + + @override + State createState() => + _SimpleColumnBlockWidthResizerState(); +} + +class _SimpleColumnBlockWidthResizerState + extends State { + bool isDragging = false; + + ValueNotifier isHovering = ValueNotifier(false); + + @override + void dispose() { + isHovering.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => isHovering.value = true, + onExit: (_) { + // delay the hover state change to avoid flickering + Future.delayed(const Duration(milliseconds: 100), () { + if (!isDragging) { + isHovering.value = false; + } + }); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: _onHorizontalDragStart, + onHorizontalDragUpdate: _onHorizontalDragUpdate, + onHorizontalDragEnd: _onHorizontalDragEnd, + onHorizontalDragCancel: _onHorizontalDragCancel, + child: ValueListenableBuilder( + valueListenable: isHovering, + builder: (context, isHovering, child) { + final hide = isDraggingAppFlowyEditorBlock.value || !isHovering; + return MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Container( + width: 2, + height: widget.height ?? 20, + margin: EdgeInsets.symmetric(horizontal: 2), + color: !hide + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ); + }, + ), + ), + ); + } + + void _onHorizontalDragStart(DragStartDetails details) { + isDragging = true; + EditorGlobalConfiguration.enableDragMenu.value = false; + } + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + if (!isDragging) { + return; + } + + // update the column width in memory + final columnNode = widget.columnNode; + final columnsNode = columnNode.columnsParent; + if (columnsNode == null) { + return; + } + final editorWidth = columnsNode.rect.width; + final rect = columnNode.rect; + final width = rect.width; + final originalRatio = columnNode.attributes[SimpleColumnBlockKeys.ratio]; + final newWidth = width + details.delta.dx; + + final transaction = widget.editorState.transaction; + final newRatio = newWidth / editorWidth; + transaction.updateNode(columnNode, { + ...columnNode.attributes, + SimpleColumnBlockKeys.ratio: newRatio, + }); + + if (newRatio < 0.1 && newRatio < originalRatio) { + return; + } + + final nextColumn = columnNode.nextColumn; + if (nextColumn != null) { + final nextColumnRect = nextColumn.rect; + final nextColumnWidth = nextColumnRect.width; + final newNextColumnWidth = nextColumnWidth - details.delta.dx; + final newNextColumnRatio = newNextColumnWidth / editorWidth; + if (newNextColumnRatio < 0.1) { + return; + } + transaction.updateNode(nextColumn, { + ...nextColumn.attributes, + SimpleColumnBlockKeys.ratio: newNextColumnRatio, + }); + } + + transaction.updateNode(columnsNode, { + ...columnsNode.attributes, + ColumnsBlockKeys.columnCount: columnsNode.children.length, + }); + + widget.editorState.apply( + transaction, + options: ApplyOptions(inMemoryUpdate: true), + ); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + isHovering.value = false; + EditorGlobalConfiguration.enableDragMenu.value = true; + + if (!isDragging) { + return; + } + + // apply the transaction again to make sure the width is updated + final transaction = widget.editorState.transaction; + final columnsNode = widget.columnNode.columnsParent; + if (columnsNode == null) { + return; + } + for (final columnNode in columnsNode.children) { + transaction.updateNode(columnNode, { + ...columnNode.attributes, + }); + } + widget.editorState.apply(transaction); + + isDragging = false; + } + + void _onHorizontalDragCancel() { + isDragging = false; + isHovering.value = false; + EditorGlobalConfiguration.enableDragMenu.value = true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart new file mode 100644 index 0000000000..05389fb760 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension SimpleColumnNodeExtension on Node { + /// Returns the parent [Node] of the current node if it is a [SimpleColumnsBlock]. + Node? get columnsParent { + Node? currentNode = parent; + while (currentNode != null) { + if (currentNode.type == SimpleColumnsBlockKeys.type) { + return currentNode; + } + currentNode = currentNode.parent; + } + return null; + } + + /// Returns the parent [Node] of the current node if it is a [SimpleColumnBlock]. + Node? get columnParent { + Node? currentNode = parent; + while (currentNode != null) { + if (currentNode.type == SimpleColumnBlockKeys.type) { + return currentNode; + } + currentNode = currentNode.parent; + } + return null; + } + + /// Returns whether the current node is in a [SimpleColumnsBlock]. + bool get isInColumnsBlock => columnsParent != null; + + /// Returns whether the current node is in a [SimpleColumnBlock]. + bool get isInColumnBlock => columnParent != null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart new file mode 100644 index 0000000000..58ecde5f2f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart @@ -0,0 +1,275 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// if the children is not provided, it will create two columns by default. +// if the columnCount is provided, it will create the specified number of columns. +Node simpleColumnsNode({ + List? children, + int? columnCount, + double? ratio, +}) { + columnCount ??= 2; + children ??= List.generate( + columnCount, + (index) => simpleColumnNode( + ratio: ratio, + children: [paragraphNode()], + ), + ); + + // check the type of children + for (final child in children) { + if (child.type != SimpleColumnBlockKeys.type) { + Log.error('the type of children must be column, but got ${child.type}'); + } + } + + return Node( + type: SimpleColumnsBlockKeys.type, + children: children, + ); +} + +class SimpleColumnsBlockKeys { + const SimpleColumnsBlockKeys._(); + + static const String type = 'simple_columns'; +} + +class SimpleColumnsBlockComponentBuilder extends BlockComponentBuilder { + SimpleColumnsBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return ColumnsBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isNotEmpty; +} + +class ColumnsBlockComponent extends BlockComponentStatefulWidget { + const ColumnsBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => ColumnsBlockComponentState(); +} + +class ColumnsBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + final columnsKey = GlobalKey(); + + late final EditorState editorState = context.read(); + + final ScrollController scrollController = ScrollController(); + + final ValueNotifier heightValueNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _updateColumnsBlock(); + } + + @override + void dispose() { + scrollController.dispose(); + heightValueNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildChildren(), + ); + + child = Align( + alignment: Alignment.topLeft, + child: child, + ); + + child = Padding( + key: columnsKey, + padding: padding, + child: child, + ); + + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.red, + width: 3.0, + ), + ), + child: child, + ); + } + + // the columns block does not support the block actions and selection + // because the columns block is a layout wrapper, it does not have a content + return NotificationListener( + onNotification: (v) => updateHeightValueNotifier(v), + child: SizeChangedLayoutNotifier(child: child), + ); + } + + List _buildChildren() { + final length = node.children.length; + final children = []; + for (var i = 0; i < length; i++) { + final childNode = node.children[i]; + final double ratio = + childNode.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length; + + Widget child = editorState.renderer.build(context, childNode); + + child = Expanded( + flex: (max(ratio, 0.1) * 10000).toInt(), + child: child, + ); + + children.add(child); + + if (i != length - 1) { + children.add( + ValueListenableBuilder( + valueListenable: heightValueNotifier, + builder: (context, height, child) { + return SimpleColumnBlockWidthResizer( + columnNode: childNode, + editorState: editorState, + height: height, + ); + }, + ), + ); + } + } + return children; + } + + // Update the existing columns block data + // if the column ratio is not existing, it will be set to 1.0 / columnCount + void _updateColumnsBlock() { + final transaction = editorState.transaction; + final length = node.children.length; + for (int i = 0; i < length; i++) { + final childNode = node.children[i]; + final ratio = childNode.attributes[SimpleColumnBlockKeys.ratio]; + if (ratio == null) { + transaction.updateNode(childNode, { + ...childNode.attributes, + SimpleColumnBlockKeys.ratio: 1.0 / length, + }); + } + } + if (transaction.operations.isNotEmpty) { + editorState.apply(transaction); + } + } + + bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) { + if (!mounted) return true; + final height = _renderBox?.size.height; + if (heightValueNotifier.value == height) return true; + WidgetsBinding.instance.addPostFrameCallback((_) { + heightValueNotifier.value = height; + }); + return true; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + return getRectsInSelection(Selection.invalid()).first; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection( + Selection.collapsed(position), + shiftWithBaseOffset: shiftWithBaseOffset, + ); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = columnsKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => + Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart new file mode 100644 index 0000000000..d8820f8613 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart @@ -0,0 +1,6 @@ +class SimpleColumnsBlockConstants { + const SimpleColumnsBlockConstants._(); + + static const double minimumColumnWidth = 128.0; + static const bool enableDebugBorder = false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart index f4aac4fe2c..f108c7e26b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart @@ -132,6 +132,7 @@ class ClipboardService { final html = await reader.readValue(Formats.htmlText); final inAppJson = await reader.readValue(inAppJsonFormat); final tableJson = await reader.readValue(tableJsonFormat); + final uri = await reader.readValue(Formats.uri); (String, Uint8List?)? image; if (reader.canProvide(Formats.png)) { image = ('png', await reader.readFile(Formats.png)); @@ -144,7 +145,7 @@ class ClipboardService { } return ClipboardServiceData( - plainText: plainText, + plainText: plainText ?? uri?.uri.toString(), html: html, image: image, inAppJson: inAppJson, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart index 99211920fa..e56ccfc941 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart @@ -1,8 +1,7 @@ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -98,7 +97,13 @@ Document _buildCopiedDocument( // if the node is a table cell, we will fetch its children instead. filteredNodes.addAll(node.children); } else if (node.type == SimpleTableRowBlockKeys.type) { - // if the node is a table row, we will fetch its children instead. + // if the node is a table row, we will fetch its children's children instead. + filteredNodes.addAll(node.children.expand((e) => e.children)); + } else if (node.type == SimpleColumnBlockKeys.type) { + // if the node is a column block, we will fetch its children instead. + filteredNodes.addAll(node.children); + } else if (node.type == SimpleColumnsBlockKeys.type) { + // if the node is a columns block, we will fetch its children's children instead. filteredNodes.addAll(node.children.expand((e) => e.children)); } else { filteredNodes.add(node); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index c523453fba..6399d3b11f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:http/http.dart' as http; import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; /// - support /// - desktop @@ -144,6 +145,13 @@ Future doPaste(EditorState editorState) async { } if (plainText != null && plainText.isNotEmpty) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + await editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ); + } await editorState.pasteText(plainText); return Log.info('Pasted plain text'); } @@ -155,6 +163,7 @@ Future _pasteAsLinkPreview( EditorState editorState, String? text, ) async { + final isMobile = UniversalPlatform.isMobile; // the url should contain a protocol if (text == null || !isURL(text, {'require_protocol': true})) { return false; @@ -177,7 +186,7 @@ Future _pasteAsLinkPreview( node.delta?.toPlainText().isNotEmpty == true) { return false; } - + if (!isMobile) return false; final bool isImageUrl; try { isImageUrl = await _isImageUrl(text); @@ -186,6 +195,8 @@ Future _pasteAsLinkPreview( return false; } + if (!isImageUrl) return false; + // insert the text with link format final textTransaction = editorState.transaction ..insertText( @@ -243,6 +254,7 @@ Future doPlainPaste(EditorState editorState) async { } Future _isImageUrl(String text) async { + if (isNotImageUrl(text)) return false; final response = await http.head(Uri.parse(text)); if (response.statusCode == 200) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart index fbd9914c1d..c47c0c967d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart @@ -43,13 +43,11 @@ extension PasteFromBlockLink on EditorState { node, selection.startIndex, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.blockId: blockId, - MentionBlockKeys.pageId: pageId, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: pageId, + blockId: blockId, + ), ); await apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart index 6eff666991..3f11759545 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; @@ -13,6 +14,7 @@ extension PasteFromHtml on EditorState { } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } 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 6e6c9b1772..d086f36bed 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 @@ -73,7 +73,6 @@ extension PasteFromImage on EditorState { Log.info('unsupported format: $format'); if (UniversalPlatform.isMobile) { showToastNotification( - context, message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), ); } @@ -112,7 +111,6 @@ extension PasteFromImage on EditorState { if (errorMessage != null && context.mounted) { showToastNotification( - context, message: errorMessage, ); return false; @@ -131,7 +129,6 @@ extension PasteFromImage on EditorState { Log.error('cannot copy image file', e); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart index 90ed451128..fcb12cefa5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart @@ -1,6 +1,8 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { @@ -32,12 +34,16 @@ extension PasteFromPlainText on EditorState { await deleteSelectionIfNeeded(); + /// try to parse the plain text as markdown final nodes = customMarkdownToDocument(plainText).root.children; if (nodes.isEmpty) { + /// if the markdown parser failed, fallback to the plain text parser + await pastePlainText(plainText); return; } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } @@ -62,6 +68,29 @@ extension PasteFromPlainText on EditorState { AppFlowyRichTextKeys.href: plainText, }); await apply(transaction); + checkToShowPasteAsMenu(node); return true; } + + void checkToShowPasteAsMenu(Node node) { + if (selection == null || !selection!.isCollapsed) return; + if (UniversalPlatform.isMobile) return; + final href = _getLinkFromNode(node); + if (href != null) { + final context = document.root.context; + if (context != null && context.mounted) { + PasteAsMenuService(context: context, editorState: this).show(href); + } + } + } + + String? _getLinkFromNode(Node node) { + final delta = node.delta; + if (delta == null) return null; + final inserts = delta.whereType(); + if (inserts.isEmpty || inserts.length > 1) return null; + final link = inserts.first.attributes?.href; + if (link != null) return inserts.first.text; + return null; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart index ee8952dc11..3006fc3104 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart @@ -1,6 +1,7 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -19,9 +20,13 @@ class DocumentImmersiveCoverBloc (event, emit) async { await event.when( initial: () async { + final latestView = await ViewBackendService.getView(view.id); add( DocumentImmersiveCoverEvent.updateCoverAndIcon( - view.cover, + latestView.fold( + (s) => s.cover, + (e) => view.cover, + ), EmojiIconData.fromViewIconPB(view.icon), view.name, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart index cd150ff1c1..87c2815091 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:appflowy/plugins/database/widgets/database_view_widget.dart'; +import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,8 +17,14 @@ class DatabaseBlockKeys { static const String parentID = 'parent_id'; static const String viewID = 'view_id'; + static const String enableCompactMode = 'enable_compact_mode'; } +const overflowTypes = { + DatabaseBlockKeys.gridType, + DatabaseBlockKeys.boardType, +}; + class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { DatabaseViewBlockComponentBuilder({ super.configuration, @@ -32,6 +42,10 @@ class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -48,6 +62,7 @@ class DatabaseBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -65,32 +80,65 @@ class _DatabaseBlockComponentWidgetState @override BlockComponentConfiguration get configuration => widget.configuration; + late StreamSubscription compactModeSubscription; + EditorState? editorState; + + @override + void initState() { + super.initState(); + compactModeSubscription = + compactModeEventBus.on().listen((event) { + if (event.id != node.id) return; + final newAttributes = { + ...node.attributes, + DatabaseBlockKeys.enableCompactMode: event.enable, + }; + final theEditorState = editorState; + if (theEditorState == null) return; + final transaction = theEditorState.transaction; + transaction.updateNode(node, newAttributes); + theEditorState.apply(transaction); + }); + } + + @override + void dispose() { + super.dispose(); + compactModeSubscription.cancel(); + editorState = null; + } + @override Widget build(BuildContext context) { final editorState = Provider.of(context, listen: false); + this.editorState = editorState; Widget child = BuiltInPageWidget( node: widget.node, editorState: editorState, - builder: (view) => DatabaseViewWidget(key: ValueKey(view.id), view: view), - ); - - child = Padding( - padding: padding, - child: FocusScope( - skipTraversal: true, - onFocusChange: (value) { - if (value && keepEditorFocusNotifier.value == 0) { - context.read().selection = null; - } - }, - child: child, + builder: (view) => Provider.value( + value: ReferenceState(true), + child: DatabaseViewWidget( + key: ValueKey(view.id), + view: view, + actionBuilder: widget.actionBuilder, + showActions: widget.showActions, + node: widget.node, + ), ), ); - if (widget.showActions && widget.actionBuilder != null) { - child = BlockComponentActionWrapper( - node: widget.node, - actionBuilder: widget.actionBuilder!, + child = FocusScope( + skipTraversal: true, + onFocusChange: (value) { + if (value && keepEditorFocusNotifier.value == 0) { + context.read().selection = null; + } + }, + child: child, + ); + + if (!editorState.editable) { + child = IgnorePointer( child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart index 5beba66c32..905c033bda 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -27,9 +28,15 @@ extension TextDeltaExtension on Delta { if (op.text == MentionBlockKeys.mentionChar) { final mention = attributes?[MentionBlockKeys.mention]; final mentionPageId = mention?[MentionBlockKeys.pageId]; + final mentionType = mention?[MentionBlockKeys.type]; if (mentionPageId != null) { text += await getMentionPageName(mentionPageId); continue; + } else if (mentionType == MentionType.externalLink.name) { + final url = mention?[MentionBlockKeys.url] ?? ''; + final info = await LinkInfoCache.get(url); + text += info?.title ?? url; + continue; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart new file mode 100644 index 0000000000..162c7a1c34 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart @@ -0,0 +1,330 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart'; +import 'package:flutter/material.dart'; + +class ColorPicker extends StatefulWidget { + const ColorPicker({ + super.key, + required this.title, + required this.selectedColorHex, + required this.onSubmittedColorHex, + required this.colorOptions, + this.resetText, + this.customColorHex, + this.resetIconName, + this.showClearButton = false, + }); + + final String title; + final String? selectedColorHex; + final String? customColorHex; + final void Function(String? color, bool isCustomColor) onSubmittedColorHex; + final String? resetText; + final String? resetIconName; + final bool showClearButton; + + final List colorOptions; + + @override + State createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State { + final TextEditingController _colorHexController = TextEditingController(); + final TextEditingController _colorOpacityController = TextEditingController(); + + @override + void initState() { + super.initState(); + final selectedColorHex = widget.selectedColorHex, + customColorHex = widget.customColorHex; + _colorHexController.text = + _extractColorHex(customColorHex ?? selectedColorHex) ?? 'FFFFFF'; + _colorOpacityController.text = + _convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100'; + } + + @override + Widget build(BuildContext context) { + return basicOverlay( + context, + width: 300, + height: 250, + children: [ + EditorOverlayTitle(text: widget.title), + const SizedBox(height: 6), + widget.showClearButton && + widget.resetText != null && + widget.resetIconName != null + ? ResetColorButton( + resetText: widget.resetText!, + resetIconName: widget.resetIconName!, + onPressed: (color) => + widget.onSubmittedColorHex.call(color, false), + ) + : const SizedBox.shrink(), + CustomColorItem( + colorController: _colorHexController, + opacityController: _colorOpacityController, + onSubmittedColorHex: (color) => + widget.onSubmittedColorHex.call(color, true), + ), + _buildColorItems( + widget.colorOptions, + widget.selectedColorHex, + ), + ], + ); + } + + Widget _buildColorItems( + List options, + String? selectedColor, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: options + .map((e) => _buildColorItem(e, e.colorHex == selectedColor)) + .toList(), + ); + } + + Widget _buildColorItem(ColorOption option, bool isChecked) { + return SizedBox( + height: 36, + child: TextButton.icon( + onPressed: () { + widget.onSubmittedColorHex(option.colorHex, false); + }, + icon: SizedBox.square( + dimension: 12, + child: Container( + decoration: BoxDecoration( + color: option.colorHex.tryToColor(), + shape: BoxShape.circle, + ), + ), + ), + style: buildOverlayButtonStyle(context), + label: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option.name, + softWrap: false, + maxLines: 1, + overflow: TextOverflow.fade, + style: TextStyle( + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ), + ), + // checkbox + if (isChecked) const FlowySvg(FlowySvgs.toolbar_check_m), + ], + ), + ), + ); + } + + String? _convertHexToOpacity(String? colorHex) { + if (colorHex == null) return null; + final opacityHex = colorHex.substring(2, 4); + final opacity = int.parse(opacityHex, radix: 16) / 2.55; + return opacity.toStringAsFixed(0); + } + + String? _extractColorHex(String? colorHex) { + if (colorHex == null) return null; + return colorHex.substring(4); + } +} + +class ResetColorButton extends StatelessWidget { + const ResetColorButton({ + super.key, + required this.resetText, + required this.resetIconName, + required this.onPressed, + }); + + final Function(String? color) onPressed; + final String resetText; + final String resetIconName; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 32, + child: TextButton.icon( + onPressed: () => onPressed(null), + icon: EditorSvg( + name: resetIconName, + width: 13, + height: 13, + color: Theme.of(context).iconTheme.color, + ), + label: Text( + resetText, + style: TextStyle( + color: Theme.of(context).hintColor, + ), + textAlign: TextAlign.left, + ), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.hovered)) { + return Theme.of(context).hoverColor; + } + return Colors.transparent; + }, + ), + alignment: Alignment.centerLeft, + ), + ), + ); + } +} + +class CustomColorItem extends StatefulWidget { + const CustomColorItem({ + super.key, + required this.colorController, + required this.opacityController, + required this.onSubmittedColorHex, + }); + + final TextEditingController colorController; + final TextEditingController opacityController; + final void Function(String color) onSubmittedColorHex; + + @override + State createState() => _CustomColorItemState(); +} + +class _CustomColorItemState extends State { + @override + Widget build(BuildContext context) { + return ExpansionTile( + tilePadding: const EdgeInsets.only(left: 8), + shape: Border.all( + color: Colors.transparent, + ), // remove the default border when it is expanded + title: Row( + children: [ + // color sample box + SizedBox.square( + dimension: 12, + child: Container( + decoration: BoxDecoration( + color: Color( + int.tryParse( + _combineColorHexAndOpacity( + widget.colorController.text, + widget.opacityController.text, + ), + ) ?? + 0xFFFFFFFF, + ), + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppFlowyEditorL10n.current.customColor, + style: Theme.of(context).textTheme.labelLarge, + // same style as TextButton.icon + ), + ), + ], + ), + children: [ + const SizedBox(height: 6), + _customColorDetailsTextField( + labelText: AppFlowyEditorL10n.current.hexValue, + controller: widget.colorController, + // update the color sample box when the text changes + onChanged: (_) => setState(() {}), + onSubmitted: _submitCustomColorHex, + ), + const SizedBox(height: 10), + _customColorDetailsTextField( + labelText: AppFlowyEditorL10n.current.opacity, + controller: widget.opacityController, + // update the color sample box when the text changes + onChanged: (_) => setState(() {}), + onSubmitted: _submitCustomColorHex, + ), + const SizedBox(height: 6), + ], + ); + } + + Widget _customColorDetailsTextField({ + required String labelText, + required TextEditingController controller, + Function(String)? onChanged, + Function(String)? onSubmitted, + }) { + return Padding( + padding: const EdgeInsets.only(right: 3), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + style: Theme.of(context).textTheme.bodyMedium, + onChanged: onChanged, + onSubmitted: onSubmitted, + ), + ); + } + + String _combineColorHexAndOpacity(String colorHex, String opacity) { + colorHex = _fixColorHex(colorHex); + opacity = _fixOpacity(opacity); + final opacityHex = (int.parse(opacity) * 2.55).round().toRadixString(16); + return '0x$opacityHex$colorHex'; + } + + String _fixColorHex(String colorHex) { + if (colorHex.length > 6) { + colorHex = colorHex.substring(0, 6); + } + if (int.tryParse(colorHex, radix: 16) == null) { + colorHex = 'FFFFFF'; + } + return colorHex; + } + + String _fixOpacity(String opacity) { + // if opacity is 0 - 99, return it + // otherwise return 100 + final RegExp regex = RegExp('^(0|[1-9][0-9]?)'); + if (regex.hasMatch(opacity)) { + return opacity; + } else { + return '100'; + } + } + + void _submitCustomColorHex(String value) { + final String color = _combineColorHexAndOpacity( + widget.colorController.text, + widget.opacityController.text, + ); + widget.onSubmittedColorHex(color); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart new file mode 100644 index 0000000000..03fc12a37c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_animation.dart'; + +class DesktopFloatingToolbar extends StatefulWidget { + const DesktopFloatingToolbar({ + super.key, + required this.editorState, + required this.child, + required this.onDismiss, + this.enableAnimation = true, + }); + + final EditorState editorState; + final Widget child; + final VoidCallback onDismiss; + final bool enableAnimation; + + @override + State createState() => _DesktopFloatingToolbarState(); +} + +class _DesktopFloatingToolbarState extends State { + EditorState get editorState => widget.editorState; + + _Position? position; + final toolbarController = getIt(); + + @override + void initState() { + super.initState(); + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return; + } + final selectionRect = editorState.selectionRects(); + if (selectionRect.isEmpty) return; + position = calculateSelectionMenuOffset(selectionRect.first); + toolbarController._addCallback(dismiss); + } + + @override + void dispose() { + toolbarController._removeCallback(dismiss); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (position == null) return Container(); + return Positioned( + left: position!.left, + top: position!.top, + right: position!.right, + child: widget.enableAnimation + ? ToolbarAnimationWidget(child: widget.child) + : widget.child, + ); + } + + void dismiss() { + widget.onDismiss.call(); + } + + _Position calculateSelectionMenuOffset( + Rect rect, + ) { + const toolbarHeight = 40, topLimit = toolbarHeight + 8; + final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState); + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorSize = editorState.renderBox?.size ?? Size.zero; + final menuWidth = + isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0; + final editorRect = editorOffset & editorSize; + final left = rect.left, leftStart = 50; + final top = + rect.top < topLimit ? rect.bottom + topLimit : rect.top - topLimit; + if (left + menuWidth > editorRect.right) { + return _Position( + editorRect.right - menuWidth, + top, + null, + ); + } else if (rect.left - leftStart > 0) { + return _Position(rect.left - leftStart, top, null); + } else { + return _Position(rect.left, top, null); + } + } +} + +class _Position { + _Position(this.left, this.top, this.right); + + final double? left; + final double? top; + final double? right; +} + +class FloatingToolbarController { + final Set _dismissCallbacks = {}; + final Set _displayListeners = {}; + + void _addCallback(VoidCallback callback) { + _dismissCallbacks.add(callback); + for (final listener in Set.of(_displayListeners)) { + listener.call(); + } + } + + void _removeCallback(VoidCallback callback) => + _dismissCallbacks.remove(callback); + + bool get isToolbarShowing => _dismissCallbacks.isNotEmpty; + + void addDisplayListener(VoidCallback listener) => + _displayListeners.add(listener); + + void removeDisplayListener(VoidCallback listener) => + _displayListeners.remove(listener); + + void hideToolbar() { + if (_dismissCallbacks.isEmpty) return; + for (final callback in _dismissCallbacks) { + callback.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart new file mode 100644 index 0000000000..002d569c7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -0,0 +1,320 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'link_search_text_field.dart'; + +class LinkCreateMenu extends StatefulWidget { + const LinkCreateMenu({ + super.key, + required this.editorState, + required this.onSubmitted, + required this.onDismiss, + required this.alignment, + required this.currentViewId, + required this.initialText, + }); + + final EditorState editorState; + final void Function(String link, bool isPage) onSubmitted; + final VoidCallback onDismiss; + final String currentViewId; + final String initialText; + final LinkMenuAlignment alignment; + + @override + State createState() => _LinkCreateMenuState(); +} + +class _LinkCreateMenuState extends State { + late LinkSearchTextField searchTextField = LinkSearchTextField( + currentViewId: widget.currentViewId, + initialSearchText: widget.initialText, + onEnter: () { + searchTextField.onSearchResult( + onLink: () => onSubmittedLink(), + onRecentViews: () => + onSubmittedPageLink(searchTextField.currentRecentView), + onSearchViews: () => + onSubmittedPageLink(searchTextField.currentSearchedView), + onEmpty: () {}, + ); + }, + onEscape: widget.onDismiss, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + ); + + bool get isTextfieldEnable => searchTextField.isTextfieldEnable; + + String get searchText => searchTextField.searchText; + + bool get showAtTop => widget.alignment.isTop; + + bool showErrorText = false; + + @override + void initState() { + super.initState(); + searchTextField.requestFocus(); + searchTextField.searchRecentViews(); + final focusNode = searchTextField.focusNode; + bool hasFocus = focusNode.hasFocus; + focusNode.addListener(() { + if (hasFocus != focusNode.hasFocus && mounted) { + setState(() { + hasFocus = focusNode.hasFocus; + }); + } + }); + } + + @override + void dispose() { + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 320, + child: Column( + children: showAtTop + ? [ + searchTextField.buildResultContainer( + margin: EdgeInsets.only(bottom: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + buildSearchContainer(), + ] + : [ + buildSearchContainer(), + searchTextField.buildResultContainer( + margin: EdgeInsets.only(top: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + ], + ), + ); + } + + Widget buildSearchContainer() { + return Container( + width: 320, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.all(8), + child: ValueListenableBuilder( + valueListenable: searchTextField.textEditingController, + builder: (context, _, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: searchTextField.buildTextField(context: context), + ), + HSpace(8), + FlowyTextButton( + LocaleKeys.document_toolbar_insert.tr(), + mainAxisAlignment: MainAxisAlignment.center, + padding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: 72, minHeight: 32), + fontSize: 14, + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + lineHeight: 20 / 14, + fontWeight: FontWeight.w600, + onPressed: onSubmittedLink, + ), + ], + ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + }, + ), + ); + } + + void onSubmittedLink() { + if (!isTextfieldEnable) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted(searchText, false); + } + + void onSubmittedPageLink(ViewPB view) async { + final workspaceId = context + .read() + ?.state + .currentWorkspace + ?.workspaceId ?? + ''; + final link = ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: view.id, + ); + widget.onSubmitted(link, true); + } +} + +void showLinkCreateMenu( + BuildContext context, + EditorState editorState, + Selection selection, + String currentViewId, +) { + if (!context.mounted) return; + final (left, top, right, bottom, alignment) = _getPosition(editorState); + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final selectedText = editorState.getTextInSelection(selection).join(); + + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: top, + bottom: bottom, + left: left, + right: right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkCreateMenu( + alignment: alignment, + initialText: selectedText, + currentViewId: currentViewId, + editorState: editorState, + onSubmitted: (link, isPage) async { + await editorState.formatDelta(selection, { + BuiltInAttributeKey.href: link, + kIsPageLink: isPage, + }); + await editorState.updateSelectionWithReason( + null, + reason: SelectionUpdateReason.uiEvent, + ); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +// get a proper position for link menu +( + double? left, + double? top, + double? right, + double? bottom, + LinkMenuAlignment alignment, +) _getPosition( + EditorState editorState, +) { + final rect = editorState.selectionRects().first; + const menuHeight = 222.0, menuWidth = 320.0; + + double? left, right, top, bottom; + LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; + final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), + editorSize = editorState.renderBox!.size; + final editorBottom = editorSize.height + editorOffset.dy, + editorRight = editorSize.width + editorOffset.dx; + final overflowBottom = rect.bottom + menuHeight > editorBottom, + overflowTop = rect.top - menuHeight < 0, + overflowLeft = rect.left - menuWidth < 0, + overflowRight = rect.right + menuWidth > editorRight; + + if (overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else if (overflowBottom && !overflowTop) { + /// show at top + bottom = editorBottom - rect.top; + } else if (!overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else { + top = 0; + } + + if (overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else if (overflowRight && !overflowLeft) { + /// show at left + right = editorRight - rect.right; + } else if (!overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else { + left = 0; + } + + if (left != null && top != null) { + alignment = LinkMenuAlignment.bottomRight; + } else if (left != null && bottom != null) { + alignment = LinkMenuAlignment.topRight; + } else if (right != null && top != null) { + alignment = LinkMenuAlignment.bottomLeft; + } else if (right != null && bottom != null) { + alignment = LinkMenuAlignment.topLeft; + } + + return (left, top, right, bottom, alignment); +} + +ShapeDecoration buildToolbarLinkDecoration( + BuildContext context, { + double radius = 12.0, +}) { + final theme = AppFlowyTheme.of(context); + return ShapeDecoration( + color: theme.surfaceColorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + shadows: theme.shadow.small, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart new file mode 100644 index 0000000000..e90ee22a80 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -0,0 +1,516 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; +import 'link_create_menu.dart'; +import 'link_search_text_field.dart'; +import 'link_styles.dart'; + +class LinkEditMenu extends StatefulWidget { + const LinkEditMenu({ + super.key, + required this.linkInfo, + required this.onDismiss, + required this.onApply, + required this.onRemoveLink, + required this.currentViewId, + }); + + final LinkInfo linkInfo; + final ValueChanged onApply; + final ValueChanged onRemoveLink; + final VoidCallback onDismiss; + final String currentViewId; + + @override + State createState() => _LinkEditMenuState(); +} + +class _LinkEditMenuState extends State { + ValueChanged get onRemoveLink => widget.onRemoveLink; + + VoidCallback get onDismiss => widget.onDismiss; + + late TextEditingController linkNameController = + TextEditingController(text: linkInfo.name); + late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); + late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); + late LinkInfo linkInfo = widget.linkInfo; + late LinkSearchTextField searchTextField; + bool isShowingSearchResult = false; + ViewPB? currentView; + bool showErrorText = false; + + @override + void initState() { + super.initState(); + final isPageLink = linkInfo.isPage; + if (isPageLink) getPageView(); + searchTextField = LinkSearchTextField( + initialSearchText: isPageLink ? '' : linkInfo.link, + initialViewId: linkInfo.viewId, + currentViewId: widget.currentViewId, + onEnter: onConfirm, + onEscape: () { + if (isShowingSearchResult) { + hideSearchResult(); + } else { + onDismiss(); + } + }, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + )..searchRecentViews(); + makeSureHasFocus(); + } + + @override + void dispose() { + linkNameController.dispose(); + textFocusNode.dispose(); + menuFocusNode.dispose(); + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final showingRecent = + searchTextField.showingRecent && isShowingSearchResult; + final errorHeight = showErrorText ? 20.0 : 0.0; + return GestureDetector( + onTap: onDismiss, + child: Focus( + focusNode: menuFocusNode, + child: Container( + width: 400, + height: 250 + (showingRecent ? 32 : 0), + color: Colors.white.withAlpha(1), + child: Stack( + children: [ + GestureDetector( + onTap: hideSearchResult, + child: Container( + width: 400, + height: 192 + errorHeight, + decoration: buildToolbarLinkDecoration(context), + ), + ), + Positioned( + top: 16, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_pageOrURL.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 80 + errorHeight, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_linkName.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 144 + errorHeight, + left: 20, + child: buildButtons(), + ), + Positioned( + top: 100 + errorHeight, + left: 20, + child: buildNameTextField(), + ), + Positioned( + top: 36, + left: 20, + child: buildLinkField(), + ), + ], + ), + ), + ), + ); + } + + Widget buildLinkField() { + final showPageView = linkInfo.isPage && !isShowingSearchResult; + Widget child; + if (showPageView) { + child = buildPageView(); + } else if (!isShowingSearchResult) { + child = buildLinkView(); + } else { + return SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 360, + height: 32, + child: searchTextField.buildTextField( + autofocus: true, + context: context, + ), + ), + VSpace(6), + searchTextField.buildResultContainer( + context: context, + width: 360, + onPageLinkSelected: onPageSelected, + onLinkSelected: onLinkSelected, + ), + ], + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + child, + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + } + + Widget buildButtons() { + return GestureDetector( + onTap: hideSearchResult, + child: SizedBox( + width: 360, + height: 32, + child: Row( + children: [ + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + width: 32, + height: 32, + tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor(context)), + ), + onPressed: () => onRemoveLink.call(linkInfo), + ), + Spacer(), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor(context)), + ), + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + fontColor: Theme.of(context).isLightMode + ? LinkStyle.textPrimary + : Theme.of(context).iconTheme.color, + fillColor: Colors.transparent, + fontWeight: FontWeight.w400, + onPressed: onDismiss, + ), + ), + HSpace(12), + ValueListenableBuilder( + valueListenable: linkNameController, + builder: (context, _, __) { + return FlowyTextButton( + LocaleKeys.settings_appearance_documentSettings_apply.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + fontWeight: FontWeight.w400, + onPressed: onApply, + ); + }, + ), + ], + ), + ), + ); + } + + Widget buildNameTextField() { + return SizedBox( + width: 360, + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + focusNode: textFocusNode, + autofocus: true, + textAlign: TextAlign.left, + controller: linkNameController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + linkInfo = LinkInfo( + name: text, + link: linkInfo.link, + isPage: linkInfo.isPage, + ); + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkNameHint.tr(), + context, + ), + ), + ); + } + + Widget buildPageView() { + late Widget child; + final view = currentView; + if (view == null) { + child = Center( + child: SizedBox.fromSize( + size: Size(10, 10), + child: CircularProgressIndicator(), + ), + ); + } else { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + child = GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + preferBelow: false, + message: displayName, + child: Container( + height: 32, + padding: EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Row( + children: [ + searchTextField.buildIcon(view), + HSpace(4), + Flexible( + child: FlowyText.regular( + displayName, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ); + } + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: child, + ); + } + + Widget buildLinkView() { + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: FlowyTooltip( + preferBelow: false, + message: linkInfo.link, + child: GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + FlowySvg(FlowySvgs.toolbar_link_earth_m), + HSpace(8), + Flexible( + child: FlowyText.regular( + linkInfo.link, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.enter) { + onApply(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future makeSureHasFocus() async { + final focusNode = textFocusNode; + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + makeSureHasFocus(); + }); + } + + void onApply() { + if (isShowingSearchResult) { + onConfirm(); + return; + } + if (linkInfo.link.isEmpty) { + widget.onRemoveLink(linkInfo); + return; + } + if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onApply.call(linkInfo); + } + + void onConfirm() { + searchTextField.onSearchResult( + onLink: onLinkSelected, + onRecentViews: () => onPageSelected(searchTextField.currentRecentView), + onSearchViews: () => onPageSelected(searchTextField.currentSearchedView), + onEmpty: () { + searchTextField.unfocus(); + }, + ); + menuFocusNode.requestFocus(); + } + + Future getPageView() async { + if (!linkInfo.isPage) return; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(linkInfo.viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + void showSearchResult() { + setState(() { + if (linkInfo.isPage) searchTextField.updateText(''); + isShowingSearchResult = true; + searchTextField.requestFocus(); + }); + } + + void hideSearchResult() { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + textFocusNode.unfocus(); + }); + } + + void onLinkSelected() { + if (mounted) { + linkInfo = LinkInfo( + name: linkInfo.name, + link: searchTextField.searchText, + ); + hideSearchResult(); + } + } + + Future onPageSelected(ViewPB view) async { + currentView = view; + final link = ShareConstants.buildShareUrl( + workspaceId: await UserBackendService.getCurrentWorkspace().fold( + (s) => s.id, + (f) => '', + ), + viewId: view.id, + ); + linkInfo = LinkInfo( + name: linkInfo.name, + link: link, + isPage: true, + ); + searchTextField.updateText(linkInfo.link); + if (mounted) { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + }); + } + } + + BoxDecoration buildDecoration() => BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: LinkStyle.borderColor(context)), + ); +} + +class LinkInfo { + LinkInfo({this.isPage = false, required this.name, required this.link}); + + final bool isPage; + final String name; + final String link; + + Attributes toAttribute() => + {AppFlowyRichTextKeys.href: link, kIsPageLink: isPage}; + + String get viewId => isPage ? link.split('/').lastOrNull ?? '' : ''; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart new file mode 100644 index 0000000000..c992e40c61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -0,0 +1,635 @@ +import 'dart:math'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'link_create_menu.dart'; +import 'link_edit_menu.dart'; + +class LinkHoverTrigger extends StatefulWidget { + const LinkHoverTrigger({ + super.key, + required this.editorState, + required this.selection, + required this.node, + required this.attribute, + required this.size, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final EditorState editorState; + final Selection selection; + final Node node; + final Attributes attribute; + final Size size; + final Duration delayToShow; + final Duration delayToHide; + + @override + State createState() => _LinkHoverTriggerState(); +} + +class _LinkHoverTriggerState extends State { + final hoverMenuController = PopoverController(); + final editMenuController = PopoverController(); + final toolbarController = getIt(); + bool isHoverMenuShowing = false; + bool isHoverMenuHovering = false; + bool isHoverTriggerHovering = false; + + Size get size => widget.size; + + EditorState get editorState => widget.editorState; + + Selection get selection => widget.selection; + + Attributes get attribute => widget.attribute; + + late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection); + + @override + void initState() { + super.initState(); + getIt()._add(triggerKey, showLinkHoverMenu); + toolbarController.addDisplayListener(onToolbarShow); + } + + @override + void dispose() { + hoverMenuController.close(); + editMenuController.close(); + getIt()._remove(triggerKey, showLinkHoverMenu); + toolbarController.removeDisplayListener(onToolbarShow); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (v) { + isHoverTriggerHovering = true; + Future.delayed(widget.delayToShow, () { + if (isHoverTriggerHovering && !isHoverMenuShowing) { + showLinkHoverMenu(); + } + }); + }, + onExit: (v) { + isHoverTriggerHovering = false; + tryToDismissLinkHoverMenu(); + }, + child: buildHoverPopover( + buildEditPopover( + Container( + color: Colors.black.withAlpha(1), + width: size.width, + height: size.height, + ), + ), + ), + ); + } + + Widget buildHoverPopover(Widget child) { + return AppFlowyPopover( + controller: hoverMenuController, + direction: PopoverDirection.topWithLeftAligned, + offset: Offset(0, size.height), + onOpen: () { + keepEditorFocusNotifier.increase(); + isHoverMenuShowing = true; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + isHoverMenuShowing = false; + }, + margin: EdgeInsets.zero, + constraints: BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ), + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + popupBuilder: (context) => LinkHoverMenu( + attribute: widget.attribute, + triggerSize: size, + onEnter: (_) { + isHoverMenuHovering = true; + }, + onExit: (_) { + isHoverMenuHovering = false; + tryToDismissLinkHoverMenu(); + }, + onConvertTo: (type) => convertLinkTo(editorState, selection, type), + onOpenLink: openLink, + onCopyLink: () => copyLink(context), + onEditLink: showLinkEditMenu, + onRemoveLink: () => removeLink(editorState, selection), + ), + child: child, + ); + } + + Widget buildEditPopover(Widget child) { + final href = attribute.href ?? '', + isPage = attribute.isPage, + title = editorState.getTextInSelection(selection).join(); + final currentViewId = context.read()?.documentId ?? ''; + return AppFlowyPopover( + controller: editMenuController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, 0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + asBarrier: true, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + constraints: BoxConstraints( + maxWidth: 400, + minHeight: 282, + ), + popupBuilder: (context) => LinkEditMenu( + currentViewId: currentViewId, + linkInfo: LinkInfo(name: title, link: href, isPage: isPage), + onDismiss: () => editMenuController.close(), + onApply: (info) async { + final transaction = editorState.transaction; + transaction.replaceText( + widget.node, + selection.startIndex, + selection.length, + info.name, + attributes: info.toAttribute(), + ); + editMenuController.close(); + await editorState.apply(transaction); + }, + onRemoveLink: (linkinfo) => + onRemoveAndReplaceLink(editorState, selection, linkinfo.name), + ), + child: child, + ); + } + + void onToolbarShow() => hoverMenuController.close(); + + void showLinkHoverMenu() { + if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) { + return; + } + keepEditorFocusNotifier.increase(); + hoverMenuController.show(); + } + + void showLinkEditMenu() { + keepEditorFocusNotifier.increase(); + hoverMenuController.close(); + editMenuController.show(); + } + + void tryToDismissLinkHoverMenu() { + Future.delayed(widget.delayToHide, () { + if (isHoverMenuHovering || isHoverTriggerHovering) { + return; + } + hoverMenuController.close(); + }); + } + + Future openLink() async { + final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage; + + if (isPage) { + final viewId = href.split('/').lastOrNull ?? ''; + if (viewId.isEmpty) { + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); + } else { + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (view != null) { + await handleMentionBlockTap(context, widget.editorState, view); + } + } + } else { + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); + } + } + + Future copyLink(BuildContext context) async { + final href = widget.attribute.href ?? ''; + await context.copyLink(href); + hoverMenuController.close(); + } + + void removeLink( + EditorState editorState, + Selection selection, + ) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..formatText( + node, + index, + length, + { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } + + Future convertLinkTo( + EditorState editorState, + Selection selection, + LinkConvertMenuCommand type, + ) async { + final url = widget.attribute.href ?? ''; + if (type == LinkConvertMenuCommand.toBookmark) { + await convertUrlToLinkPreview(editorState, selection, url); + } else if (type == LinkConvertMenuCommand.toMention) { + await convertUrlToMention(editorState, selection); + } else if (type == LinkConvertMenuCommand.toEmbed) { + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: LinkEmbedKeys.embed, + ); + } + } + + void onRemoveAndReplaceLink( + EditorState editorState, + Selection selection, + String text, + ) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..replaceText( + node, + index, + length, + text, + attributes: { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } +} + +class LinkHoverMenu extends StatefulWidget { + const LinkHoverMenu({ + super.key, + required this.attribute, + required this.onEnter, + required this.onExit, + required this.triggerSize, + required this.onCopyLink, + required this.onOpenLink, + required this.onEditLink, + required this.onRemoveLink, + required this.onConvertTo, + }); + + final Attributes attribute; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final Size triggerSize; + final VoidCallback onCopyLink; + final VoidCallback onOpenLink; + final VoidCallback onEditLink; + final VoidCallback onRemoveLink; + final ValueChanged onConvertTo; + + @override + State createState() => _LinkHoverMenuState(); +} + +class _LinkHoverMenuState extends State { + ViewPB? currentView; + late bool isPage = widget.attribute.isPage; + late String href = widget.attribute.href ?? ''; + final popoverController = PopoverController(); + bool isConvertButtonSelected = false; + + @override + void initState() { + super.initState(); + if (isPage) getPageView(); + } + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: SizedBox( + width: max(320, widget.triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded(child: buildLinkWidget()), + Container( + height: 20, + width: 1, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onCopyLink, + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), + tooltipText: LocaleKeys.editor_editLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onEditLink, + ), + buildConvertButton(), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onRemoveLink, + ), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + onTap: widget.onOpenLink, + child: Container( + width: widget.triggerSize.width, + height: widget.triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } + + Future getPageView() async { + final viewId = href.split('/').lastOrNull ?? ''; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + Widget buildLinkWidget() { + final view = currentView; + if (isPage && view == null) { + return SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ); + } + String text = ''; + if (isPage && view != null) { + text = view.name; + if (text.isEmpty) { + text = LocaleKeys.document_title_placeholder.tr(); + } + } else { + text = href; + } + return FlowyTooltip( + message: text, + preferBelow: false, + child: FlowyText.regular( + text, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(44, 10.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showConvertMenu(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkConvertMenuCommand.values.length, (index) { + final command = LinkConvertMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + widget.onConvertTo(command); + closeConvertMenu(); + }, + ), + ); + }), + ), + ), + ); + } + + void showConvertMenu() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void closeConvertMenu() { + popoverController.close(); + } +} + +class HoverTriggerKey { + HoverTriggerKey(this.nodeId, this.selection); + + final String nodeId; + final Selection selection; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HoverTriggerKey && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + isSelectionSame(other.selection); + + bool isSelectionSame(Selection other) => + (selection.start == other.start && selection.end == other.end) || + (selection.start == other.end && selection.end == other.start); + + @override + int get hashCode => nodeId.hashCode ^ selection.hashCode; +} + +class LinkHoverTriggers { + final Map> _map = {}; + + void _add(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.add(callback); + _map[key] = callbacks; + } + + void _remove(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.remove(callback); + _map[key] = callbacks; + } + + void call(HoverTriggerKey key) { + final callbacks = _map[key] ?? {}; + if (callbacks.isEmpty) return; + callbacks.first.call(); + } +} + +enum LinkConvertMenuCommand { + toMention, + toBookmark, + toEmbed; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + } + } + + String get type { + switch (this) { + case toMention: + return MentionBlockKeys.type; + case toBookmark: + return LinkPreviewBlockKeys.type; + case toEmbed: + return LinkPreviewBlockKeys.type; + } + } +} + +extension LinkExtension on BuildContext { + Future copyLink(String link) async { + if (link.isEmpty) return; + await getIt() + .setData(ClipboardServiceData(plainText: link)); + if (mounted) { + showToastNotification( + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart new file mode 100644 index 0000000000..d08442d779 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart @@ -0,0 +1,184 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; + +import 'link_create_menu.dart'; +import 'link_styles.dart'; + +void showReplaceMenu({ + required BuildContext context, + required EditorState editorState, + required Node node, + String? url, + required LTRB ltrb, + required ValueChanged onReplace, +}) { + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: ltrb.top, + bottom: ltrb.bottom, + left: ltrb.left, + right: ltrb.right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkReplaceMenu( + link: url ?? '', + onSubmitted: (link) async { + onReplace.call(link); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +class LinkReplaceMenu extends StatefulWidget { + const LinkReplaceMenu({ + super.key, + required this.onSubmitted, + required this.link, + required this.onDismiss, + }); + + final ValueChanged onSubmitted; + final VoidCallback onDismiss; + final String link; + + @override + State createState() => _LinkReplaceMenuState(); +} + +class _LinkReplaceMenuState extends State { + bool showErrorText = false; + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + late TextEditingController textEditingController = + TextEditingController(text: widget.link); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void dispose() { + focusNode.dispose(); + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 330, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: buildLinkField()), + HSpace(8), + buildReplaceButton(), + ], + ), + ); + } + + Widget buildLinkField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: true, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint + .tr(), + context, + showErrorBorder: showErrorText, + ), + ), + ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + } + + Widget buildReplaceButton() { + return FlowyTextButton( + LocaleKeys.button_replace.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + fontWeight: FontWeight.w400, + onPressed: onSubmit, + ); + } + + void onSubmit() { + final link = textEditingController.text.trim(); + if (link.isEmpty || !isUri(link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted.call(link); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onSubmit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart new file mode 100644 index 0000000000..97fd6abdad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart @@ -0,0 +1,352 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'link_create_menu.dart'; +import 'link_styles.dart'; + +class LinkSearchTextField { + LinkSearchTextField({ + this.onEscape, + this.onEnter, + this.onDataRefresh, + this.initialViewId = '', + required this.currentViewId, + String? initialSearchText, + }) : textEditingController = TextEditingController( + text: isUri(initialSearchText ?? '') ? initialSearchText : '', + ); + + final TextEditingController textEditingController; + final String initialViewId; + final String currentViewId; + final ItemScrollController searchController = ItemScrollController(); + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + final List searchedViews = []; + final List recentViews = []; + int selectedIndex = 0; + + final VoidCallback? onEscape; + final VoidCallback? onEnter; + final VoidCallback? onDataRefresh; + + String get searchText => textEditingController.text; + + bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText); + + bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty; + + ViewPB get currentSearchedView => searchedViews[selectedIndex]; + + ViewPB get currentRecentView => recentViews[selectedIndex]; + + void dispose() { + textEditingController.dispose(); + focusNode.dispose(); + searchedViews.clear(); + recentViews.clear(); + } + + Widget buildTextField({ + bool autofocus = false, + bool showError = false, + required BuildContext context, + }) { + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: autofocus, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + if (text.isEmpty) { + searchedViews.clear(); + selectedIndex = 0; + onDataRefresh?.call(); + } else { + searchViews(text); + } + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkInputHint.tr(), + context, + showErrorBorder: showError, + ), + ); + } + + Widget buildResultContainer({ + EdgeInsetsGeometry? margin, + required BuildContext context, + VoidCallback? onLinkSelected, + ValueChanged? onPageLinkSelected, + double width = 320.0, + }) { + return onSearchResult( + onEmpty: () => SizedBox.shrink(), + onLink: () => Container( + height: 48, + width: width, + padding: EdgeInsets.all(8), + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: FlowyButton( + leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m), + isSelected: true, + text: FlowyText.regular( + searchText, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + onTap: onLinkSelected, + ), + ), + onRecentViews: () => Container( + width: width, + height: recentViews.length.clamp(1, 5) * 32.0 + 48, + margin: margin, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + LocaleKeys.inlineActions_recentPages.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Flexible( + child: ListView.builder( + itemBuilder: (context, index) { + final currentView = recentViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + itemCount: recentViews.length, + ), + ), + ], + ), + ), + onSearchViews: () => Container( + width: width, + height: searchedViews.length.clamp(1, 5) * 32.0 + 16, + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: ScrollablePositionedList.builder( + padding: EdgeInsets.all(8), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: searchedViews.length, + itemScrollController: searchController, + initialScrollIndex: max(0, selectedIndex), + itemBuilder: (context, index) { + final currentView = searchedViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + ), + ), + ); + } + + Widget buildPageItem( + ViewPB view, + bool isSelected, + ValueChanged? onSubmittedPageLink, + ) { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + final isCurrent = initialViewId == view.id; + return SizedBox( + height: 32, + child: FlowyButton( + isSelected: isSelected, + leftIcon: buildIcon(view, padding: EdgeInsets.zero), + text: FlowyText.regular( + displayName, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () => onSubmittedPageLink?.call(view), + ), + ); + } + + Widget buildIcon( + ViewPB view, { + EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4), + }) { + if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20)); + final iconData = view.icon.toEmojiIconData(); + return Padding( + padding: padding, + child: RawEmojiIconWidget( + emoji: iconData, + emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, + lineHeight: 1, + ), + ); + } + + void requestFocus() => focusNode.requestFocus(); + + void unfocus() => focusNode.unfocus(); + + void updateText(String text) => textEditingController.text = text; + + T onSearchResult({ + required ValueGetter onLink, + required ValueGetter onRecentViews, + required ValueGetter onSearchViews, + required ValueGetter onEmpty, + }) { + if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) { + return onEmpty.call(); + } + if (searchedViews.isEmpty && searchText.isNotEmpty) { + return onLink.call(); + } + if (searchedViews.isEmpty) return onRecentViews.call(); + return onSearchViews.call(); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + int index = selectedIndex; + if (key.logicalKey == LogicalKeyboardKey.escape) { + onEscape?.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowUp) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index - 1; + if (result < 0) result = recentViews.length - 1; + return result; + }, + onSearchViews: () { + int result = index - 1; + if (result < 0) result = searchedViews.length - 1; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowDown) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index + 1; + if (result >= recentViews.length) result = 0; + return result; + }, + onSearchViews: () { + int result = index + 1; + if (result >= searchedViews.length) result = 0; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onEnter?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future searchRecentViews() async { + final recentService = getIt(); + final sectionViews = await recentService.recentViews(); + final views = sectionViews + .unique((e) => e.item.id) + .map((e) => e.item) + .where((e) => e.id != currentViewId) + .take(5) + .toList(); + recentViews.clear(); + recentViews.addAll(views); + selectedIndex = 0; + onDataRefresh?.call(); + } + + Future searchViews(String search) async { + final viewResult = await ViewBackendService.getAllViews(); + final allViews = viewResult + .toNullable() + ?.items + .where( + (view) => + (view.id != currentViewId) && + (view.name.toLowerCase().contains(search.toLowerCase()) || + (view.name.isEmpty && search.isEmpty) || + (view.name.isEmpty && + LocaleKeys.menuAppHeader_defaultNewPageName + .tr() + .toLowerCase() + .contains(search.toLowerCase()))), + ) + .take(10) + .toList(); + searchedViews.clear(); + searchedViews.addAll(allViews ?? []); + selectedIndex = 0; + onDataRefresh?.call(); + } + + void refreshIndex(int index) { + selectedIndex = index; + onDataRefresh?.call(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart new file mode 100644 index 0000000000..cabc00a312 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class LinkStyle { + static const textTertiary = Color(0xFF99A1A8); + static const textStatusError = Color(0xffE71D32); + static const fillThemeThick = Color(0xFF00B5FF); + static const shadowMedium = Color(0x1F22251F); + static const textPrimary = Color(0xFF1F2329); + + static Color borderColor(BuildContext context) => + Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD); + + static InputDecoration buildLinkTextFieldInputDecoration( + String hintText, + BuildContext context, { + bool showErrorBorder = false, + }) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: borderColor(context)), + ); + final enableBorder = border.copyWith( + borderSide: BorderSide( + color: showErrorBorder + ? LinkStyle.textStatusError + : LinkStyle.fillThemeThick, + ), + ); + const hintStyle = TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + color: LinkStyle.textTertiary, + ); + return InputDecoration( + hintText: hintText, + hintStyle: hintStyle, + contentPadding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + isDense: true, + border: border, + enabledBorder: border, + focusedBorder: enableBorder, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart new file mode 100644 index 0000000000..7598a2b657 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class ToolbarAnimationWidget extends StatefulWidget { + const ToolbarAnimationWidget({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 150), + this.beginOpacity = 0.0, + this.endOpacity = 1.0, + this.beginScaleFactor = 0.95, + this.endScaleFactor = 1.0, + }); + + final Widget child; + final Duration duration; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; + + @override + State createState() => _ToolbarAnimationWidgetState(); +} + +class _ToolbarAnimationWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation fadeAnimation; + late Animation scaleAnimation; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + fadeAnimation = _buildFadeAnimation(); + scaleAnimation = _buildScaleAnimation(); + controller.forward(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (_, child) => Opacity( + opacity: fadeAnimation.value, + child: Transform.scale( + scale: scaleAnimation.value, + child: child, + ), + ), + child: widget.child, + ); + } + + Animation _buildFadeAnimation() { + return Tween( + begin: widget.beginOpacity, + end: widget.endOpacity, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ), + ); + } + + Animation _buildScaleAnimation() { + return Tween( + begin: widget.beginScaleFactor, + end: widget.endScaleFactor, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ), + ); + } +} 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 a37ed29150..6c09ca6a28 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 @@ -30,6 +30,10 @@ class ErrorBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -43,6 +47,7 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -81,6 +86,7 @@ class _ErrorBlockComponentWidgetState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } @@ -148,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State void _copyBlockContent() { showToastNotification( - context, message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), ); 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 52fc2e717f..fe50224caa 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/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; @@ -95,6 +96,17 @@ enum FileUrlType { return 2; } } + + FileUploadTypePB toFileUploadTypePB() { + switch (this) { + case FileUrlType.local: + return FileUploadTypePB.LocalFile; + case FileUrlType.network: + return FileUploadTypePB.NetworkFile; + case FileUrlType.cloud: + return FileUploadTypePB.CloudFile; + } + } } Node fileNode({ @@ -141,6 +153,7 @@ class FileBlockComponent extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -303,6 +316,7 @@ class FileBlockComponentState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } 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 d79d5a1994..99529b3b8e 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 @@ -1,15 +1,17 @@ -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/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FileBlockMenu extends StatefulWidget { @@ -59,11 +61,37 @@ class _FileBlockMenuState extends State { final dateFormat = context.read().state.dateFormat; final urlType = FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]); - + final fileUploadType = urlType.toFileUploadTypePB(); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.download_s), + name: LocaleKeys.button_download.tr(), + onTap: () { + final userProfile = widget.editorState.document.root.context + ?.read() + .state + .userProfilePB; + final url = widget.node.attributes[FileBlockKeys.url]; + final name = widget.node.attributes[FileBlockKeys.name]; + if (url != null && name != null) { + final filePB = MediaFilePB( + url: url, + name: name, + uploadType: fileUploadType, + ); + downloadMediaFile( + context, + filePB, + userProfile: userProfile, + ); + } + }, + ), + const VSpace(4), HoverButton( itemHeight: 20, leftIcon: const FlowySvg(FlowySvgs.edit_s), 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 75f02f5079..4ef680d1b9 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 @@ -141,7 +141,8 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { height: 32, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, margin: const EdgeInsets.all(5), text: FlowyText( @@ -295,7 +296,7 @@ class _FileUploadNetworkState extends State<_FileUploadNetwork> { child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: - Theme.of(context).colorScheme.primary.withOpacity(0.9), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, margin: const EdgeInsets.all(5), text: FlowyText( 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 3ab93b4c95..69791f78b7 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 @@ -14,8 +14,8 @@ import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; @@ -104,10 +104,10 @@ Future downloadMediaFile( await afLaunchUrlString(file.url); } else { if (userProfile == null) { - return showToastNotification( - context, + showToastNotification( message: LocaleKeys.grid_media_downloadFailedToken.tr(), ); + return; } final uri = Uri.parse(file.url); @@ -128,14 +128,12 @@ Future downloadMediaFile( if (result != null && context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); @@ -159,13 +157,11 @@ Future downloadMediaFile( if (context.mounted) { showToastNotification( - context, message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( - context, type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); @@ -188,8 +184,8 @@ Future insertLocalFile( final fileType = file.fileType.toMediaFileTypePB(); // Check upload type - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; String? path; String? errorMsg; @@ -233,8 +229,8 @@ Future insertLocalFiles( if (files.every((f) => f.path.isEmpty)) return; // Check upload type - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; for (final file in files) { final fileType = file.fileType.toMediaFileTypePB(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart index f716c107df..f4c7a76c0e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart @@ -138,7 +138,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { radius: Corners.s8Border, backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: - Theme.of(context).colorScheme.primary.withOpacity(0.9), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_uploadMobileGallery.tr(), @@ -155,7 +155,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { radius: Corners.s8Border, backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: - Theme.of(context).colorScheme.primary.withOpacity(0.9), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_uploadMobile.tr(), @@ -241,7 +241,7 @@ class _FileUploadNetworkState extends State<_FileUploadNetwork> { child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: - Theme.of(context).colorScheme.primary.withOpacity(0.9), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), radius: Corners.s8Border, margin: const EdgeInsets.all(5), text: FlowyText( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart index ef943a7ef7..e0f63e57c7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart @@ -9,8 +9,6 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart'; -import 'package:appflowy_backend/log.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'; @@ -21,68 +19,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; -const kFontToolbarItemId = 'editor.font'; - -@visibleForTesting -const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem'); - -final customizeFontToolbarItem = ToolbarItem( - id: kFontToolbarItemId, - group: 4, - isActive: onlyShowInTextType, - builder: (context, editorState, highlightColor, _, tooltipBuilder) { - final selection = editorState.selection!; - final popoverController = PopoverController(); - final String? currentFontFamily = editorState - .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); - - Widget child = FontFamilyDropDown( - currentFontFamily: currentFontFamily ?? '', - offset: const Offset(0, 12), - popoverController: popoverController, - onOpen: () => keepEditorFocusNotifier.increase(), - onClose: () => keepEditorFocusNotifier.decrease(), - onFontFamilyChanged: (fontFamily) async { - popoverController.close(); - try { - await editorState.formatDelta(selection, { - AppFlowyRichTextKeys.fontFamily: fontFamily, - }); - } catch (e) { - Log.error('Failed to set font family: $e'); - } - }, - onResetFont: () async { - popoverController.close(); - await editorState - .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); - }, - child: FlowyButton( - key: kFontFamilyToolbarItemKey, - useIntrinsicWidth: true, - hoverColor: Colors.grey.withOpacity(0.3), - onTap: () => popoverController.show(), - text: const FlowySvg( - FlowySvgs.font_family_s, - size: Size.square(16.0), - color: Colors.white, - ), - ), - ); - - if (tooltipBuilder != null) { - child = tooltipBuilder( - context, - kFontToolbarItemId, - LocaleKeys.document_plugins_fonts.tr(), - child, - ); - } - - return child; - }, -); - class ThemeFontFamilySetting extends StatefulWidget { const ThemeFontFamilySetting({ super.key, @@ -163,6 +99,11 @@ class _FontFamilyDropDownState extends State { popoverKey: ThemeFontFamilySetting.popoverKey, popoverController: widget.popoverController, currentValue: currentValue, + margin: EdgeInsets.zero, + boxConstraints: const BoxConstraints( + maxWidth: 240, + maxHeight: 420, + ), onClose: () { query.value = ''; widget.onClose?.call(); @@ -171,27 +112,25 @@ class _FontFamilyDropDownState extends State { child: widget.child, popupBuilder: (_) { widget.onOpen?.call(); - return CustomScrollView( - shrinkWrap: true, - slivers: [ - SliverPadding( - padding: const EdgeInsets.only(right: 8), - sliver: SliverToBoxAdapter( - child: FlowyTextField( - key: ThemeFontFamilySetting.textFieldKey, - hintText: - LocaleKeys.settings_appearance_fontFamily_search.tr(), - autoFocus: false, - debounceDuration: const Duration(milliseconds: 300), - onChanged: (value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyTextField( + key: ThemeFontFamilySetting.textFieldKey, + hintText: LocaleKeys.settings_appearance_fontFamily_search.tr(), + autoFocus: false, + debounceDuration: const Duration(milliseconds: 300), + onChanged: (value) { + setState(() { query.value = value; - }, - ), + }); + }, ), ), - const SliverToBoxAdapter( - child: SizedBox(height: 4), - ), + Container(height: 1, color: Theme.of(context).dividerColor), ValueListenableBuilder( valueListenable: query, builder: (context, value, child) { @@ -206,14 +145,32 @@ class _FontFamilyDropDownState extends State { .sorted((a, b) => levenshtein(a, b)) .toList(); } - return SliverFixedExtentList.builder( - itemBuilder: (context, index) => _fontFamilyItemButton( - context, - getGoogleFontSafely(displayed[index]), - ), - itemCount: displayed.length, - itemExtent: 32, - ); + return displayed.length >= 10 + ? Flexible( + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + itemBuilder: (context, index) => + _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + itemCount: displayed.length, + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: List.generate( + displayed.length, + (index) => _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + ), + ), + ); }, ), ], @@ -233,16 +190,18 @@ class _FontFamilyDropDownState extends State { waitDuration: const Duration(milliseconds: 150), child: SizedBox( key: ValueKey(buttonFontFamily), - height: 32, + height: 36, child: FlowyButton( onHover: (_) => FocusScope.of(context).unfocus(), - text: FlowyText.medium( + text: FlowyText( buttonFontFamily.fontFamilyDisplayName, fontFamily: buttonFontFamily, + figmaLineHeight: 20, + fontWeight: FontWeight.w400, ), rightIcon: buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() - ? const FlowySvg(FlowySvgs.check_s) + ? const FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { if (widget.onFontFamilyChanged != null) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart index 23e117bbf4..2c5062d408 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart @@ -1,7 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; -import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; @@ -93,7 +92,6 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { builder: (context, state) { final appearance = context.read().state; return Container( - padding: EditorStyleCustomizer.documentPaddingWithOptionMenu, constraints: BoxConstraints(maxWidth: width), child: Theme( data: Theme.of(context).copyWith( @@ -243,6 +241,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { return _moveCursorToNextLine(event.logicalKey); } else if (event.logicalKey == LogicalKeyboardKey.escape) { return _exitEditing(); + } else if (event.logicalKey == LogicalKeyboardKey.tab) { + return KeyEventResult.handled; } return KeyEventResult.ignored; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart index a565fd4e43..16605367ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -160,6 +160,7 @@ class _DocumentCoverWidgetState extends State { final offset = _calculateIconLeft(context, constraints); return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( children: [ @@ -187,49 +188,65 @@ class _DocumentCoverWidgetState extends State { onChangeCover: (type, details) => _saveIconOrCover(cover: (type, details)), ), - _buildCoverIcon( - context, - constraints, - offset, - ), + _buildAlignedCoverIcon(context), ], ), - Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: MouseRegion( - onEnter: (event) => isCoverTitleHovered.value = true, - onExit: (event) => isCoverTitleHovered.value = false, - child: CoverTitle( - view: widget.view, - ), - ), - ), + _buildAlignedTitle(context), ], ); }, ); } - Widget _buildCoverIcon( - BuildContext context, - BoxConstraints constraints, - double offset, - ) { - if (!hasIcon || offset == 0) { + Widget _buildAlignedTitle(BuildContext context) { + return Center( + child: Container( + constraints: BoxConstraints( + maxWidth: widget.editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: widget.editorState.editorStyle.padding + + const EdgeInsets.symmetric(horizontal: 44), + child: MouseRegion( + onEnter: (event) => isCoverTitleHovered.value = true, + onExit: (event) => isCoverTitleHovered.value = false, + child: CoverTitle( + view: widget.view, + ), + ), + ), + ); + } + + Widget _buildAlignedCoverIcon(BuildContext context) { + if (!hasIcon) { return const SizedBox.shrink(); } return Positioned( - // if hasCover, there shouldn't be icons present so the icon can - // be closer to the bottom. - left: offset, bottom: hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight, - child: DocumentIcon( - editorState: widget.editorState, - node: widget.node, - icon: viewIcon, - documentId: view.id, - onChangeIcon: (icon) => _saveIconOrCover(icon: icon), + left: 0, + right: 0, + child: Center( + child: Container( + constraints: BoxConstraints( + maxWidth: + widget.editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: widget.editorState.editorStyle.padding + + const EdgeInsets.symmetric(horizontal: 44), + child: Row( + children: [ + DocumentIcon( + editorState: widget.editorState, + node: widget.node, + icon: viewIcon, + documentId: view.id, + onChangeIcon: (icon) => _saveIconOrCover(icon: icon), + ), + Spacer(), + ], + ), + ), ), ); } @@ -640,7 +657,7 @@ class DocumentCoverState extends State { fillColor: Theme.of(context) .colorScheme .onSurfaceVariant - .withOpacity(0.5), + .withValues(alpha: 0.5), height: 32, title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), @@ -726,8 +743,10 @@ class DocumentCoverState extends State { onPressed: () => popoverController.show(), hoverColor: Theme.of(context).colorScheme.surface, textColor: Theme.of(context).colorScheme.tertiary, - fillColor: - Theme.of(context).colorScheme.surface.withOpacity(0.5), + fillColor: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.5), title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), ), @@ -821,8 +840,8 @@ class DeleteCoverButton extends StatelessWidget { @override Widget build(BuildContext context) { final fillColor = UniversalPlatform.isDesktopOrWeb - ? Theme.of(context).colorScheme.surface.withOpacity(0.5) - : Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5); + ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.5) + : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5); final svgColor = UniversalPlatform.isDesktopOrWeb ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.onPrimary; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart index 1d7f43004c..cda76233d6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart @@ -3,9 +3,13 @@ import 'dart:io'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:string_validator/string_validator.dart'; @@ -38,7 +42,10 @@ class _EmojiIconWidgetState extends State { child: Container( decoration: BoxDecoration( color: !hover - ? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5) + ? Theme.of(context) + .colorScheme + .inverseSurface + .withValues(alpha: 0.5) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), @@ -59,76 +66,101 @@ class _EmojiIconWidgetState extends State { } } -class RawEmojiIconWidget extends StatelessWidget { +class RawEmojiIconWidget extends StatefulWidget { const RawEmojiIconWidget({ super.key, required this.emoji, required this.emojiSize, this.enableColor = true, + this.lineHeight, }); final EmojiIconData emoji; final double emojiSize; final bool enableColor; + final double? lineHeight; + + @override + State createState() => _RawEmojiIconWidgetState(); +} + +class _RawEmojiIconWidgetState extends State { + UserProfilePB? userProfile; + + EmojiIconData get emoji => widget.emoji; + + @override + void initState() { + super.initState(); + loadUserProfile(); + } + + @override + void didUpdateWidget(RawEmojiIconWidget oldWidget) { + super.didUpdateWidget(oldWidget); + loadUserProfile(); + } @override Widget build(BuildContext context) { final defaultEmoji = SizedBox( - width: emojiSize, + width: widget.emojiSize, child: EmojiText( emoji: '❓', - fontSize: emojiSize, + fontSize: widget.emojiSize, textAlign: TextAlign.center, ), ); try { - switch (emoji.type) { + switch (widget.emoji.type) { case FlowyIconType.emoji: - return SizedBox( - width: emojiSize, - child: EmojiText( - emoji: emoji.emoji, - fontSize: emojiSize, - textAlign: TextAlign.justify, - ), + return FlowyText.emoji( + widget.emoji.emoji, + fontSize: widget.emojiSize, + textAlign: TextAlign.justify, + lineHeight: widget.lineHeight, ); case FlowyIconType.icon: - IconsData iconData = IconsData.fromJson(jsonDecode(emoji.emoji)); - if (!enableColor) { + IconsData iconData = IconsData.fromJson( + jsonDecode(widget.emoji.emoji), + ); + if (!widget.enableColor) { iconData = iconData.noColor(); } - /// Under the same width conditions, icons on macOS seem to appear - /// larger than emojis, so 0.9 is used here to slightly reduce the - /// size of the icons - final iconSize = Platform.isMacOS ? emojiSize * 0.9 : emojiSize; + final iconSize = widget.emojiSize; return IconWidget( iconsData: iconData, size: iconSize, ); case FlowyIconType.custom: - final url = emoji.emoji; + final url = widget.emoji.emoji; + final isSvg = url.endsWith('.svg'); + final hasUserProfile = userProfile != null; if (isURL(url)) { - return SizedBox.square( - dimension: emojiSize, - child: FutureBuilder( - future: UserBackendService.getCurrentUserProfile(), - builder: (context, value) { - final userProfile = value.data?.fold( - (userProfile) => userProfile, - (l) => null, - ); - if (userProfile == null) return const SizedBox.shrink(); - return FlowyNetworkImage( - url: url, - width: emojiSize, - height: emojiSize, - userProfilePB: userProfile, - errorWidgetBuilder: (context, url, error) => - const SizedBox.shrink(), - ); + Widget child = const SizedBox.shrink(); + if (isSvg) { + child = FlowyNetworkSvg( + url, + headers: + hasUserProfile ? _buildRequestHeader(userProfile!) : {}, + width: widget.emojiSize, + height: widget.emojiSize, + ); + } else if (hasUserProfile) { + child = FlowyNetworkImage( + url: url, + width: widget.emojiSize, + height: widget.emojiSize, + userProfilePB: userProfile, + errorWidgetBuilder: (context, url, error) { + return const SizedBox.shrink(); }, - ), + ); + } + return SizedBox.square( + dimension: widget.emojiSize, + child: child, ); } final imageFile = File(url); @@ -136,20 +168,52 @@ class RawEmojiIconWidget extends StatelessWidget { throw PathNotFoundException(url, const OSError()); } return SizedBox.square( - dimension: emojiSize, - child: Image.file( - imageFile, - fit: BoxFit.cover, - width: emojiSize, - height: emojiSize, - ), + dimension: widget.emojiSize, + child: isSvg + ? SvgPicture.file( + File(url), + width: widget.emojiSize, + height: widget.emojiSize, + ) + : Image.file( + imageFile, + fit: BoxFit.cover, + width: widget.emojiSize, + height: widget.emojiSize, + ), ); - default: - return defaultEmoji; } } catch (e) { Log.error("Display widget error: $e"); return defaultEmoji; } } + + Map _buildRequestHeader(UserProfilePB userProfilePB) { + final header = {}; + final token = userProfilePB.token; + try { + final decodedToken = jsonDecode(token); + header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; + } catch (e) { + Log.error('Unable to decode token: $e'); + } + return header; + } + + Future loadUserProfile() async { + if (userProfile != null) return; + if (emoji.type == FlowyIconType.custom) { + final userProfile = + (await UserBackendService.getCurrentUserProfile()).fold( + (userProfile) => userProfile, + (l) => null, + ); + if (mounted) { + setState(() { + this.userProfile = userProfile; + }); + } + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart index c5e1758435..3d0c199ea2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart @@ -140,7 +140,7 @@ class HeadingPopup extends StatelessWidget { }, child: FlowyButton( useIntrinsicWidth: true, - hoverColor: Colors.grey.withOpacity(0.3), + hoverColor: Colors.grey.withValues(alpha: 0.3), text: child, ), ); @@ -209,7 +209,7 @@ class HeadingButton extends StatelessWidget { Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, - hoverColor: Colors.grey.withOpacity(0.3), + hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: onTap, text: FlowyTooltip( message: tooltip, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart index 12d7fd4da6..7f0105134d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart @@ -9,8 +9,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; @@ -19,6 +20,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:saver_gallery/saver_gallery.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -121,6 +123,7 @@ class CustomImageBlockComponent extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.showMenu = false, this.menuBuilder, @@ -224,6 +227,7 @@ class CustomImageBlockComponentState extends State delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, supportTypes: const [BlockSelectionType.block], child: child, ); @@ -233,6 +237,7 @@ class CustomImageBlockComponentState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } @@ -309,7 +314,7 @@ class CustomImageBlockComponentState extends State }) { final imageBox = imageKey.currentContext?.findRenderObject(); if (imageBox is RenderBox) { - return Offset.zero & imageBox.size; + return padding.topLeft & imageBox.size; } return Rect.zero; } @@ -373,7 +378,6 @@ class CustomImageBlockComponentState extends State onTap: () async { context.pop(); showToastNotification( - context, message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); await getIt().setPlainText(url); @@ -388,11 +392,8 @@ class CustomImageBlockComponentState extends State ), onTap: () async { context.pop(); - showSnackBarMessage( - context, - LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), - ); - await getIt().setPlainText(url); + // save the image to the photo library + await _saveImageToGallery(url); }, ), ]; @@ -413,4 +414,27 @@ class CustomImageBlockComponentState extends State return true; } + + Future _saveImageToGallery(String url) async { + final permission = await PermissionChecker.checkPhotoPermission(context); + if (!permission) { + return; + } + + final imageFile = await CustomImageCacheManager().getSingleFile(url); + if (imageFile.existsSync()) { + final result = await SaverGallery.saveImage( + imageFile.readAsBytesSync(), + fileName: imageFile.basename, + skipIfExists: false, + ); + if (mounted) { + showToastNotification( + message: result.isSuccess + ? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr() + : LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(), + ); + } + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart index 21caa81297..d11d943066 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; @@ -59,7 +60,7 @@ class _ImageMenuState extends State { BoxShadow( blurRadius: 5, spreadRadius: 1, - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(4.0), @@ -116,14 +117,12 @@ class _ImageMenuState extends State { if (mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), ); } } catch (e) { if (mounted) { showToastNotification( - context, message: LocaleKeys.message_copy_fail.tr(), type: ToastificationType.error, ); @@ -145,7 +144,8 @@ class _ImageMenuState extends State { showDialog( context: context, builder: (_) => InteractiveImageViewer( - userProfile: context.read().userProfile, + userProfile: context.read()?.userProfile ?? + context.read().state.userProfilePB, imageProvider: AFBlockImageProvider( images: [ ImageBlockData( 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 1105480bf3..eb8ddba0b8 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 @@ -1,8 +1,5 @@ import 'dart:io'; -import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; @@ -12,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/mult import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.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'; @@ -24,11 +22,12 @@ import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../image_render.dart'; -const _thumbnailItemSize = 100.0; +const _thumbnailItemSize = 100.0, _imageHeight = 400.0; class ImageBrowserLayout extends ImageBlockMultiLayout { const ImageBrowserLayout({ @@ -54,18 +53,19 @@ class _ImageBrowserLayoutState extends State { @override void initState() { super.initState(); - _userProfile = context.read()?.userProfile; + _userProfile = context.read()?.userProfile ?? + context.read().state.userProfilePB; } @override Widget build(BuildContext context) { - return Stack( + final gallery = Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: 400, + height: _imageHeight, width: MediaQuery.of(context).size.width, child: GestureDetector( onDoubleTap: () => _openInteractiveViewer(context), @@ -136,7 +136,8 @@ class _ImageBrowserLayoutState extends State { ), DecoratedBox( decoration: BoxDecoration( - color: Colors.white.withOpacity(0.5), + color: + Colors.white.withValues(alpha: 0.5), ), child: Center( child: FlowyText( @@ -225,8 +226,9 @@ class _ImageBrowserLayoutState extends State { ? const SizedBox.shrink() : SizedBox.expand( child: DecoratedBox( - decoration: - BoxDecoration(color: Colors.white.withOpacity(0.5)), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + ), child: Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -256,6 +258,10 @@ class _ImageBrowserLayoutState extends State { ), ], ); + return SizedBox( + height: _imageHeight + _thumbnailItemSize + 20, + child: gallery, + ); } void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog( @@ -386,8 +392,8 @@ class _ThumbnailItemState extends State { child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( - backgroundColor: Colors.black.withOpacity(0.6), - hoverColor: Colors.black.withOpacity(0.9), + backgroundColor: Colors.black.withValues(alpha: 0.6), + hoverColor: Colors.black.withValues(alpha: 0.9), ), child: const Padding( padding: EdgeInsets.all(4), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart index 00919a20cc..43d1c7ae36 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; +import 'package:flutter/material.dart'; abstract class ImageBlockMultiLayout extends StatefulWidget { const ImageBlockMultiLayout({ @@ -65,7 +64,6 @@ class ImageLayoutRender extends StatelessWidget { isLocalMode: isLocalMode, ); case MultiImageLayout.browser: - default: return ImageBrowserLayout( node: node, editorState: editorState, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart index 272b492835..51da975938 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart @@ -13,10 +13,11 @@ import 'package:universal_platform/universal_platform.dart'; const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; -Node multiImageNode() => Node( +Node multiImageNode({List? images}) => Node( type: MultiImageBlockKeys.type, attributes: { - MultiImageBlockKeys.images: MultiImageData(images: []).toJson(), + MultiImageBlockKeys.images: + MultiImageData(images: images ?? []).toJson(), MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), }, ); @@ -81,6 +82,7 @@ class MultiImageBlockComponent extends BlockComponentStatefulWidget { this.menuBuilder, super.configuration = const BlockComponentConfiguration(), super.actionBuilder, + super.actionTrailingBuilder, }); final bool showMenu; @@ -189,6 +191,7 @@ class MultiImageBlockComponentState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart index 8abeaf9e99..dc95054e81 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -1,8 +1,5 @@ import 'dart:io'; -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/application/document_bloc.dart'; @@ -15,6 +12,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/log.dart'; @@ -25,6 +23,8 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; @@ -103,7 +103,7 @@ class _MultiImageMenuState extends State { BoxShadow( blurRadius: 5, spreadRadius: 1, - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(4.0), @@ -217,9 +217,8 @@ class _MultiImageMenuState extends State { Clipboard.setData( ClipboardData(text: images[widget.indexNotifier.value].url), ); - showSnackBarMessage( - context, - LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + showToastNotification( + message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart index 021476aa4e..da37945bf5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -70,8 +70,11 @@ class _ResizableImageState extends State { @override void initState() { super.initState(); + imageWidth = widget.width; - _userProfilePB = context.read()?.userProfile; + + _userProfilePB = context.read()?.userProfile ?? + context.read().state.userProfilePB; } @override @@ -97,11 +100,6 @@ class _ResizableImageState extends State { Widget child; final src = widget.src; if (isURL(src)) { - // load network image - if (widget.type == CustomImageType.internal && _userProfilePB == null) { - return _buildLoading(context); - } - _cacheImage = FlowyNetworkImage( url: widget.src, width: imageWidth - moveDistance, @@ -228,7 +226,7 @@ class _ResizableImageState extends State { child: Container( height: 40, decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), + color: Colors.black.withValues(alpha: 0.5), borderRadius: const BorderRadius.all( Radius.circular(5.0), ), @@ -264,7 +262,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all(color: Colors.grey.withOpacity(0.6)), + border: Border.all(color: Colors.grey.withValues(alpha: 0.6)), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -282,7 +280,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { FlowyText( error, textAlign: TextAlign.center, - color: Theme.of(context).hintColor.withOpacity(0.6), + color: Theme.of(context).hintColor.withValues(alpha: 0.6), fontSize: 10, maxLines: 2, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart index caddbf464a..28dccad72d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart @@ -57,7 +57,8 @@ class _EmbedImageUrlWidgetState extends State { width: 300, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, radius: UniversalPlatform.isMobile ? BorderRadius.circular(8) : null, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart index 061a6fe320..cd3779cb6c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart @@ -9,7 +9,7 @@ const _kInlineMathEquationToolbarItemId = 'editor.inline_math_equation'; final ToolbarItem inlineMathEquationItem = ToolbarItem( id: _kInlineMathEquationToolbarItemId, - group: 2, + group: 4, isActive: onlyShowInSingleSelectionAndTextType, builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart new file mode 100644 index 0000000000..baf9702a36 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -0,0 +1,310 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'link_embed_menu.dart'; + +class LinkEmbedKeys { + const LinkEmbedKeys._(); + static const String previewType = 'preview_type'; + static const String embed = 'embed'; + static const String align = 'align'; +} + +Node linkEmbedNode({required String url}) => Node( + type: LinkPreviewBlockKeys.type, + attributes: { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }, + ); + +class LinkEmbedBlockComponent extends BlockComponentStatefulWidget { + const LinkEmbedBlockComponent({ + super.key, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + required super.node, + }); + + @override + DefaultSelectableMixinState createState() => + LinkEmbedBlockComponentState(); +} + +class LinkEmbedBlockComponentState + extends DefaultSelectableMixinState + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? ''; + + LinkLoadingStatus status = LinkLoadingStatus.loading; + final parser = LinkParser(); + late LinkInfo linkInfo = LinkInfo(url: url); + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + parser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget result = MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + child: buildChild(context), + ); + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + + result = Padding(padding: newPadding, child: result); + + if (widget.showActions && widget.actionBuilder != null) { + result = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: result, + ); + } + return result; + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + fillSceme = theme.fillColorScheme, + borderScheme = theme.borderColorScheme; + Widget child; + final isIdle = status == LinkLoadingStatus.idle; + if (isIdle) { + child = buildContent(context); + } else { + child = buildErrorLoadingWidget(context); + } + return Container( + height: 450, + key: widgetKey, + decoration: BoxDecoration( + color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover, + borderRadius: BorderRadius.all(Radius.circular(16)), + border: Border.all(color: borderScheme.greyTertiary), + ), + child: Stack( + children: [ + child, + buildMenu(context), + ], + ), + ); + } + + Widget buildMenu(BuildContext context) { + return Positioned( + top: 12, + right: 12, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + if (!showActions) return SizedBox.shrink(); + return LinkEmbedMenu( + editorState: context.read(), + node: node, + onReload: () { + setState(() { + status = LinkLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + ); + }, + ), + ); + } + + Widget buildContent(BuildContext context) { + final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: MediaQuery.of(context).size.width, + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: Center( + child: linkInfo.buildIconWidget(size: Size.square(32)), + ), + ), + HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + linkInfo.siteName ?? '', + color: textScheme.primary, + fontSize: 14, + figmaLineHeight: 20, + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + ), + VSpace(4), + FlowyText.regular( + url, + color: textScheme.secondary, + fontSize: 12, + figmaLineHeight: 16, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget buildErrorLoadingWidget(BuildContext context) { + final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme; + final isLoading = status == LinkLoadingStatus.loading; + return isLoading + ? Center( + child: SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + FlowySvgs.embed_error_xl.path, + ), + VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: '$url ', + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w700, + ), + ), + TextSpan( + text: LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_unableToDisplay + .tr(), + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart new file mode 100644 index 0000000000..c3d2aebbcc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -0,0 +1,354 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import 'link_embed_block_component.dart'; + +class LinkEmbedMenu extends StatefulWidget { + const LinkEmbedMenu({ + super.key, + required this.node, + required this.editorState, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, + }); + + final Node node; + final EditorState editorState; + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; + + @override + State createState() => _LinkEmbedMenuState(); +} + +class _LinkEmbedMenuState extends State { + final turnintoController = PopoverController(); + final moreOptionController = PopoverController(); + int turnintoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0; + final moreOptionButtonKey = GlobalKey(); + bool get isTurnIntoShowing => turnintoMenuNum > 0; + bool get isMoreOptionShowing => moreOptionNum > 0; + bool get isAlignMenuShowing => alignMenuNum > 0; + + Node get node => widget.node; + EditorState get editorState => widget.editorState; + + String get url => node.attributes[LinkPreviewBlockKeys.url] ?? ''; + + @override + void dispose() { + super.dispose(); + turnintoController.close(); + moreOptionController.close(); + widget.onMenuHided.call(); + } + + @override + Widget build(BuildContext context) { + return buildChild(); + } + + Widget buildChild() { + final theme = AppFlowyTheme.of(context), + iconScheme = theme.iconColorScheme, + fillScheme = theme.fillColorScheme; + + return Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: fillScheme.primaryAlpha80, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // FlowyIconButton( + // icon: FlowySvg( + // FlowySvgs.embed_fullscreen_m, + // color: iconScheme.tertiary, + // ), + // tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(), + // preferBelow: false, + // onPressed: () {}, + // ), + FlowyIconButton( + icon: FlowySvg( + FlowySvgs.toolbar_link_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + onPressed: () => copyLink(context), + ), + buildconvertBotton(), + buildMoreOptionBotton(), + ], + ), + ); + } + + Widget buildconvertBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: turnintoController, + onOpen: () { + keepEditorFocusNotifier.increase(); + turnintoMenuNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + turnintoMenuNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg( + FlowySvgs.turninto_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + onPressed: showTurnIntoMenu, + ), + ); + } + + Widget buildConvertMenu() { + final types = LinkEmbedConvertCommand.values; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + if (command == LinkEmbedConvertCommand.toBookmark) { + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: '', + }); + editorState.apply(transaction); + } else if (command == LinkEmbedConvertCommand.toMention) { + convertUrlPreviewNodeToMention(editorState, node); + } else if (command == LinkEmbedConvertCommand.toURL) { + convertUrlPreviewNodeToLink(editorState, node); + } + }, + ), + ); + }), + ), + ); + } + + Widget buildMoreOptionBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: moreOptionController, + onOpen: () { + keepEditorFocusNotifier.increase(); + moreOptionNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + moreOptionNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildMoreOptionMenu(), + child: FlowyIconButton( + key: moreOptionButtonKey, + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(), + preferBelow: false, + onPressed: showMoreOptionMenu, + ), + ); + } + + Widget buildMoreOptionMenu() { + final types = LinkEmbedMenuCommand.values; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onEmbedMenuCommand(command), + ), + ); + }), + ), + ); + } + + void showTurnIntoMenu() { + keepEditorFocusNotifier.increase(); + turnintoController.show(); + checkToShowMenu(); + turnintoMenuNum++; + if (isMoreOptionShowing) closeMoreOptionMenu(); + } + + void closeTurnIntoMenu() { + turnintoController.close(); + checkToHideMenu(); + } + + void showMoreOptionMenu() { + keepEditorFocusNotifier.increase(); + moreOptionController.show(); + checkToShowMenu(); + moreOptionNum++; + if (isTurnIntoShowing) closeTurnIntoMenu(); + } + + void closeMoreOptionMenu() { + moreOptionController.close(); + checkToHideMenu(); + } + + void checkToHideMenu() { + Future.delayed(Duration(milliseconds: 200), () { + if (!mounted) return; + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuHided.call(); + } + }); + } + + void checkToShowMenu() { + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuShowed.call(); + } + } + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + widget.onMenuHided.call(); + } + + void onEmbedMenuCommand(LinkEmbedMenuCommand command) { + switch (command) { + case LinkEmbedMenuCommand.openLink: + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + break; + case LinkEmbedMenuCommand.replace: + final box = moreOptionButtonKey.currentContext?.findRenderObject() + as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkEmbedMenuCommand.reload: + widget.onReload.call(); + break; + case LinkEmbedMenuCommand.removeLink: + removeUrlPreviewLink(editorState, node); + break; + } + closeMoreOptionMenu(); + } +} + +enum LinkEmbedMenuCommand { + openLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case openLink: + return LocaleKeys.editor_openLink.tr(); + case replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} + +enum LinkEmbedConvertCommand { + toMention, + toURL, + toBookmark; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart new file mode 100644 index 0000000000..1907f68d29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; +import 'link_parsers/default_parser.dart'; +import 'link_parsers/youtube_parser.dart'; + +class LinkParser { + final Set> _listeners = >{}; + static final Map _hostToParsers = { + 'www.youtube.com': YoutubeParser(), + 'youtube.com': YoutubeParser(), + 'youtu.be': YoutubeParser(), + }; + + Future start(String url, {LinkInfoParser? parser}) async { + final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url); + final data = await LinkInfoCache.get(uri); + if (data != null) { + refreshLinkInfo(data); + } + + final host = uri.host; + final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser(); + await _getLinkInfo(uri, currentParser); + } + + Future _getLinkInfo(Uri uri, LinkInfoParser parser) async { + try { + final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri'); + if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo); + refreshLinkInfo(linkInfo); + return linkInfo; + } catch (e, s) { + Log.error('get link info error: ', e, s); + refreshLinkInfo(LinkInfo(url: '$uri')); + return null; + } + } + + void refreshLinkInfo(LinkInfo info) { + for (final listener in _listeners) { + listener(info); + } + } + + void addLinkInfoListener(ValueChanged listener) { + _listeners.add(listener); + } + + void dispose() { + _listeners.clear(); + } +} + +class LinkInfo { + factory LinkInfo.fromJson(Map json) => LinkInfo( + siteName: json['siteName'], + url: json['url'] ?? '', + title: json['title'], + description: json['description'], + imageUrl: json['imageUrl'], + faviconUrl: json['faviconUrl'], + ); + + LinkInfo({ + required this.url, + this.siteName, + this.title, + this.description, + this.imageUrl, + this.faviconUrl, + }); + + final String url; + final String? siteName; + final String? title; + final String? description; + final String? imageUrl; + final String? faviconUrl; + + Map toJson() => { + 'url': url, + 'siteName': siteName, + 'title': title, + 'description': description, + 'imageUrl': imageUrl, + 'faviconUrl': faviconUrl, + }; + + @override + String toString() { + return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}'; + } + + bool isEmpty() { + return title == null; + } + + Widget buildIconWidget({Size size = const Size.square(20.0)}) { + final iconUrl = faviconUrl; + if (iconUrl == null) { + return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size); + } + if (iconUrl.endsWith('.svg')) { + return FlowyNetworkSvg( + iconUrl, + height: size.height, + width: size.width, + errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } + return FlowyNetworkImage( + url: iconUrl, + fit: BoxFit.contain, + height: size.height, + width: size.width, + errorWidgetBuilder: (context, error, stackTrace) => + const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } +} + +class LinkInfoCache { + static const _linkInfoPrefix = 'link_info'; + + static Future get(Uri uri) async { + final option = await getIt().getWithFormat( + '$_linkInfoPrefix$uri', + (value) => LinkInfo.fromJson(jsonDecode(value)), + ); + return option; + } + + static Future set(Uri uri, LinkInfo data) async { + await getIt().set( + '$_linkInfoPrefix$uri', + jsonEncode(data.toJson()), + ); + } +} + +enum LinkLoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 879a71f008..d7f3e26302 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -3,8 +3,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -12,6 +14,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; + +import 'custom_link_parser.dart'; class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ @@ -21,6 +26,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { this.title, this.description, this.imageUrl, + this.isHovering = false, + this.status = LinkLoadingStatus.loading, }); final Node node; @@ -28,9 +35,14 @@ class CustomLinkPreviewWidget extends StatelessWidget { final String? description; final String? imageUrl; final String url; + final bool isHovering; + final LinkLoadingStatus status; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + borderScheme = theme.borderColorScheme, + textScheme = theme.textColorScheme; final documentFontSize = context .read() .editorStyle @@ -38,73 +50,67 @@ class CustomLinkPreviewWidget extends StatelessWidget { .text .fontSize ?? 16.0; + final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type && + !Theme.of(context).isLightMode; final (fontSize, width) = UniversalPlatform.isDesktopOrWeb - ? (documentFontSize, 180.0) + ? (documentFontSize, 160.0) : (documentFontSize - 2, 120.0); final Widget child = Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.onSurface, - ), - borderRadius: BorderRadius.circular( - 6.0, + color: isHovering || isInDarkCallout + ? borderScheme.greyTertiaryHover + : borderScheme.greyTertiary, ), + borderRadius: BorderRadius.circular(16.0), ), - child: IntrinsicHeight( + child: SizedBox( + height: 96, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (imageUrl != null) - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6.0), - bottomLeft: Radius.circular(6.0), - ), - child: FlowyNetworkImage( - url: imageUrl!, - width: width, - ), - ), + buildImage(context), Expanded( child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (title != null) - Padding( - padding: const EdgeInsets.only( - bottom: 4.0, - right: 10.0, - ), - child: FlowyText.medium( - title!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - fontSize: fontSize, - ), + padding: const EdgeInsets.fromLTRB(20, 12, 58, 12), + child: status != LinkLoadingStatus.idle + ? buildLoadingOrErrorWidget() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText.medium( + title!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize, + color: textScheme.primary, + figmaLineHeight: 20, + ), + ), + if (description != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FlowyText( + description!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize - 4, + figmaLineHeight: 16, + color: textScheme.primary, + ), + ), + FlowyText( + url.toString(), + overflow: TextOverflow.ellipsis, + color: textScheme.secondary, + fontSize: fontSize - 4, + figmaLineHeight: 16, + ), + ], ), - if (description != null) - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: FlowyText( - description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - fontSize: fontSize - 4, - ), - ), - FlowyText( - url.toString(), - overflow: TextOverflow.ellipsis, - maxLines: 2, - color: Theme.of(context).hintColor, - fontSize: fontSize - 4, - ), - ], - ), ), ), ], @@ -113,9 +119,12 @@ class CustomLinkPreviewWidget extends StatelessWidget { ); if (UniversalPlatform.isDesktopOrWeb) { - return InkWell( - onTap: () => afLaunchUrlString(url), - child: child, + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), ); } @@ -150,4 +159,61 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), ]; } + + Widget buildImage(BuildContext context) { + final theme = AppFlowyTheme.of(context), + fillScheme = theme.fillColorScheme, + iconScheme = theme.iconColorScheme; + final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; + Widget child; + if (imageUrl?.isNotEmpty ?? false) { + child = FlowyNetworkImage( + url: imageUrl!, + width: width, + ); + } else { + child = Center( + child: FlowySvg( + FlowySvgs.toolbar_link_earth_m, + color: iconScheme.secondary, + size: Size.square(30), + ), + ); + } + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + child: Container( + width: width, + color: fillScheme.quaternary, + child: child, + ), + ); + } + + Widget buildLoadingOrErrorWidget() { + if (status == LinkLoadingStatus.loading) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), + ), + ); + } else if (status == LinkLoadingStatus.error) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: Icon( + Icons.error_outline, + color: Colors.red, + ), + ), + ); + } + return SizedBox.shrink(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart new file mode 100644 index 0000000000..3f2128db52 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -0,0 +1,194 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; + +import 'custom_link_preview.dart'; +import 'default_selectable_mixin.dart'; +import 'link_preview_menu.dart'; + +class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { + CustomLinkPreviewBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + final isEmbed = + node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed; + if (isEmbed) { + return LinkEmbedBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => + actionBuilder(blockComponentContext, state), + ); + } + return CustomLinkPreviewBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => + (node) => node.attributes[LinkPreviewBlockKeys.url]!.isNotEmpty; +} + +class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget { + const CustomLinkPreviewBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + DefaultSelectableMixinState createState() => + CustomLinkPreviewBlockComponentState(); +} + +class CustomLinkPreviewBlockComponentState + extends DefaultSelectableMixinState + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; + + final parser = LinkParser(); + LinkLoadingStatus status = LinkLoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + parser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + return buildPreview(showActions); + }, + ), + ); + } + + Widget buildPreview(bool showActions) { + Widget child = CustomLinkPreviewWidget( + key: widgetKey, + node: node, + url: url, + isHovering: showActions, + title: linkInfo.siteName, + description: linkInfo.description, + imageUrl: linkInfo.imageUrl, + status: status, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + child = Stack( + children: [ + child, + if (showActions) + Positioned( + top: 12, + right: 12, + child: CustomLinkPreviewMenu( + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + onReload: () { + setState(() { + status = LinkLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + node: node, + ), + ), + ], + ); + + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + child = Padding(padding: newPadding, child: child); + + return child; + } + + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart new file mode 100644 index 0000000000..c894811522 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +abstract class DefaultSelectableMixinState + extends State with SelectableMixin { + final widgetKey = GlobalKey(); + RenderBox? get _renderBox => + widgetKey.currentContext?.findRenderObject() as RenderBox?; + + Node get currentNode; + + EdgeInsets get boxPadding => EdgeInsets.zero; + + @override + Position start() => Position(path: currentNode.path); + + @override + Position end() => Position(path: currentNode.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + final box = _renderBox; + if (box is RenderBox) { + return boxPadding.topLeft & box.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final box = widgetKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && box is RenderBox) { + return [ + box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: currentNode.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart new file mode 100644 index 0000000000..ab0b246743 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; +// ignore: depend_on_referenced_packages +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; + +abstract class LinkInfoParser { + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }); + + static String formatUrl(String url) { + Uri? uri = Uri.tryParse(url); + if (uri == null) return url; + if (!uri.hasScheme) uri = Uri.tryParse('http://$url'); + if (uri == null) return url; + final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery; + final homeUrl = '${uri.scheme}://${uri.host}/'; + if (isHome) return homeUrl; + return '$uri'; + } +} + +class DefaultParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + final http.Response response = + await http.get(link, headers: headers).timeout(timeout); + final code = response.statusCode; + if (code != 200 && isHome) { + throw Exception('Http request error: $code'); + } + // else if (!isHome && code == 403) { + // uri = Uri.parse('${uri.scheme}://${uri.host}/'); + // response = await http.get(uri).timeout(timeout); + // } + + final document = html_parser.parse(response.body); + + final siteName = document + .querySelector('meta[property="og:site_name"]') + ?.attributes['content']; + + String? title = document + .querySelector('meta[property="og:title"]') + ?.attributes['content']; + title ??= document.querySelector('title')?.text; + + String? description = document + .querySelector('meta[property="og:description"]') + ?.attributes['content']; + description ??= document + .querySelector('meta[name="description"]') + ?.attributes['content']; + + String? imageUrl = document + .querySelector('meta[property="og:image"]') + ?.attributes['content']; + if (imageUrl != null && !imageUrl.startsWith('http')) { + imageUrl = link.resolve(imageUrl).toString(); + } + + final favicon = + 'https://www.faviconextractor.com/favicon/${link.host}?larger=true'; + + return LinkInfo( + url: '$link', + siteName: siteName, + title: title, + description: description, + imageUrl: imageUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart new file mode 100644 index 0000000000..6f1ac6fb22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:http/http.dart' as http; +import 'default_parser.dart'; + +class YoutubeParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + if (isHome) { + return DefaultParser().parse( + link, + timeout: timeout, + headers: headers, + ); + } + + final requestLink = + 'https://www.youtube.com/oembed?url=$link&format=json'; + final http.Response response = await http + .get(Uri.parse(requestLink), headers: headers) + .timeout(timeout); + final code = response.statusCode; + if (code != 200) { + throw Exception('Http request error: $code'); + } + + final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body)); + + final favicon = + 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; + return LinkInfo( + url: '$link', + title: youtubeInfo.title, + siteName: youtubeInfo.authorName, + imageUrl: youtubeInfo.thumbnailUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} + +class YoutubeInfo { + YoutubeInfo({ + this.title, + this.authorName, + this.version, + this.providerName, + this.providerUrl, + this.thumbnailUrl, + }); + + YoutubeInfo.fromJson(Map json) { + title = json['title']; + authorName = json['author_name']; + version = json['version']; + providerName = json['provider_name']; + providerUrl = json['provider_url']; + thumbnailUrl = json['thumbnail_url']; + } + String? title; + String? authorName; + String? version; + String? providerName; + String? providerUrl; + String? thumbnailUrl; + + Map toJson() => { + 'title': title, + 'author_name': authorName, + 'version': version, + 'provider_name': providerName, + 'provider_url': providerUrl, + 'thumbnail_url': thumbnailUrl, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart deleted file mode 100644 index 6688cfe304..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:appflowy/core/config/kv.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; - -class LinkPreviewDataCache implements LinkPreviewDataCacheInterface { - @override - Future get(String url) async { - final option = - await getIt().getWithFormat( - url, - (value) => LinkPreviewData.fromJson(jsonDecode(value)), - ); - return option; - } - - @override - Future set(String url, LinkPreviewData data) async { - await getIt().set( - url, - jsonEncode(data.toJson()), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index 61e5156060..2fb493dda3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -1,110 +1,207 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../image/custom_image_block_component/custom_image_block_component.dart'; - -class LinkPreviewMenu extends StatefulWidget { - const LinkPreviewMenu({ +class CustomLinkPreviewMenu extends StatefulWidget { + const CustomLinkPreviewMenu({ super.key, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, required this.node, - required this.state, }); - + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; final Node node; - final LinkPreviewBlockComponentState state; @override - State createState() => _LinkPreviewMenuState(); + State createState() => _CustomLinkPreviewMenuState(); } -class _LinkPreviewMenuState extends State { +class _CustomLinkPreviewMenuState extends State { + final popoverController = PopoverController(); + final buttonKey = GlobalKey(); + bool closed = false; + bool selected = false; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + widget.onMenuHided.call(); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - height: 32, - decoration: BoxDecoration( - color: theme.cardColor, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(4.0), - ), - child: Row( - children: [ - const HSpace(4), - MenuBlockButton( - tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), - iconData: FlowySvgs.m_toolbar_link_m, - onTap: () async => convertUrlPreviewNodeToLink( - context.read(), - widget.node, - ), - ), - const HSpace(4), - MenuBlockButton( - tooltip: LocaleKeys.editor_copyLink.tr(), - iconData: FlowySvgs.copy_s, - onTap: copyImageLink, - ), - const _Divider(), - MenuBlockButton( - tooltip: LocaleKeys.button_delete.tr(), - iconData: FlowySvgs.trash_s, - onTap: deleteLinkPreviewNode, - ), - const HSpace(4), - ], + return AppFlowyPopover( + offset: Offset(0, 0.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + keepEditorFocusNotifier.decrease(); + if (!closed) { + closed = true; + return; + } else { + closed = false; + widget.onMenuHided.call(); + } + setState(() { + selected = false; + }); + }, + popupBuilder: (context) => buildMenu(), + child: FlowyIconButton( + key: buttonKey, + isSelected: selected, + icon: FlowySvg(FlowySvgs.toolbar_more_m), + onPressed: showPopover, ), ); } - void copyImageLink() { - final url = widget.node.attributes[CustomImageBlockKeys.url]; - if (url != null) { - Clipboard.setData(ClipboardData(text: url)); - showToastNotification( - context, - message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), - ); + Widget buildMenu() { + return MouseRegion( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkPreviewMenuCommand.values.length, (index) { + final command = LinkPreviewMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + Future onTap(LinkPreviewMenuCommand command) async { + final editorState = context.read(); + final node = widget.node; + final url = node.attributes[LinkPreviewBlockKeys.url]; + switch (command) { + case LinkPreviewMenuCommand.convertToMention: + await convertUrlPreviewNodeToMention(editorState, node); + break; + case LinkPreviewMenuCommand.convertToUrl: + await convertUrlPreviewNodeToLink(editorState, node); + break; + case LinkPreviewMenuCommand.convertToEmbed: + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }); + await editorState.apply(transaction); + break; + case LinkPreviewMenuCommand.copyLink: + if (url != null) { + await context.copyLink(url); + } + break; + case LinkPreviewMenuCommand.replace: + final box = buttonKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkPreviewMenuCommand.reload: + widget.onReload.call(); + break; + case LinkPreviewMenuCommand.removeLink: + await removeUrlPreviewLink(editorState, node); + break; + } + closePopover(); + } + + void showPopover() { + widget.onMenuShowed.call(); + keepEditorFocusNotifier.increase(); + popoverController.show(); + setState(() { + selected = true; + }); + } + + void closePopover() { + popoverController.close(); + widget.onMenuHided.call(); + } +} + +enum LinkPreviewMenuCommand { + convertToMention, + convertToUrl, + convertToEmbed, + copyLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case convertToMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case LinkPreviewMenuCommand.convertToUrl: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case LinkPreviewMenuCommand.convertToEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case LinkPreviewMenuCommand.copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case LinkPreviewMenuCommand.replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case LinkPreviewMenuCommand.reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case LinkPreviewMenuCommand.removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); } } - - Future deleteLinkPreviewNode() async { - final node = widget.node; - final editorState = context.read(); - final transaction = editorState.transaction; - transaction.deleteNode(node); - transaction.afterSelection = null; - await editorState.apply(transaction); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Container( - width: 1, - color: Colors.grey, - ), - ); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart new file mode 100644 index 0000000000..fb51cdcf47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -0,0 +1,259 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const _menuHeighgt = 188.0, _menuWidth = 288.0; + +class PasteAsMenuService { + PasteAsMenuService({ + required this.context, + required this.editorState, + }); + + final BuildContext context; + final EditorState editorState; + OverlayEntry? _menuEntry; + + void show(String href) { + WidgetsBinding.instance.addPostFrameCallback((_) => _show(href)); + } + + void dismiss() { + if (_menuEntry != null) { + keepEditorFocusNotifier.decrease(); + // editorState.service.scrollService?.enable(); + // editorState.service.keyboardService?.enable(); + } + _menuEntry?.remove(); + _menuEntry = null; + } + + void _show(String href) { + final Size editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize == Size.zero) return; + final menuPosition = editorState.calculateMenuOffset( + menuWidth: _menuWidth, + menuHeight: _menuHeighgt, + ); + if (menuPosition == null) return; + final ltrb = menuPosition.ltrb; + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + ltrb.buildPositioned( + child: PasteAsMenu( + editorState: editorState, + onSelect: (t) { + final selection = editorState.selection; + if (selection == null) return; + final end = selection.end; + final urlSelection = Selection( + start: end.copyWith(offset: end.offset - href.length), + end: end, + ); + if (t == PasteMenuType.bookmark) { + convertUrlToLinkPreview(editorState, urlSelection, href); + } else if (t == PasteMenuType.mention) { + convertUrlToMention(editorState, urlSelection); + } else if (t == PasteMenuType.embed) { + convertUrlToLinkPreview( + editorState, + urlSelection, + href, + previewType: LinkEmbedKeys.embed, + ); + } + dismiss(); + }, + onDismiss: dismiss, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + keepEditorFocusNotifier.increase(); + // editorState.service.keyboardService?.disable(showCursor: true); + // editorState.service.scrollService?.disable(); + } +} + +class PasteAsMenu extends StatefulWidget { + const PasteAsMenu({ + super.key, + required this.onSelect, + required this.onDismiss, + required this.editorState, + }); + final ValueChanged onSelect; + final VoidCallback onDismiss; + final EditorState editorState; + + @override + State createState() => _PasteAsMenuState(); +} + +class _PasteAsMenuState extends State { + final focusNode = FocusNode(debugLabel: 'paste_as_menu'); + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + + EditorState get editorState => widget.editorState; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + editorState.selectionNotifier.addListener(dismiss); + } + + @override + void dispose() { + focusNode.dispose(); + selectedIndexNotifier.dispose(); + editorState.selectionNotifier.removeListener(dismiss); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + width: _menuWidth, + height: _menuHeighgt, + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.surfaceColorScheme.primary, + boxShadow: theme.shadow.medium, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + color: theme.textColorScheme.primary, + LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs + .tr(), + ), + ), + ...List.generate( + PasteMenuType.values.length, + (i) => buildItem(PasteMenuType.values[i], i), + ), + ], + ), + ), + ); + } + + Widget buildItem(PasteMenuType type, int i) { + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = i == value; + return SizedBox( + height: 36, + child: FlowyButton( + isSelected: isSelected, + text: FlowyText( + type.title, + ), + onTap: () => onSelect(type), + ), + ); + }, + ); + } + + void changeIndex(int index) => selectedIndexNotifier.value = index; + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + int index = selectedIndexNotifier.value, + length = PasteMenuType.values.length; + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(PasteMenuType.values[index]); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + dismiss(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + dismiss(); + } else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft] + .contains(event.logicalKey)) { + if (index == 0) { + index = length - 1; + } else { + index--; + } + changeIndex(index); + return KeyEventResult.handled; + } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + if (index == length - 1) { + index = 0; + } else { + index++; + } + changeIndex(index); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void onSelect(PasteMenuType type) => widget.onSelect.call(type); + + void dismiss() => widget.onDismiss.call(); +} + +enum PasteMenuType { + mention, + url, + bookmark, + embed, +} + +extension PasteMenuTypeExtension on PasteMenuType { + String get title { + switch (this) { + case PasteMenuType.mention: + return LocaleKeys.document_plugins_linkPreview_typeSelection_mention + .tr(); + case PasteMenuType.url: + return LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr(); + case PasteMenuType.bookmark: + return LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark + .tr(); + case PasteMenuType.embed: + return LocaleKeys.document_plugins_linkPreview_typeSelection_embed.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart index 57564c4722..8b193c70fb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -1,3 +1,5 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -9,7 +11,7 @@ Future convertUrlPreviewNodeToLink( return; } - final url = node.attributes[ImageBlockKeys.url]; + final url = node.attributes[LinkPreviewBlockKeys.url]; final delta = Delta() ..insert( url, @@ -29,3 +31,172 @@ Future convertUrlPreviewNodeToLink( ); return editorState.apply(transaction); } + +Future convertUrlPreviewNodeToMention( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta() + ..insert( + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: url, + }, + }, + ); + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(delta: delta)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + return editorState.apply(transaction); +} + +Future removeUrlPreviewLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta()..insert(url); + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(delta: delta)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + return editorState.apply(transaction); +} + +Future convertUrlToLinkPreview( + EditorState editorState, + Selection selection, + String url, { + String? previewType, +}) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + final List beforeOperations = [], afterOperations = []; + int index = 0; + for (final insert in delta.whereType()) { + if (index < selection.startIndex) { + beforeOperations.add(insert); + } else if (index >= selection.endIndex) { + afterOperations.add(insert); + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction + ..deleteNode(node) + ..insertNodes(node.path.next, [ + if (beforeOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: beforeOperations)), + if (previewType == LinkEmbedKeys.embed) + linkEmbedNode(url: url) + else + linkPreviewNode(url: url), + if (afterOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: afterOperations)), + ]); + await editorState.apply(transaction); +} + +Future convertUrlToMention( + EditorState editorState, + Selection selection, +) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + String url = ''; + int index = 0; + for (final insert in delta.whereType()) { + if (index >= selection.startIndex && index < selection.endIndex) { + final href = insert.attributes?.href ?? ''; + if (href.isNotEmpty) { + url = href; + break; + } + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: url, + }, + }, + ); + await editorState.apply(transaction); +} + +Future convertLinkBlockToOtherLinkBlock( + EditorState editorState, + Node node, + String toType, { + String? url, +}) async { + final nodeType = node.type; + if (nodeType != LinkPreviewBlockKeys.type || + (nodeType == toType && url == null)) { + return; + } + final insertedNode = []; + + final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; + final previewType = node.attributes[LinkEmbedKeys.previewType]; + Node afterNode = node.copyWith( + type: toType, + attributes: { + LinkPreviewBlockKeys.url: afterUrl, + LinkEmbedKeys.previewType: previewType, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: node.attributes[blockComponentTextDirection], + blockComponentDelta: (node.delta ?? Delta()).toJson(), + }, + ); + afterNode = afterNode.copyWith(children: []); + insertedNode.add(afterNode); + insertedNode.addAll(node.children.map((e) => e.deepCopy())); + final transaction = editorState.transaction; + transaction.insertNodes( + node.path, + insertedNode, + ); + transaction.deleteNodes([node]); + await editorState.apply(transaction); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart index e9d6b3297e..2f724061ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart @@ -79,6 +79,10 @@ class MathEquationBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -94,6 +98,7 @@ class MathEquationBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -157,6 +162,7 @@ class MathEquationBlockComponentWidgetState child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart new file mode 100644 index 0000000000..9c9fe7905b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +/// Windows / Linux : ctrl + shift + e +/// macOS : cmd + shift + e +/// Allows the user to insert math equation by shortcut +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent insertInlineMathEquationCommand = + CommandShortcutEvent( + key: 'Insert inline math equation', + command: 'ctrl+shift+e', + macOSCommand: 'cmd+shift+e', + getDescription: LocaleKeys.document_plugins_mathEquation_name.tr, + handler: (editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed || !selection.isSingle) { + return KeyEventResult.ignored; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + if (node.delta == null || !toolbarItemWhiteList.contains(node.type)) { + return KeyEventResult.ignored; + } + final transaction = editorState.transaction; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[InlineMathEquationKeys.formula] != null, + ); + }); + if (isHighlight) { + final formula = delta + .slice(selection.startIndex, selection.endIndex) + .whereType() + .firstOrNull + ?.attributes?[InlineMathEquationKeys.formula]; + assert(formula != null); + if (formula == null) { + return KeyEventResult.ignored; + } + // clear the format + transaction.replaceText( + node, + selection.startIndex, + selection.length, + formula, + attributes: {}, + ); + } else { + final text = editorState.getTextInSelection(selection).join(); + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + InlineMathEquationKeys.formula: text, + }, + ); + } + editorState.apply(transaction); + return KeyEventResult.handled; + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart index ef32ad1098..77f8c8d0a1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -100,7 +100,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { Log.error(error); if (context.mounted) { showToastNotification( - context, message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage .tr(), ); @@ -179,13 +178,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { await duplicatedViewOrFailure.fold( (newView) async { - final newMentionAttributes = { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.childPage.name, - MentionBlockKeys.pageId: newView.id, - }, - }; - // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. int mentionIndex = 0; @@ -202,7 +194,11 @@ class ChildPageTransactionHandler extends MentionTransactionHandler { node, mentionIndex, MentionBlockKeys.mentionChar.length, - newMentionAttributes, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: newView.id, + blockId: null, + ), ); await editorState.apply( transaction, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart index 972ed229dd..cb3196e9b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart @@ -192,15 +192,12 @@ class DateTransactionHandler extends MentionTransactionHandler { ), ); - final newMentionAttributes = { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: dateTime.toIso8601String(), - MentionBlockKeys.reminderId: reminderId, - MentionBlockKeys.includeTime: data.includeTime, - MentionBlockKeys.reminderOption: data.reminderOption.name, - }, - }; + final newMentionAttributes = MentionBlockKeys.buildMentionDateAttributes( + date: dateTime.toIso8601String(), + reminderId: reminderId, + reminderOption: data.reminderOption.name, + includeTime: data.includeTime, + ); // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index a5bc340f71..0060d65bb7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -6,14 +6,18 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'mention_link_block.dart'; + enum MentionType { page, date, + externalLink, childPage; static MentionType fromString(String value) => switch (value) { 'page' => page, 'date' => date, + 'externalLink' => externalLink, 'childPage' => childPage, // Backwards compatibility 'reminder' => date, @@ -27,12 +31,12 @@ Node dateMentionNode() { operations: [ TextInsert( MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), ), ], ), @@ -42,18 +46,52 @@ Node dateMentionNode() { class MentionBlockKeys { const MentionBlockKeys._(); - static const reminderId = 'reminder_id'; // ReminderID static const mention = 'mention'; static const type = 'type'; // MentionType, String + static const pageId = 'page_id'; static const blockId = 'block_id'; + static const url = 'url'; // Related to Reminder and Date blocks static const date = 'date'; // Start Date static const includeTime = 'include_time'; + static const reminderId = 'reminder_id'; // ReminderID static const reminderOption = 'reminder_option'; static const mentionChar = '\$'; + + static Map buildMentionPageAttributes({ + required MentionType mentionType, + required String pageId, + required String? blockId, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: mentionType.name, + MentionBlockKeys.pageId: pageId, + if (blockId != null) MentionBlockKeys.blockId: blockId, + }, + }; + } + + static Map buildMentionDateAttributes({ + required String date, + required String? reminderId, + required String? reminderOption, + required bool includeTime, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date, + MentionBlockKeys.includeTime: includeTime, + if (reminderId != null) MentionBlockKeys.reminderId: reminderId, + if (reminderOption != null) + MentionBlockKeys.reminderOption: reminderOption, + }, + }; + } } class MentionBlock extends StatelessWidget { @@ -124,8 +162,17 @@ class MentionBlock extends StatelessWidget { reminderOption: reminderOption ?? ReminderOption.none, includeTime: mention[MentionBlockKeys.includeTime] ?? false, ); - default: - return const SizedBox.shrink(); + case MentionType.externalLink: + final String? url = mention[MentionBlockKeys.url] as String?; + if (url == null) { + return const SizedBox.shrink(); + } + return MentionLinkBlock( + url: url, + editorState: editorState, + node: node, + index: index, + ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index e1df115e15..20f60be23d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -60,8 +60,6 @@ class MentionDateBlock extends StatefulWidget { } class _MentionDateBlockState extends State { - final PopoverMutex mutex = PopoverMutex(); - late bool _includeTime = widget.includeTime; late DateTime? parsedDate = DateTime.tryParse(widget.date); @@ -71,12 +69,6 @@ class _MentionDateBlockState extends State { super.didUpdateWidget(oldWidget); } - @override - void dispose() { - mutex.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { if (parsedDate == null) { @@ -105,7 +97,6 @@ class _MentionDateBlockState extends State { final options = DatePickerOptions( focusedDay: parsedDate, - popoverMutex: mutex, selectedDay: parsedDate, includeTime: _includeTime, dateFormat: appearance.dateFormat, @@ -210,16 +201,17 @@ class _MentionDateBlockState extends State { (reminderOption == ReminderOption.none ? null : widget.reminderId); final transaction = widget.editorState.transaction - ..formatText(widget.node, widget.index, 1, { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.reminderId: rId, - MentionBlockKeys.includeTime: includeTime, - MentionBlockKeys.reminderOption: - reminderOption?.name ?? widget.reminderOption.name, - }, - }); + ..formatText( + widget.node, + widget.index, + 1, + MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: rId, + includeTime: includeTime, + reminderOption: reminderOption?.name ?? widget.reminderOption.name, + ), + ); widget.editorState.apply(transaction, withUpdateSelection: false); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart new file mode 100644 index 0000000000..06ebcb5002 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'mention_link_error_preview.dart'; +import 'mention_link_preview.dart'; + +class MentionLinkBlock extends StatefulWidget { + const MentionLinkBlock({ + super.key, + required this.url, + required this.editorState, + required this.node, + required this.index, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final String url; + final Duration delayToShow; + final Duration delayToHide; + final EditorState editorState; + final Node node; + final int index; + + @override + State createState() => _MentionLinkBlockState(); +} + +class _MentionLinkBlockState extends State { + final parser = LinkParser(); + _LoadingStatus status = _LoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); + final previewController = PopoverController(); + bool isHovering = false; + int previewFocusNum = 0; + bool isPreviewHovering = false; + bool showAtBottom = false; + final key = GlobalKey(); + + bool get isPreviewShowing => previewFocusNum > 0; + String get url => widget.url; + + EditorState get editorState => widget.editorState; + + Node get node => widget.node; + + int get index => widget.index; + + bool get readyForPreview => + status == _LoadingStatus.idle && !linkInfo.isEmpty(); + + @override + void initState() { + super.initState(); + + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = _LoadingStatus.idle; + } else if (!hasOldInfo) { + status = _LoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + super.dispose(); + parser.dispose(); + previewController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: ValueKey(showAtBottom), + controller: previewController, + direction: showAtBottom + ? PopoverDirection.bottomWithLeftAligned + : PopoverDirection.topWithLeftAligned, + offset: Offset(0, showAtBottom ? -20 : 20), + onOpen: () { + keepEditorFocusNotifier.increase(); + previewFocusNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + previewFocusNum--; + }, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + margin: EdgeInsets.zero, + constraints: getConstraints(), + borderRadius: BorderRadius.circular(16), + popupBuilder: (context) => readyForPreview + ? MentionLinkPreview( + linkInfo: linkInfo, + showAtBottom: showAtBottom, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ) + : MentionLinkErrorPreview( + url: url, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ), + child: buildIconWithTitle(context), + ); + } + + Widget buildIconWithTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final siteName = linkInfo.siteName, linkTitle = linkInfo.title ?? url; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: onEnter, + onExit: onExit, + child: GestureDetector( + onTap: () async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + }, + child: FlowyHoverContainer( + style: + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + applyStyle: isHovering, + child: Row( + mainAxisSize: MainAxisSize.min, + key: key, + children: [ + HSpace(2), + buildIcon(), + HSpace(4), + Flexible( + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + if (siteName != null) ...[ + TextSpan( + text: siteName, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.secondary), + ), + WidgetSpan(child: HSpace(2)), + ], + TextSpan( + text: linkTitle, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.primary), + ), + ], + ), + ), + ), + HSpace(2), + ], + ), + ), + ), + ); + } + + Widget buildIcon() { + const defaultWidget = FlowySvg(FlowySvgs.toolbar_link_earth_m); + Widget icon = defaultWidget; + if (status == _LoadingStatus.loading) { + icon = Padding( + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator(strokeWidth: 1), + ); + } else { + icon = linkInfo.buildIconWidget(); + } + return SizedBox( + height: 20, + width: 20, + child: icon, + ); + } + + RenderBox? get box => key.currentContext?.findRenderObject() as RenderBox?; + + Size getSizeFromKey() => box?.size ?? Size.zero; + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + previewController.close(); + } + + Future openLink() async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + } + + Future removeLink() async { + final transaction = editorState.transaction + ..replaceText(widget.node, widget.index, 1, url, attributes: {}); + await editorState.apply(transaction); + } + + Future convertTo(PasteMenuType type) async { + if (type == PasteMenuType.url) { + await toUrl(); + } else if (type == PasteMenuType.bookmark) { + await toLinkPreview(); + } else if (type == PasteMenuType.embed) { + await toLinkPreview(previewType: LinkEmbedKeys.embed); + } + } + + Future toUrl() async { + final transaction = editorState.transaction + ..replaceText( + widget.node, + widget.index, + 1, + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); + await editorState.apply(transaction); + } + + Future toLinkPreview({String? previewType}) async { + final selection = Selection( + start: Position(path: node.path, offset: index), + end: Position(path: node.path, offset: index + 1), + ); + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: previewType, + ); + } + + void changeHovering(bool hovering) { + if (isHovering == hovering) return; + if (mounted) { + setState(() { + isHovering = hovering; + }); + } + } + + void changeShowAtBottom(bool bottom) { + if (showAtBottom == bottom) return; + if (mounted) { + setState(() { + showAtBottom = bottom; + }); + } + } + + void tryToDismissPreview() { + Future.delayed(widget.delayToHide, () { + if (isHovering || isPreviewHovering) { + return; + } + previewController.close(); + }); + } + + void onEnter(PointerEnterEvent e) { + changeHovering(true); + final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; + if (readyForPreview) { + if (location.dy < 300) { + changeShowAtBottom(true); + } else { + changeShowAtBottom(false); + } + } + Future.delayed(widget.delayToShow, () { + if (isHovering && !isPreviewShowing && status != _LoadingStatus.loading) { + showPreview(); + } + }); + } + + void onExit(PointerExitEvent e) { + changeHovering(false); + tryToDismissPreview(); + } + + void showPreview() { + if (!mounted) return; + keepEditorFocusNotifier.increase(); + previewController.show(); + previewFocusNum++; + } + + BoxConstraints getConstraints() { + final size = getSizeFromKey(); + if (!readyForPreview) { + return BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ); + } + final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false; + return BoxConstraints( + maxWidth: max(300, size.width), + maxHeight: hasImage ? 300 : 180, + ); + } +} + +enum _LoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart new file mode 100644 index 0000000000..df396108e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart @@ -0,0 +1,232 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MentionLinkErrorPreview extends StatefulWidget { + const MentionLinkErrorPreview({ + super.key, + required this.url, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + }); + + final String url; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + + @override + State createState() => + _MentionLinkErrorPreviewState(); +} + +class _MentionLinkErrorPreviewState extends State { + final menuController = PopoverController(); + bool isConvertButtonSelected = false; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: SizedBox( + width: max(320, widget.triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded(child: buildLinkWidget()), + Container( + height: 20, + width: 1, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onCopyLink, + ), + buildConvertButton(), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + onTap: widget.onOpenLink, + child: Container( + width: widget.triggerSize.width, + height: widget.triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } + + Widget buildLinkWidget() { + final url = widget.url; + return FlowyTooltip( + message: url, + preferBelow: false, + child: FlowyText.regular( + url, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(8, 10), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: menuController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(MentionLinktErrorMenuCommand.values.length, + (index) { + final command = MentionLinktErrorMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktErrorMenuCommand command) { + switch (command) { + case MentionLinktErrorMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktErrorMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktErrorMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktErrorMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktErrorMenuCommand { + toURL, + toBookmark, + toEmbed, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart new file mode 100644 index 0000000000..00b161379e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -0,0 +1,276 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MentionLinkPreview extends StatefulWidget { + const MentionLinkPreview({ + super.key, + required this.linkInfo, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + required this.showAtBottom, + }); + + final LinkInfo linkInfo; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + final bool showAtBottom; + + @override + State createState() => _MentionLinkPreviewState(); +} + +class _MentionLinkPreviewState extends State { + final menuController = PopoverController(); + bool isSelected = false; + + LinkInfo get linkInfo => widget.linkInfo; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + textColorScheme = theme.textColorScheme; + final imageUrl = linkInfo.imageUrl ?? '', + description = linkInfo.description ?? ''; + final imageHeight = 120.0; + final card = MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Container( + decoration: buildToolbarLinkDecoration(context, radius: 16), + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (imageUrl.isNotEmpty) + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: 280, + height: imageHeight, + ), + ), + VSpace(12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText.semibold( + linkInfo.title ?? linkInfo.siteName ?? '', + fontSize: 14, + figmaLineHeight: 20, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(4), + if (description.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText( + description, + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.secondary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(36), + ], + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 28, + child: Row( + children: [ + linkInfo.buildIconWidget(size: Size.square(16)), + HSpace(6), + Expanded( + child: FlowyText( + linkInfo.siteName ?? linkInfo.url, + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.w700, + ), + ), + buildMoreOptionButton(), + ], + ), + ), + VSpace(12), + ], + ), + ), + ); + + final clickPlaceHolder = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + child: Container( + height: 20, + width: widget.triggerSize.width, + color: Colors.white.withAlpha(1), + ), + onTap: () { + widget.onOpenLink.call(); + closePopover(); + }, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widget.showAtBottom + ? [clickPlaceHolder, card] + : [card, clickPlaceHolder], + ); + } + + Widget buildMoreOptionButton() { + return AppFlowyPopover( + controller: menuController, + direction: PopoverDirection.topWithLeftAligned, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + borderRadius: BorderRadius.circular(12), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + width: 28, + height: 28, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + size: Size.square(20), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(MentionLinktMenuCommand.values.length, (index) { + final command = MentionLinktMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktMenuCommand command) { + switch (command) { + case MentionLinktMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktMenuCommand.copyLink: + widget.onCopyLink(); + break; + case MentionLinktMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktMenuCommand { + toURL, + toBookmark, + toEmbed, + copyLink, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 90a4c73013..ede690eb30 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -50,18 +50,23 @@ Node pageMentionNode(String viewId) { operations: [ TextInsert( MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: viewId, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: viewId, + blockId: null, + ), ), ], ), ); } +class ReferenceState { + ReferenceState(this.isReference); + + final bool isReference; +} + class MentionPageBlock extends StatefulWidget { const MentionPageBlock({ super.key, @@ -112,7 +117,7 @@ class _MentionPageBlockState extends State { view: view, content: state.blockContent, textStyle: widget.textStyle, - handleTap: () => _handleTap( + handleTap: () => handleMentionBlockTap( context, widget.editorState, view, @@ -132,7 +137,7 @@ class _MentionPageBlockState extends State { content: state.blockContent, textStyle: widget.textStyle, showTrashHint: state.isInTrash, - handleTap: () => _handleTap( + handleTap: () => handleMentionBlockTap( context, widget.editorState, view, @@ -215,7 +220,8 @@ class _MentionSubPageBlockState extends State { view: view, showTrashHint: state.isInTrash, textStyle: widget.textStyle, - handleTap: () => _handleTap(context, widget.editorState, view), + handleTap: () => + handleMentionBlockTap(context, widget.editorState, view), isChildPage: true, content: '', handleDoubleTap: () => _handleDoubleTap( @@ -233,7 +239,8 @@ class _MentionSubPageBlockState extends State { content: null, textStyle: widget.textStyle, isChildPage: true, - handleTap: () => _handleTap(context, widget.editorState, view), + handleTap: () => + handleMentionBlockTap(context, widget.editorState, view), ); } }, @@ -276,12 +283,11 @@ class _MentionSubPageBlockState extends State { widget.node, widget.index, MentionBlockKeys.mentionChar.length, - { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: widget.pageId, - }, - }, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: widget.pageId, + blockId: null, + ), ); widget.editorState.apply( @@ -315,7 +321,7 @@ Path? _findNodePathByBlockId(EditorState editorState, String blockId) { return null; } -Future _handleTap( +Future handleMentionBlockTap( BuildContext context, EditorState editorState, ViewPB view, { @@ -375,25 +381,24 @@ Future _handleDoubleTap( } final currentViewId = context.read().documentId; - final newViewId = await showPageSelectorSheet( + final newView = await showPageSelectorSheet( context, currentViewId: currentViewId, selectedViewId: viewId, ); - if (newViewId != null) { + if (newView != null) { // Update this nodes pageId final transaction = editorState.transaction ..formatText( node, index, 1, - { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: newViewId, - }, - }, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: newView.id, + blockId: null, + ), ); await editorState.apply(transaction, withUpdateSelection: false); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart new file mode 100644 index 0000000000..7dcd21f423 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +extension MenuExtension on EditorState { + MenuPosition? calculateMenuOffset({ + Rect? rect, + required double menuWidth, + required double menuHeight, + Offset menuOffset = const Offset(0, 10), + }) { + final selectionService = service.selectionService; + final selectionRects = selectionService.selectionRects; + late Rect startRect; + if (rect != null) { + startRect = rect; + } else { + if (selectionRects.isEmpty) return null; + startRect = selectionRects.first; + } + + final editorOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = renderBox!.size.height; + final editorWidth = renderBox!.size.width; + + // show below default + Alignment alignment = Alignment.topLeft; + final bottomRight = startRect.bottomRight; + final topRight = startRect.topRight; + var startOffset = bottomRight + menuOffset; + Offset offset = Offset( + startOffset.dx, + startOffset.dy, + ); + + // show above + if (startOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { + startOffset = topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + startOffset.dx, + editorHeight + editorOffset.dy - startOffset.dy, + ); + } + + // show on right + if (offset.dx + menuWidth < editorOffset.dx + editorWidth) { + offset = Offset( + offset.dx, + offset.dy, + ); + } else if (startOffset.dx - editorOffset.dx > menuWidth) { + // show on left + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx + editorOffset.dx, + offset.dy, + ); + } + return MenuPosition(align: alignment, offset: offset); + } +} + +class MenuPosition { + MenuPosition({ + required this.align, + required this.offset, + }); + + final Alignment align; + final Offset offset; + + LTRB get ltrb { + double? left, top, right, bottom; + switch (align) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return LTRB(left: left, top: top, right: right, bottom: bottom); + } +} + +class LTRB { + LTRB({this.left, this.top, this.right, this.bottom}); + + final double? left; + final double? top; + final double? right; + final double? bottom; + + Positioned buildPositioned({required Widget child}) => Positioned( + left: left, + top: top, + right: right, + bottom: bottom, + child: child, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart index be9063a6c8..41bb8ce873 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart @@ -7,7 +7,8 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; import 'package:string_validator/string_validator.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart index 57670afadd..8e1a8533e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart @@ -6,7 +6,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart index d3d4c5daa9..6ec777429c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart @@ -86,7 +86,7 @@ class _TextColorAndBackgroundColorState EditorTextColorWidget( selectedColor: selectedTextColor?.tryToColor(), onSelectedColor: (textColor) async { - final hex = textColor.alpha == 0 ? null : textColor.toHex(); + final hex = textColor.a == 0 ? null : textColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( @@ -123,8 +123,7 @@ class _TextColorAndBackgroundColorState EditorBackgroundColors( selectedColor: selectedBackgroundColor?.tryToColor(), onSelectedColor: (backgroundColor) async { - final hex = - backgroundColor.alpha == 0 ? null : backgroundColor.toHex(); + final hex = backgroundColor.a == 0 ? null : backgroundColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( @@ -296,7 +295,7 @@ class _TextColorItem extends StatelessWidget { child: FlowyText( 'A', fontSize: 24, - color: color.alpha == 0 ? null : color, + color: color.a == 0 ? null : color, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart index 4ae243575c..b3df4dfd39 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart @@ -12,7 +12,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart index 2f31a68a73..f77083d21d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,9 +30,9 @@ class NumberedListIcon extends StatelessWidget { ); return Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(left: 6.0, right: 10.0), child: Text( - node.levelString, + node.buildLevelString(context), style: adjustedTextStyle, strutStyle: StrutStyle.fromTextStyle(combinedTextStyle), textHeightBehavior: TextHeightBehavior( @@ -47,9 +48,12 @@ class NumberedListIcon extends StatelessWidget { } } -extension on Node { - String get levelString { - final builder = _NumberedListIconBuilder(node: this); +extension NumberedListNodeIndex on Node { + String buildLevelString(BuildContext context) { + final builder = NumberedListIndexBuilder( + editorState: context.read(), + node: this, + ); final indexInRootLevel = builder.indexInRootLevel; final indexInSameLevel = builder.indexInSameLevel; final level = indexInRootLevel % 3; @@ -62,11 +66,13 @@ extension on Node { } } -class _NumberedListIconBuilder { - _NumberedListIconBuilder({ +class NumberedListIndexBuilder { + NumberedListIndexBuilder({ + required this.editorState, required this.node, }); + final EditorState editorState; final Node node; // the level of the current node @@ -88,7 +94,13 @@ class _NumberedListIconBuilder { Node? previous = node.previous; // if the previous one is not a numbered list, then it is the first one - if (previous == null || previous.type != NumberedListBlockKeys.type) { + final aiNodeExternalValues = + node.externalValues?.unwrapOrNull(); + + if (previous == null || + previous.type != NumberedListBlockKeys.type || + (aiNodeExternalValues != null && + aiNodeExternalValues.isFirstNumberedListNode)) { return node.attributes[NumberedListBlockKeys.number] ?? level; } @@ -97,10 +109,17 @@ class _NumberedListIconBuilder { startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; level++; previous = previous.previous; + + // break the loop if the start number is found when the current node is an AI node + if (aiNodeExternalValues != null && startNumber != null) { + return startNumber + level - 1; + } } + if (startNumber != null) { - return startNumber + level - 1; + level = startNumber + level - 1; } + return level; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart index a883c3410d..e3120356d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -52,6 +52,10 @@ class OutlineBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -65,6 +69,7 @@ class OutlineBlockWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -76,7 +81,9 @@ class _OutlineBlockWidgetState extends State with BlockComponentConfigurable, BlockComponentTextDirectionMixin, - BlockComponentBackgroundColorMixin { + BlockComponentBackgroundColorMixin, + DefaultSelectableMixin, + SelectableMixin { // Change the value if the heading block type supports heading levels greater than '3' static const maxVisibleDepth = 6; @@ -90,6 +97,17 @@ class _OutlineBlockWidgetState extends State late EditorState editorState = context.read(); late Stream stream = editorState.transactionStream; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: OutlineBlockKeys.type, + ); + + @override + GlobalKey> get containerKey => widget.node.key; + + @override + GlobalKey> get forwardKey => widget.node.key; + @override Widget build(BuildContext context) { return StreamBuilder( @@ -97,11 +115,25 @@ class _OutlineBlockWidgetState extends State builder: (context, snapshot) { Widget child = _buildOutlineBlock(); + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + remoteSelection: editorState.remoteSelections, + blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + if (UniversalPlatform.isDesktopOrWeb) { if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } @@ -170,6 +202,7 @@ class _OutlineBlockWidgetState extends State } return Container( + key: blockComponentKey, constraints: const BoxConstraints( minHeight: 40.0, ), @@ -263,10 +296,13 @@ class OutlineItemWidget extends StatelessWidget { textDirection: textDirection, children: [ HSpace(node.leftIndent), - Text( - node.outlineItemText, - textDirection: textDirection, - style: style, + Flexible( + child: Text( + node.outlineItemText, + textDirection: textDirection, + style: style, + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart new file mode 100644 index 0000000000..731ba4c7cd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/block_component/base_component/widget/ignore_parent_gesture.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class CustomPageBlockComponentBuilder extends BlockComponentBuilder { + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + return CustomPageBlockComponent( + key: blockComponentContext.node.key, + node: blockComponentContext.node, + header: blockComponentContext.header, + footer: blockComponentContext.footer, + ); + } +} + +class CustomPageBlockComponent extends BlockComponentStatelessWidget { + const CustomPageBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + this.header, + this.footer, + }); + + final Widget? header; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final editorState = context.read(); + final scrollController = context.read(); + final items = node.children; + + if (scrollController == null || scrollController.shrinkWrap) { + return SingleChildScrollView( + child: Builder( + builder: (context) { + final scroller = Scrollable.maybeOf(context); + if (scroller != null) { + editorState.updateAutoScroller(scroller); + } + return Column( + children: [ + if (header != null) header!, + ...items.map( + (e) => Container( + constraints: BoxConstraints( + maxWidth: + editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: editorState.editorStyle.padding, + child: editorState.renderer.build(context, e), + ), + ), + if (footer != null) footer!, + ], + ); + }, + ), + ); + } else { + int extentCount = 0; + if (header != null) extentCount++; + if (footer != null) extentCount++; + + return ScrollablePositionedList.builder( + shrinkWrap: scrollController.shrinkWrap, + itemCount: items.length + extentCount, + itemBuilder: (context, index) { + editorState.updateAutoScroller(Scrollable.of(context)); + if (header != null && index == 0) { + return IgnoreEditorSelectionGesture( + child: header!, + ); + } + + if (footer != null && index == (items.length - 1) + extentCount) { + return IgnoreEditorSelectionGesture( + child: footer!, + ); + } + + final childNode = items[index - (header != null ? 1 : 0)]; + final isOverflowType = overflowTypes.contains(childNode.type); + + final item = Container( + constraints: BoxConstraints( + maxWidth: editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: isOverflowType + ? EdgeInsets.zero + : editorState.editorStyle.padding, + child: editorState.renderer.build( + context, + childNode, + ), + ); + + return isOverflowType ? item : Center(child: item); + }, + itemScrollController: scrollController.itemScrollController, + scrollOffsetController: scrollController.scrollOffsetController, + itemPositionsListener: scrollController.itemPositionsListener, + scrollOffsetListener: scrollController.scrollOffsetListener, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart index 27498cc65e..6136392884 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -225,8 +225,7 @@ class PageStyleCoverImage extends StatelessWidget { (s) => s, (f) => null, ); - final isAppFlowyCloud = - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud; + final isAppFlowyCloud = userProfile?.authType == AuthTypePB.Server; final PageStyleCoverImageType type; if (!isAppFlowyCloud) { result = await saveImageToLocalStorage(path); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart index 2578b6de07..b2cd77f312 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart @@ -37,7 +37,7 @@ class PageStyleIconBloc extends Bloc { emit(state.copyWith(icon: icon)); if (shouldUpdateRemote && icon != null) { await ViewBackendService.updateViewIcon( - viewId: view.id, + view: view, viewIcon: icon, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart index 867fcf236f..d9cf060e3b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class CalloutNodeParser extends NodeParser { @@ -9,8 +10,6 @@ class CalloutNodeParser extends NodeParser { @override String transform(Node node, DocumentMarkdownEncoder? encoder) { - assert(node.children.isEmpty); - final icon = node.attributes[CalloutBlockKeys.icon]; final delta = node.delta ?? Delta() ..insert(''); final String markdown = DeltaMarkdownEncoder() @@ -18,9 +17,15 @@ class CalloutNodeParser extends NodeParser { .split('\n') .map((e) => '> $e') .join('\n'); + final type = node.attributes[CalloutBlockKeys.iconType]; + final icon = type == FlowyIconType.emoji.name || type == null || type == "" + ? node.attributes[CalloutBlockKeys.icon] + : null; + + final content = icon == null ? markdown : "> $icon\n$markdown"; + return ''' -> $icon -$markdown +$content '''; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart index 91398302ed..d4b6bb444f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart @@ -1,4 +1,9 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; import '../image/custom_image_block_component/custom_image_block_component.dart'; @@ -16,3 +21,64 @@ class CustomImageNodeParser extends NodeParser { return '![]($url)\n'; } } + +class CustomImageNodeFileParser extends NodeParser { + const CustomImageNodeFileParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String get id => ImageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final url = node.attributes[CustomImageBlockKeys.url]; + final hasFile = File(url).existsSync(); + if (hasFile) { + final bytes = File(url).readAsBytesSync(); + files.add( + Future.value( + ArchiveFile(p.join(dirPath, p.basename(url)), bytes.length, bytes), + ), + ); + return '![](${p.join(dirPath, p.basename(url))})\n'; + } + assert(url != null); + return '![]($url)\n'; + } +} + +class CustomMultiImageNodeFileParser extends NodeParser { + const CustomMultiImageNodeFileParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String get id => MultiImageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final images = node.attributes[MultiImageBlockKeys.images] as List; + final List markdownImages = []; + for (final image in images) { + final String url = image['url'] ?? ''; + if (url.isEmpty) continue; + final hasFile = File(url).existsSync(); + if (hasFile) { + final bytes = File(url).readAsBytesSync(); + final filePath = p.join(dirPath, p.basename(url)); + files.add( + Future.value(ArchiveFile(filePath, bytes.length, bytes)), + ); + markdownImages.add('![]($filePath)'); + } else { + markdownImages.add('![]($url)'); + } + } + return markdownImages.join('\n'); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart new file mode 100644 index 0000000000..b7d7674137 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class CustomParagraphNodeParser extends NodeParser { + const CustomParagraphNodeParser(); + + @override + String get id => ParagraphBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final delta = node.delta; + if (delta != null) { + for (final o in delta) { + final attribute = o.attributes ?? {}; + final Map? mention = attribute[MentionBlockKeys.mention] ?? {}; + if (mention == null) continue; + + /// filter date reminder node, and return it + final String date = mention[MentionBlockKeys.date] ?? ''; + if (date.isNotEmpty) { + final dateTime = DateTime.tryParse(date); + if (dateTime == null) continue; + return '${DateFormat.yMMMd().format(dateTime)}\n'; + } + + /// filter reference page + final String pageId = mention[MentionBlockKeys.pageId] ?? ''; + if (pageId.isNotEmpty) { + return '[]($pageId)\n'; + } + } + } + return const TextNodeParser().transform(node, encoder); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart new file mode 100644 index 0000000000..3ba599d491 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; +import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +abstract class DatabaseNodeParser extends NodeParser { + DatabaseNodeParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final String viewId = node.attributes[DatabaseBlockKeys.viewID] ?? ''; + if (viewId.isEmpty) return ''; + files.add(_convertDatabaseToCSV(viewId)); + return '[](${p.join(dirPath, '$viewId.csv')})\n'; + } + + Future _convertDatabaseToCSV(String viewId) async { + final result = await BackendExportService.exportDatabaseAsCSV(viewId); + final filePath = p.join(dirPath, '$viewId.csv'); + ArchiveFile file = ArchiveFile.string(filePath, ''); + result.fold( + (s) => file = ArchiveFile.string(filePath, s.data), + (f) => Log.error('convertDatabaseToCSV error with $viewId, error: $f'), + ); + return file; + } +} + +class GridNodeParser extends DatabaseNodeParser { + GridNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.gridType; +} + +class BoardNodeParser extends DatabaseNodeParser { + BoardNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.boardType; +} + +class CalendarNodeParser extends DatabaseNodeParser { + CalendarNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.calendarType; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart index 3f2895d57e..c0a15629b8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart @@ -1,5 +1,7 @@ export 'callout_node_parser.dart'; export 'custom_image_node_parser.dart'; +export 'custom_paragraph_node_parser.dart'; +export 'database_node_parser.dart'; export 'file_block_node_parser.dart'; export 'link_preview_node_parser.dart'; export 'math_equation_node_parser.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart index a857d1ca8f..09973021f1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart @@ -106,6 +106,7 @@ class MarkdownSimpleTableParser extends CustomMarkdownParser { return [ simpleTableBlockNode( children: rows, + enableHeaderRow: true, columnWidths: UniversalPlatform.isMobile || tableWidth == null ? null : {for (var i = 0; i < th.length; i++) i.toString(): tableWidth!}, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart new file mode 100644 index 0000000000..1cf0c569bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +class SubPageNodeParser extends NodeParser { + const SubPageNodeParser(); + + @override + String get id => SubPageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final String viewId = node.attributes[SubPageBlockKeys.viewId] ?? ''; + if (viewId.isNotEmpty) { + final view = pageMemorizer[viewId]; + return '[$viewId](${view?.name ?? ''})\n'; + } + return ''; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index a7d63f2786..4161036a08 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -1,5 +1,7 @@ export 'actions/block_action_list.dart'; export 'actions/option/option_actions.dart'; +export 'ai/ai_writer_block_component.dart'; +export 'ai/ai_writer_toolbar_item.dart'; export 'align_toolbar_item/align_toolbar_item.dart'; export 'base/backtick_character_command.dart'; export 'base/cover_title_command.dart'; @@ -8,6 +10,10 @@ export 'bulleted_list/bulleted_list_icon.dart'; export 'callout/callout_block_component.dart'; export 'code_block/code_block_language_selector.dart'; export 'code_block/code_block_menu_item.dart'; +export 'columns/simple_column_block_component.dart'; +export 'columns/simple_column_block_width_resizer.dart'; +export 'columns/simple_column_node_extension.dart'; +export 'columns/simple_columns_block_component.dart'; export 'context_menu/custom_context_menu.dart'; export 'copy_and_paste/custom_copy_command.dart'; export 'copy_and_paste/custom_cut_command.dart'; @@ -34,7 +40,6 @@ export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; export 'keyboard_interceptor/keyboard_interceptor.dart'; export 'link_preview/custom_link_preview.dart'; -export 'link_preview/link_preview_cache.dart'; export 'link_preview/link_preview_menu.dart'; export 'math_equation/math_equation_block_component.dart'; export 'math_equation/mobile_math_equation_toolbar_item.dart'; @@ -53,13 +58,11 @@ export 'mobile_toolbar_v3/toolbar_item_builder.dart'; export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart'; export 'mobile_toolbar_v3/util.dart'; export 'numbered_list/numbered_list_icon.dart'; -export 'ai/ai_writer_block_component.dart'; -export 'ai/ask_ai_block_component.dart'; -export 'ai/ask_ai_toolbar_item.dart'; export 'outline/outline_block_component.dart'; export 'parsers/document_markdown_parsers.dart'; export 'parsers/markdown_parsers.dart'; export 'parsers/markdown_simple_table_parser.dart'; +export 'quote/quote_block_component.dart'; export 'quote/quote_block_shortcuts.dart'; export 'shortcuts/character_shortcuts.dart'; export 'shortcuts/command_shortcuts.dart'; @@ -72,3 +75,4 @@ export 'table/table_option_action.dart'; export 'todo_list/todo_list_icon.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcuts.dart'; +export 'video/video_block_component.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart new file mode 100644 index 0000000000..39ab2c5327 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart @@ -0,0 +1,324 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// In memory cache of the quote block height to avoid flashing when the quote block is updated. +Map _quoteBlockHeightCache = {}; + +typedef QuoteBlockIconBuilder = Widget Function( + BuildContext context, + Node node, +); + +class QuoteBlockKeys { + const QuoteBlockKeys._(); + + static const String type = 'quote'; + + static const String delta = blockComponentDelta; + + static const String backgroundColor = blockComponentBackgroundColor; + + static const String textDirection = blockComponentTextDirection; +} + +Node quoteNode({ + Delta? delta, + String? textDirection, + Attributes? attributes, + Iterable? children, +}) { + attributes ??= {'delta': (delta ?? Delta()).toJson()}; + return Node( + type: QuoteBlockKeys.type, + attributes: { + ...attributes, + if (textDirection != null) QuoteBlockKeys.textDirection: textDirection, + }, + children: children ?? [], + ); +} + +class QuoteBlockComponentBuilder extends BlockComponentBuilder { + QuoteBlockComponentBuilder({ + super.configuration, + this.iconBuilder, + }); + + final QuoteBlockIconBuilder? iconBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return QuoteBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + iconBuilder: iconBuilder, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.delta != null; +} + +class QuoteBlockComponentWidget extends BlockComponentStatefulWidget { + const QuoteBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + this.iconBuilder, + }); + + final QuoteBlockIconBuilder? iconBuilder; + + @override + State createState() => + _QuoteBlockComponentWidgetState(); +} + +class _QuoteBlockComponentWidgetState extends State + with + SelectableMixin, + DefaultSelectableMixin, + BlockComponentConfigurable, + BlockComponentBackgroundColorMixin, + BlockComponentTextDirectionMixin, + BlockComponentAlignMixin, + NestedBlockComponentStatefulWidgetMixin { + @override + final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); + + @override + GlobalKey> get containerKey => widget.node.key; + + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: QuoteBlockKeys.type, + ); + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + late ValueNotifier quoteBlockHeightNotifier = ValueNotifier( + _quoteBlockHeightCache[node.id] ?? 0, + ); + + StreamSubscription? _transactionSubscription; + + final GlobalKey layoutBuilderKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _updateQuoteBlockHeight(); + } + + @override + void dispose() { + _transactionSubscription?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + key: layoutBuilderKey, + onNotification: (notification) { + _updateQuoteBlockHeight(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: node.children.isEmpty + ? buildComponent(context) + : buildComponentWithChildren(context), + ), + ); + } + + @override + Widget buildComponentWithChildren(BuildContext context) { + final Widget child = Stack( + children: [ + Positioned.fill( + left: UniversalPlatform.isMobile ? padding.left : cachedLeft, + right: UniversalPlatform.isMobile ? padding.right : 0, + child: Container( + color: backgroundColor, + ), + ), + NestedListWidget( + indentPadding: indentPadding, + child: buildComponent(context, withBackgroundColor: false), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ], + ); + + return child; + } + + @override + Widget buildComponent( + BuildContext context, { + bool withBackgroundColor = true, + }) { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + + Widget child = AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + textAlign: alignment?.toTextAlign ?? textAlign, + placeholderText: placeholderText, + textSpanDecorator: (textSpan) => textSpan.updateTextStyle( + textStyleWithTextSpan(textSpan: textSpan), + ), + placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( + placeholderTextStyleWithTextSpan(textSpan: textSpan), + ), + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + cursorWidth: editorState.editorStyle.cursorWidth, + ); + + child = Container( + width: double.infinity, + alignment: alignment, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + widget.iconBuilder != null + ? widget.iconBuilder!(context, node) + : ValueListenableBuilder( + valueListenable: quoteBlockHeightNotifier, + builder: (context, height, child) { + return QuoteIcon(height: height); + }, + ), + Flexible( + child: child, + ), + ], + ), + ), + ); + + child = Container( + color: withBackgroundColor ? backgroundColor : null, + child: Padding( + key: blockComponentKey, + padding: padding, + child: child, + ), + ); + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + remoteSelection: editorState.remoteSelections, + blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, + child: child, + ); + } + + return child; + } + + void _updateQuoteBlockHeight() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final renderObject = layoutBuilderKey.currentContext?.findRenderObject(); + double height = _quoteBlockHeightCache[node.id] ?? 0; + if (renderObject != null && renderObject is RenderBox) { + if (UniversalPlatform.isMobile) { + height = renderObject.size.height - padding.top; + } else { + height = renderObject.size.height - padding.top * 2; + } + } else { + height = 0; + } + + quoteBlockHeightNotifier.value = height; + _quoteBlockHeightCache[node.id] = height; + }); + } +} + +class QuoteIcon extends StatelessWidget { + const QuoteIcon({ + super.key, + this.height = 0, + }); + + final double height; + + @override + Widget build(BuildContext context) { + final textScaleFactor = + context.read().editorStyle.textScaleFactor; + return Container( + alignment: Alignment.center, + constraints: + const BoxConstraints(minWidth: 22, minHeight: 22, maxHeight: 22) * + textScaleFactor, + padding: const EdgeInsets.only(right: 6.0), + child: SizedBox( + width: 3 * textScaleFactor, + // use overflow box to ensure the container can overflow the height so that the children of the quote block can have the quote + child: OverflowBox( + alignment: Alignment.topCenter, + maxHeight: height, + child: Container( + width: 3 * textScaleFactor, + height: height, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart index ba3ad6e7df..47c6549923 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart @@ -30,10 +30,25 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { await editorState.deleteSelection(selection); if (HardwareKeyboard.instance.isShiftPressed) { - await editorState.insertNewLine(); - } else { - await editorState.insertTextAtCurrentSelection('\n'); + // ignore the shift+enter event, fallback to the default behavior + return false; + } else if (node.children.isEmpty && + selection.endIndex == node.delta?.length) { + // insert a new paragraph within the callout block + final path = node.path.child(0); + final transaction = editorState.transaction; + transaction.insertNode( + path, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + await editorState.apply(transaction); + return true; } - return true; + return false; }; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart index 158d14f48e..13b2fea5ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -44,6 +45,7 @@ List buildCharacterShortcutEvents( customFormatGreaterEqual, customFormatDashGreater, + customFormatDoubleHyphenEmDash, customFormatNumberToNumberedList, customFormatSignToHeading, @@ -55,6 +57,7 @@ List buildCharacterShortcutEvents( formatGreaterEqual, // Overridden by customFormatGreaterEqual formatNumberToNumberedList, // Overridden by customFormatNumberToNumberedList formatSignToHeading, // Overridden by customFormatSignToHeading + formatDoubleHyphenEmDash, // Overridden by customFormatDoubleHyphenEmDash ].contains(shortcut), ), @@ -80,5 +83,9 @@ List buildCharacterShortcutEvents( documentBloc.documentId, styleCustomizer.inlineActionsMenuStyleBuilder(), ), + + /// show emoji list + /// - Using `:` + emojiCommand(context), ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart index 1f409255c6..aedfcff432 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart @@ -1,5 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart'; @@ -39,6 +40,7 @@ List commandShortcutEvents = [ ...customTextAlignCommands, customDeleteCommand, + insertInlineMathEquationCommand, // remove standard shortcuts for copy, cut, paste, todo ...standardCommandShortcutEvents diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart index 5d4c3cab52..ee6020793c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart @@ -161,6 +161,10 @@ class SimpleTableBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -174,6 +178,7 @@ class SimpleTableBlockWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); @@ -262,6 +267,7 @@ class _SimpleTableBlockWidgetState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart index 376ed90069..29b3c3455f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart @@ -47,6 +47,10 @@ class SimpleTableCellBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -60,6 +64,7 @@ class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); @@ -468,7 +473,7 @@ class SimpleTableCellBlockWidgetState extends State final isSelectingTable = simpleTableContext?.isSelectingTable.value ?? false; if (isSelectingTable) { - return Theme.of(context).colorScheme.primary.withOpacity(0.1); + return Theme.of(context).colorScheme.primary.withValues(alpha: 0.1); } final columnColor = node.buildColumnColor(context); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart index 6bdbcd516a..295a636e09 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart @@ -191,7 +191,7 @@ class SimpleTableContext { class SimpleTableConstants { /// Table - static const defaultColumnWidth = 120.0; + static const defaultColumnWidth = 160.0; static const minimumColumnWidth = 36.0; static const defaultRowHeight = 36.0; @@ -294,7 +294,7 @@ extension SimpleTableColors on BuildContext { Color get simpleTableDividerColor => Theme.of(this).isLightMode ? const Color(0x141F2329) - : const Color(0xFF23262B).withOpacity(0.5); + : const Color(0xFF23262B).withValues(alpha: 0.5); Color get simpleTableMoreActionBackgroundColor => Theme.of(this).isLightMode ? const Color(0xFFF2F3F5) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart index be82ee3627..4906ed85eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart @@ -412,7 +412,9 @@ class SimpleTableActionMenu extends StatelessWidget { editorState.service.keyboardService?.closeKeyboard(); // delay the bottom sheet show to make sure the keyboard is closed Future.delayed(Durations.short3, () { - _showTableActionBottomSheet(context); + if (context.mounted) { + _showTableActionBottomSheet(context); + } }); }, child: Container( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart index 021181591f..a60ece2c2c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -18,9 +18,15 @@ extension TableHeaderOperation on EditorState { 'toggle enable header column: $enable in table ${tableNode.id}', ); + final columnColors = tableNode.columnColors; + final transaction = this.transaction; transaction.updateNode(tableNode, { SimpleTableBlockKeys.enableHeaderColumn: enable, + // remove the previous background color if the header column is enable again + if (enable) + SimpleTableBlockKeys.columnColors: columnColors + ..removeWhere((key, _) => key == '0'), }); await apply(transaction); } @@ -38,9 +44,15 @@ extension TableHeaderOperation on EditorState { Log.info('toggle enable header row: $enable in table ${tableNode.id}'); + final rowColors = tableNode.rowColors; + final transaction = this.transaction; transaction.updateNode(tableNode, { SimpleTableBlockKeys.enableHeaderRow: enable, + // remove the previous background color if the header row is enable again + if (enable) + SimpleTableBlockKeys.rowColors: rowColors + ..removeWhere((key, _) => key == '0'), }); await apply(transaction); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart index 0987bc29d3..1a2e21c305 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart @@ -238,8 +238,13 @@ extension TableNodeExtension on Node { try { final columnWidths = parentTableNode.attributes[SimpleTableBlockKeys.columnWidths]; - final width = columnWidths?[columnIndex.toString()]; - return width ?? SimpleTableConstants.defaultColumnWidth; + final width = columnWidths?[columnIndex.toString()] as Object?; + if (width == null) { + return SimpleTableConstants.defaultColumnWidth; + } + return width.toDouble( + defaultValue: SimpleTableConstants.defaultColumnWidth, + ); } catch (e) { Log.warn('get column width: $e'); return SimpleTableConstants.defaultColumnWidth; @@ -856,3 +861,18 @@ extension TableNodeExtension on Node { return TableAlign.left; } } + +extension on Object { + double toDouble({double defaultValue = 0}) { + if (this is double) { + return this as double; + } + if (this is String) { + return double.tryParse(this as String) ?? defaultValue; + } + if (this is int) { + return (this as int).toDouble(); + } + return defaultValue; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart index d61d371e1b..99f23d1ee9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart @@ -39,6 +39,10 @@ class SimpleTableRowBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -52,6 +56,7 @@ class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart index 74bdc3fc62..bfc31e8abc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -40,7 +41,9 @@ KeyEventResult _enterInTableCellHandler(EditorState editorState) { return KeyEventResult.handled; } } - return convertToParagraphCommand.execute(editorState); + if (node.type != CalloutBlockKeys.type) { + return convertToParagraphCommand.execute(editorState); + } } return KeyEventResult.ignored; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart index aae1acb68b..b81ff89ee8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart @@ -622,7 +622,7 @@ class _SimpleTableHeaderActionButtonState fit: BoxFit.fill, child: CupertinoSwitch( value: value, - activeColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: (_) => _toggle(), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart index 644aae1926..97519422ec 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart @@ -264,7 +264,7 @@ class _SimpleTableCellBottomSheetState } void _onTextColorSelected(Color color) { - final hex = color.alpha == 0 ? null : color.toHex(); + final hex = color.a == 0 ? null : color.toHex(); switch (widget.type) { case SimpleTableMoreActionType.column: widget.editorState.updateColumnTextColor( @@ -284,7 +284,7 @@ class _SimpleTableCellBottomSheetState } void _onCellBackgroundColorSelected(Color color) { - final hex = color.alpha == 0 ? null : color.toHex(); + final hex = color.a == 0 ? null : color.toHex(); switch (widget.type) { case SimpleTableMoreActionType.column: widget.editorState.updateColumnBackgroundColor( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart index f8bf2a40a7..3d6fe113c1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart @@ -1,8 +1,7 @@ -import 'dart:io'; - +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -50,6 +49,7 @@ CharacterShortcutEvent customAppFlowySlashCommand({ } SelectionMenuService? _selectionMenuService; + Future _showSlashMenu( EditorState editorState, { required SlashMenuItemsBuilder itemsBuilder, @@ -59,10 +59,6 @@ Future _showSlashMenu( SelectionMenuStyle style = SelectionMenuStyle.light, required Set supportSlashMenuNodeTypes, }) async { - if (UniversalPlatform.isMobile) { - return false; - } - final selection = editorState.selection; if (selection == null) { return false; @@ -99,25 +95,35 @@ Future _showSlashMenu( final context = editorState.getNodeAtPath(selection.start.path)?.context; if (context != null && context.mounted) { - _selectionMenuService = SelectionMenu( - context: context, - editorState: editorState, - selectionMenuItems: items, - deleteSlashByDefault: shouldInsertSlash, - deleteKeywordsByDefault: deleteKeywordsByDefault, - singleColumn: singleColumn, - style: style, - ); + final isLight = Theme.of(context).brightness == Brightness.light; + _selectionMenuService?.dismiss(); + _selectionMenuService = UniversalPlatform.isMobile + ? MobileSelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: isLight + ? MobileSelectionMenuStyle.light + : MobileSelectionMenuStyle.dark, + startOffset: editorState.selection?.start.offset ?? 0, + ) + : SelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + ); // disable the keyboard service editorState.service.keyboardService?.disable(); - if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { - await _selectionMenuService?.show(); - } else { - await _selectionMenuService?.show(); - } - + await _selectionMenuService?.show(); // enable the keyboard service editorState.service.keyboardService?.enable(); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart index 4e3f455522..46d1b4eabb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -15,41 +15,67 @@ final _keywords = [ 'autogenerator', ]; -// auto generate menu item SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_aiWriter.tr, - keywords: _keywords, - handler: (editorState, _, __) async => editorState.insertAiWriter(), + keywords: [ + ..._keywords, + LocaleKeys.document_slashMenu_name_aiWriter.tr(), + ], + handler: (editorState, _, __) async => + _insertAiWriter(editorState, AiWriterCommand.userQuestion), icon: (_, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_ai_writer_s, + data: AiWriterCommand.userQuestion.icon, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, ); -extension on EditorState { - Future insertAiWriter() async { - final selection = this.selection; - if (selection == null || !selection.isCollapsed) { - return; - } - final node = getNodeAtPath(selection.end.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - final newNode = aiWriterNode(start: selection); +SelectionMenuItem continueWritingSlashMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_aiWriter_continueWriting.tr, + keywords: [ + ..._keywords, + LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), + ], + handler: (editorState, _, __) async => + _insertAiWriter(editorState, AiWriterCommand.continueWriting), + icon: (_, isSelected, style) => SelectableSvgWidget( + data: AiWriterCommand.continueWriting.icon, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, +); - final transaction = this.transaction; - //default insert after - final path = node.path.next; - transaction - ..insertNode(path, newNode) - ..afterSelection = null; - await apply( - transaction, - options: const ApplyOptions(inMemoryUpdate: true), - ); +Future _insertAiWriter( + EditorState editorState, + AiWriterCommand action, +) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; } + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null || node.delta == null) { + return; + } + final newNode = aiWriterNode( + selection: selection, + command: action, + ); + + // default insert after + final path = node.path.next; + final transaction = editorState.transaction + ..insertNode(path, newNode) + ..afterSelection = null; + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart index 0bb09d0a7e..04a8ee1b7e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart @@ -46,12 +46,12 @@ extension on EditorState { selection.start.offset, 0, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), ); await apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart index f0ce852e41..844b73c3e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart @@ -17,17 +17,20 @@ final _keywords = [ ]; /// Image menu item -final imageSlashMenuItem = SelectionMenuItem( - getName: () => LocaleKeys.document_slashMenu_name_image.tr(), - keywords: _keywords, - handler: (editorState, _, __) async => editorState.insertImageBlock(), - nameBuilder: slashMenuItemNameBuilder, - icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_image_s, - isSelected: isSelected, - style: style, - ), -); +final imageSlashMenuItem = buildImageSlashMenuItem(); + +SelectionMenuItem buildImageSlashMenuItem({FlowySvgData? svg}) => + SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_image.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertImageBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: svg ?? FlowySvgs.slash_menu_icon_image_s, + isSelected: isSelected, + style: style, + ), + ); extension on EditorState { Future insertImageBlock() async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart new file mode 100644 index 0000000000..b71b54ad40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart @@ -0,0 +1,131 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_items.dart'; + +final List mobileItems = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileAndMediaMobileSlashMenuItem, + mobileTableSlashMenuItem, + visualsMobileSlashMenuItem, + dateOrReminderSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), + advancedMobileSlashMenuItem, +]; + +final List mobileItemsInTale = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileAndMediaMobileSlashMenuItem, + visualsMobileSlashMenuItem, + dateOrReminderSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), + advancedMobileSlashMenuItem, +]; + +SelectionMenuItemHandler _handler = (_, __, ___) {}; + +MobileSelectionMenuItem textStyleMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_textStyle.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_text_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + paragraphSlashMenuItem, + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem listMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_list.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_bulleted_list_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + todoListSlashMenuItem, + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + ], +); + +MobileSelectionMenuItem toggleListMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_toggle.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_toggle_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + toggleListSlashMenuItem, + toggleHeading1SlashMenuItem, + toggleHeading2SlashMenuItem, + toggleHeading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem fileAndMediaMobileSlashMenuItem = + MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_fileAndMedia.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_file_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + buildImageSlashMenuItem(svg: FlowySvgs.slash_menu_image_m), + photoGallerySlashMenuItem, + fileSlashMenuItem, + ], +); + +MobileSelectionMenuItem visualsMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_visuals.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_visuals_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + calloutSlashMenuItem, + dividerSlashMenuItem, + quoteSlashMenuItem, + ], +); + +MobileSelectionMenuItem advancedMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_advanced.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.drag_element_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + codeBlockSlashMenuItem, + mathEquationSlashMenuItem, + ], +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart new file mode 100644 index 0000000000..8609b76e70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final _baseKeywords = [ + 'columns', + 'column block', +]; + +final _twoColumnsKeywords = [ + ..._baseKeywords, + 'two columns', + '2 columns', +]; + +final _threeColumnsKeywords = [ + ..._baseKeywords, + 'three columns', + '3 columns', +]; + +final _fourColumnsKeywords = [ + ..._baseKeywords, + 'four columns', + '4 columns', +]; + +// 2 columns menu item +SelectionMenuItem twoColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_twoColumns.tr(), + keywords: _twoColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 2), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_two_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +// 3 columns menu item +SelectionMenuItem threeColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_threeColumns.tr(), + keywords: _threeColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 3), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_three_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +// 4 columns menu item +SelectionMenuItem fourColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_fourColumns.tr(), + keywords: _fourColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 4), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_four_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +Node _buildColumnsNode(EditorState editorState, int columnCount) { + return simpleColumnsNode( + columnCount: columnCount, + ratio: 1.0 / columnCount, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart index 1b05d70091..9e16571d39 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart @@ -27,6 +27,18 @@ SelectionMenuItem tableSlashMenuItem = SelectionMenuItem( ), ); +SelectionMenuItem mobileTableSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_simpleTable.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertSimpleTable(), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_simple_table_s, + isSelected: isSelected, + style: style, + ), +); + extension on EditorState { Future insertSimpleTable() async { final selection = this.selection; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart index 85f8a6895b..fada0addd9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart @@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; /// Builder function for the slash menu item. Widget slashMenuItemNameBuilder( @@ -49,9 +50,10 @@ class SlashMenuItemNameBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = UniversalPlatform.isMobile; return FlowyText.regular( name, - fontSize: 12.0, + fontSize: isMobile ? 16.0 : 12.0, figmaLineHeight: 15.0, color: isSelected ? style.selectionMenuItemSelectedTextColor @@ -80,9 +82,11 @@ class SlashMenuIconBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = UniversalPlatform.isMobile; return SelectableSvgWidget( data: data, isSelected: isSelected, + size: isMobile ? Size.square(20) : null, style: style, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart index ee76bc0ea3..27be8e4f03 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart @@ -15,6 +15,7 @@ export 'outline_item.dart'; export 'paragraph_item.dart'; export 'photo_gallery_item.dart'; export 'quote_item.dart'; +export 'simple_columns_item.dart'; export 'simple_table_item.dart'; export 'slash_menu_item_builder.dart'; export 'sub_page_item.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart index bc7f7e46b4..1052dbbe3e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart @@ -22,26 +22,29 @@ final _keywords = [ ]; // Sub-page menu item -SelectionMenuItem subPageSlashMenuItem = SelectionMenuItem.node( - getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), - keywords: _keywords, - updateSelection: (editorState, path, __, ___) { - final context = editorState.document.root.context; - if (context != null) { - final isInDatabase = - context.read().isInDatabaseRowPage; - if (isInDatabase) { - Navigator.of(context).pop(); - } - } - return Selection.collapsed(Position(path: path)); - }, - replace: (_, node) => node.delta?.isEmpty ?? false, - nodeBuilder: (_, __) => subPageNode(), - nameBuilder: slashMenuItemNameBuilder, - iconBuilder: (_, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.insert_document_s, - isSelected: isSelected, - style: style, - ), -); +SelectionMenuItem subPageSlashMenuItem = buildSubpageSlashMenuItem(); + +SelectionMenuItem buildSubpageSlashMenuItem({FlowySvgData? svg}) => + SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), + keywords: _keywords, + updateSelection: (editorState, path, __, ___) { + final context = editorState.document.root.context; + if (context != null) { + final isInDatabase = + context.read().isInDatabaseRowPage; + if (isInDatabase) { + Navigator.of(context).pop(); + } + } + return Selection.collapsed(Position(path: path)); + }, + replace: (_, node) => node.delta?.isEmpty ?? false, + nodeBuilder: (_, __) => subPageNode(), + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: svg ?? FlowySvgs.insert_document_s, + isSelected: isSelected, + style: style, + ), + ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart index 74ffa7e075..518dccb35e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart @@ -15,7 +15,7 @@ final _keywords = [ ]; final todoListSlashMenuItem = SelectionMenuItem( - getName: () => LocaleKeys.document_slashMenu_name_todoList.tr(), + getName: () => LocaleKeys.editor_checkbox.tr(), keywords: _keywords, handler: (editorState, _, __) async => insertCheckboxAfterSelection( editorState, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart index 3e9bdef355..137f592902 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart @@ -1,7 +1,11 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'slash_menu_items/mobile_items.dart'; import 'slash_menu_items/slash_menu_items.dart'; /// Build slash menu items @@ -11,16 +15,32 @@ List slashMenuItemsBuilder({ DocumentBloc? documentBloc, EditorState? editorState, Node? node, + ViewPB? view, }) { final isInTable = node != null && node.parentTableCellNode != null; - - if (isInTable) { - return _simpleTableSlashMenuItems(); + final isMobile = UniversalPlatform.isMobile; + bool isEmpty = false; + if (editorState == null || editorState.isEmptyForContinueWriting()) { + if (view == null || view.name.isEmpty) { + isEmpty = true; + } + } + if (isMobile) { + if (isInTable) { + return mobileItemsInTale; + } else { + return mobileItems; + } } else { - return _defaultSlashMenuItems( - isLocalMode: isLocalMode, - documentBloc: documentBloc, - ); + if (isInTable) { + return _simpleTableSlashMenuItems(); + } else { + return _defaultSlashMenuItems( + isLocalMode: isLocalMode, + documentBloc: documentBloc, + isEmpty: isEmpty, + ); + } } } @@ -38,10 +58,14 @@ List slashMenuItemsBuilder({ List _defaultSlashMenuItems({ bool isLocalMode = false, DocumentBloc? documentBloc, + bool isEmpty = false, }) { return [ - // disable ai writer in local mode - if (!isLocalMode) aiWriterSlashMenuItem, + // ai + if (!isLocalMode) ...[ + if (!isEmpty) continueWritingSlashMenuItem, + aiWriterSlashMenuItem, + ], paragraphSlashMenuItem, @@ -70,6 +94,12 @@ List _defaultSlashMenuItems({ // link to page linkToPageSlashMenuItem, + // columns + // 2-4 columns + twoColumnsSlashMenuItem, + threeColumnsSlashMenuItem, + fourColumnsSlashMenuItem, + // grid if (documentBloc != null) gridSlashMenuItem(documentBloc), referencedGridSlashMenuItem, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart index 35ff1f219b..a549e87f83 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart'; @@ -14,6 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class SubPageBlockTransactionHandler extends BlockTransactionHandler { @@ -162,7 +161,9 @@ class SubPageBlockTransactionHandler extends BlockTransactionHandler { if (UniversalPlatform.isDesktop) { getIt().openPlugin(view); } else { - await context.pushView(view); + if (context.mounted) { + await context.pushView(view); + } } }); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart index ad69d4c784..0ce2b74a74 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart @@ -73,6 +73,7 @@ class SubPageBlockComponent extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -295,6 +296,7 @@ class SubPageBlockComponentState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart index 99275b9a8e..9e93f80ce4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart @@ -1,8 +1,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class ToggleListBlockKeys { @@ -109,6 +111,10 @@ class ToggleListBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @@ -122,6 +128,7 @@ class ToggleListBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.padding = const EdgeInsets.all(0), this.textStyleBuilder, @@ -172,6 +179,7 @@ class _ToggleListBlockComponentWidgetState ); bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false; + int? get level => node.attributes[ToggleListBlockKeys.level] as int?; @override @@ -194,12 +202,16 @@ class _ToggleListBlockComponentWidgetState color: backgroundColor, ), ), - NestedListWidget( - indentPadding: indentPadding, - child: buildComponent(context), - children: editorState.renderer.buildList( - context, - widget.node.children, + Provider( + create: (context) => + DatabasePluginWidgetBuilderSize(horizontalPadding: 0.0), + child: NestedListWidget( + indentPadding: indentPadding, + child: buildComponent(context), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), ), ), ], @@ -240,6 +252,7 @@ class _ToggleListBlockComponentWidgetState child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart index 212be0f7bf..f3059bf1be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart @@ -47,15 +47,12 @@ Future _formatGreaterToToggleHeading( delta = delta.compose(Delta()..delete(_greater.length)); // if the previous block is heading block, convert it to toggle heading block if (type == HeadingBlockKeys.type && level != null) { - final cubit = BlockActionOptionCubit( - editorState: editorState, - blockComponentBuilder: {}, - ); - await cubit.turnIntoSingleToggleHeading( + await BlockActionOptionCubit.turnIntoSingleToggleHeading( type: ToggleListBlockKeys.type, selectedNodes: [node], level: level, delta: delta, + editorState: editorState, afterSelection: afterSelection, ); return; @@ -98,7 +95,8 @@ CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent( } final slicedDelta = delta.slice(selection.start.offset); final transaction = editorState.transaction; - final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool; + final bool collapsed = + node.attributes[ToggleListBlockKeys.collapsed] ?? false; if (collapsed) { // if the delta is empty, clear the format if (delta.isEmpty) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart new file mode 100644 index 0000000000..d4f3d21f46 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +import 'custom_placeholder_toolbar_item.dart'; +import 'toolbar_id_enum.dart'; + +final List customMarkdownFormatItems = [ + _FormatToolbarItem( + id: ToolbarId.bold, + name: 'bold', + svg: FlowySvgs.toolbar_bold_m, + ), + group1PaddingItem, + _FormatToolbarItem( + id: ToolbarId.underline, + name: 'underline', + svg: FlowySvgs.toolbar_underline_m, + ), + group1PaddingItem, + _FormatToolbarItem( + id: ToolbarId.italic, + name: 'italic', + svg: FlowySvgs.toolbar_inline_italic_m, + ), +]; + +final ToolbarItem customInlineCodeItem = _FormatToolbarItem( + id: ToolbarId.code, + name: 'code', + svg: FlowySvgs.toolbar_inline_code_m, + group: 2, +); + +class _FormatToolbarItem extends ToolbarItem { + _FormatToolbarItem({ + required ToolbarId id, + required String name, + required FlowySvgData svg, + super.group = 1, + }) : super( + id: id.id, + isActive: showInAnyTextType, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection( + selection, + (delta) => + delta.isNotEmpty && + delta.everyAttributes((attr) => attr[name] == true), + ); + + final hoverColor = isHighlight + ? highlightColor + : EditorStyleCustomizer.toolbarHoverColor(context); + final isDark = !Theme.of(context).isLightMode; + final theme = AppFlowyTheme.of(context); + + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: hoverColor, + isSelected: isHighlight, + icon: FlowySvg( + svg, + size: Size.square(20.0), + color: (isDark && isHighlight) + ? Color(0xFF282E3A) + : theme.iconColorScheme.primary, + ), + onPressed: () => editorState.toggleAttribute( + name, + selection: selection, + ), + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + id.id, + _getTooltipText(id), + child, + ); + } + return child; + }, + ); +} + +String _getTooltipText(ToolbarId id) { + switch (id) { + case ToolbarId.underline: + return '${LocaleKeys.toolbar_underline.tr()}${shortcutTooltips( + '⌘ + U', + 'CTRL + U', + 'CTRL + U', + )}'; + case ToolbarId.bold: + return '${LocaleKeys.toolbar_bold.tr()}${shortcutTooltips( + '⌘ + B', + 'CTRL + B', + 'CTRL + B', + )}'; + case ToolbarId.italic: + return '${LocaleKeys.toolbar_italic.tr()}${shortcutTooltips( + '⌘ + I', + 'CTRL + I', + 'CTRL + I', + )}'; + case ToolbarId.code: + return '${LocaleKeys.document_toolbar_inlineCode.tr()}${shortcutTooltips( + '⌘ + E', + 'CTRL + E', + 'CTRL + E', + )}'; + default: + return ''; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart new file mode 100644 index 0000000000..46f2c02c5a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -0,0 +1,227 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +String? _customHighlightColorHex; + +final customHighlightColorItem = ToolbarItem( + id: ToolbarId.highlightColor.id, + group: 1, + isActive: showInAnyTextType, + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => + HighlightColorPickerWidget( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ), +); + +class HighlightColorPickerWidget extends StatefulWidget { + const HighlightColorPickerWidget({ + super.key, + required this.editorState, + this.tooltipBuilder, + required this.highlightColor, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => + _HighlightColorPickerWidgetState(); +} + +class _HighlightColorPickerWidgetState + extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } + final selectionRectList = editorState.selectionRects(); + final top = + selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, top), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + margin: EdgeInsets.zero, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: SizedBox( + width: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_highlight_m, + size: Size(20, 16), + color: iconColor, + ), + buildColorfulDivider(iconColor), + ], + ), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.highlightColor.id, + AppFlowyEditorL10n.current.highlightColor, + child, + ) ?? + child; + } + + Widget buildColorfulDivider(Color? iconColor) { + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.backgroundColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + + final colorLength = colors.length; + if (colors.isEmpty || !isHighLight) { + return Container( + width: 20, + height: 4, + color: iconColor, + ); + } + return SizedBox( + width: 20, + height: 4, + child: Row( + children: List.generate(colorLength, (index) { + final currentColor = int.tryParse(colors[index]); + return Container( + width: 20 / colorLength, + height: 4, + color: currentColor == null ? iconColor : Color(currentColor), + ); + }), + ), + ); + } + + Widget buildPopoverContent() { + final List colors = []; + + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attributes) { + final highlightColorHex = + attributes[AppFlowyRichTextKeys.backgroundColor]; + if (highlightColorHex != null) colors.add(highlightColorHex); + return highlightColorHex != null; + }); + }); + bool showClearButton = false; + nodes.allSatisfyInSelection(selection, (delta) { + if (!showClearButton) { + showClearButton = delta.whereType().any( + (element) { + return element.attributes?[AppFlowyRichTextKeys.backgroundColor] != + null; + }, + ); + } + return true; + }); + return MouseRegion( + child: ColorPicker( + title: AppFlowyEditorL10n.current.highlightColor, + showClearButton: showClearButton, + selectedColorHex: + (colors.length == 1 && isHighlight) ? colors.first : null, + customColorHex: _customHighlightColorHex, + colorOptions: generateHighlightColorOptions(), + onSubmittedColorHex: (color, isCustomColor) { + if (isCustomColor) { + _customHighlightColorHex = color; + } + formatHighlightColor( + editorState, + editorState.selection, + color, + withUpdateSelection: true, + ); + hidePopover(); + }, + resetText: AppFlowyEditorL10n.current.clearHighlightColor, + resetIconName: 'clear_highlight_color', + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void hidePopover() { + popoverController.close(); + keepEditorFocusNotifier.decrease(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart new file mode 100644 index 0000000000..8c9e6b69da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'toolbar_id_enum.dart'; + +const kIsPageLink = 'is_page_link'; + +final customLinkItem = ToolbarItem( + id: ToolbarId.link.id, + group: 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHref = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[AppFlowyRichTextKeys.href] != null, + ); + }); + + final isDark = !Theme.of(context).isLightMode; + final hoverColor = isHref + ? highlightColor + : EditorStyleCustomizer.toolbarHoverColor(context); + final theme = AppFlowyTheme.of(context); + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: hoverColor, + isSelected: isHref, + icon: FlowySvg( + FlowySvgs.toolbar_link_m, + size: Size.square(20.0), + color: (isDark && isHref) + ? Color(0xFF282E3A) + : theme.iconColorScheme.primary, + ), + onPressed: () { + getIt().hideToolbar(); + if (!isHref) { + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + getIt() + .call(HoverTriggerKey(nodes.first.id, selection)); + }); + } + }, + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + ToolbarId.highlightColor.id, + AppFlowyEditorL10n.current.link, + child, + ); + } + + return child; + }, +); + +extension AttributeExtension on Attributes { + bool get isPage { + if (this[kIsPageLink] is bool) { + return this[kIsPageLink]; + } + return false; + } +} + +enum LinkMenuAlignment { + topLeft, + topRight, + bottomLeft, + bottomRight, +} + +extension LinkMenuAlignmentExtension on LinkMenuAlignment { + bool get isTop => + this == LinkMenuAlignment.topLeft || this == LinkMenuAlignment.topRight; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart new file mode 100644 index 0000000000..e087731c82 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customPlaceholderItem = ToolbarItem( + id: ToolbarId.placeholder.id, + group: -1, + isActive: (editorState) => true, + builder: (context, __, ___, ____, _____) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 5), + child: Container( + width: 1, + color: Color(0xffE8ECF3).withAlpha(isDark ? 40 : 255), + ), + ); + }, +); + +ToolbarItem buildPaddingPlaceholderItem( + int group, { + bool Function(EditorState editorState)? isActive, +}) => + ToolbarItem( + id: ToolbarId.paddingPlaceHolder.id, + group: group, + isActive: isActive, + builder: (context, __, ___, ____, _____) => HSpace(4), + ); + +ToolbarItem group0PaddingItem = buildPaddingPlaceholderItem( + 0, + isActive: onlyShowInTextTypeAndExcludeTable, +); + +ToolbarItem group1PaddingItem = + buildPaddingPlaceholderItem(1, isActive: showInAnyTextType); + +ToolbarItem group4PaddingItem = buildPaddingPlaceholderItem( + 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart new file mode 100644 index 0000000000..efaff532f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -0,0 +1,215 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customTextAlignItem = ToolbarItem( + id: ToolbarId.textAlign.id, + group: 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return TextAlignActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ); + }, +); + +class TextAlignActionList extends StatefulWidget { + const TextAlignActionList({ + super.key, + required this.editorState, + required this.highlightColor, + this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; + + @override + State createState() => _TextAlignActionListState(); +} + +class _TextAlignActionListState extends State { + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: widget.popoverDirection, + offset: widget.showOffset, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: widget.child ?? buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_alignment_m, + size: Size.square(20), + color: iconColor, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textAlign.id, + LocaleKeys.document_toolbar_textAlign.tr(), + child, + ) ?? + child; + } + + Widget buildPopoverContent() { + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: List.generate(TextAlignCommand.values.length, (index) { + final command = TextAlignCommand.values[index]; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.every( + (n) => n.attributes[blockComponentAlign] == command.name, + ); + + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + iconPadding: 12, + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: + isHighlight ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () { + command.onAlignChanged(editorState); + widget.onSelect?.call(); + popoverController.close(); + }, + ), + ); + }), + ), + ); + } +} + +enum TextAlignCommand { + left(FlowySvgs.toolbar_text_align_left_m), + center(FlowySvgs.toolbar_text_align_center_m), + right(FlowySvgs.toolbar_text_align_right_m); + + const TextAlignCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case left: + return LocaleKeys.document_toolbar_alignLeft.tr(); + case center: + return LocaleKeys.document_toolbar_alignCenter.tr(); + case right: + return LocaleKeys.document_toolbar_alignRight.tr(); + } + } + + Future onAlignChanged(EditorState editorState) async { + final selection = editorState.selection!; + + await editorState.updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: name, + }, + ), + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart new file mode 100644 index 0000000000..9f5a917b89 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -0,0 +1,226 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'toolbar_id_enum.dart'; + +String? _customColorHex; + +final customTextColorItem = ToolbarItem( + id: ToolbarId.textColor.id, + group: 1, + isActive: showInAnyTextType, + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => + TextColorPickerWidget( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ), +); + +class TextColorPickerWidget extends StatefulWidget { + const TextColorPickerWidget({ + super.key, + required this.editorState, + this.tooltipBuilder, + required this.highlightColor, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => _TextColorPickerWidgetState(); +} + +class _TextColorPickerWidgetState extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } + final selectionRectList = editorState.selectionRects(); + final top = + selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, top), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + margin: EdgeInsets.zero, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: SizedBox( + width: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_color_m, + size: Size(20, 16), + color: iconColor, + ), + buildColorfulDivider(iconColor), + ], + ), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textColor.id, + LocaleKeys.document_toolbar_textColor.tr(), + child, + ) ?? + child; + } + + Widget buildColorfulDivider(Color? iconColor) { + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.textColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + + final colorLength = colors.length; + if (colors.isEmpty || !isHighLight) { + return Container( + width: 20, + height: 4, + color: iconColor, + ); + } + return SizedBox( + width: 20, + height: 4, + child: Row( + children: List.generate(colorLength, (index) { + final currentColor = int.tryParse(colors[index]); + return Container( + width: 20 / colorLength, + height: 4, + color: currentColor == null ? iconColor : Color(currentColor), + ); + }), + ), + ); + } + + Widget buildPopoverContent() { + bool showClearButton = false; + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.textColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + nodes.allSatisfyInSelection( + selection, + (delta) { + if (!showClearButton) { + showClearButton = delta.whereType().any( + (element) { + return element.attributes?[AppFlowyRichTextKeys.textColor] != + null; + }, + ); + } + return true; + }, + ); + return MouseRegion( + child: ColorPicker( + title: LocaleKeys.document_toolbar_textColor.tr(), + showClearButton: showClearButton, + selectedColorHex: + (colors.length == 1 && isHighLight) ? colors.first : null, + customColorHex: _customColorHex, + colorOptions: generateTextColorOptions(), + onSubmittedColorHex: (color, isCustomColor) { + if (isCustomColor) { + _customColorHex = color; + } + formatFontColor( + editorState, + editorState.selection, + color, + withUpdateSelection: true, + ); + hidePopover(); + }, + resetText: AppFlowyEditorL10n.current.resetToDefaultColor, + resetIconName: 'reset_text_color', + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void hidePopover() { + popoverController.close(); + keepEditorFocusNotifier.decrease(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart new file mode 100644 index 0000000000..46b707a8d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart @@ -0,0 +1,455 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'custom_text_align_toolbar_item.dart'; +import 'text_suggestions_toolbar_item.dart'; + +const _kMoreOptionItemId = 'editor.more_option'; +const kFontToolbarItemId = 'editor.font'; + +@visibleForTesting +const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem'); + +final ToolbarItem moreOptionItem = ToolbarItem( + id: _kMoreOptionItemId, + group: 5, + isActive: showInAnyTextType, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return MoreOptionActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ); + }, +); + +class MoreOptionActionList extends StatefulWidget { + const MoreOptionActionList({ + super.key, + required this.editorState, + required this.highlightColor, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => _MoreOptionActionListState(); +} + +class _MoreOptionActionListState extends State { + final popoverController = PopoverController(); + PopoverController fontPopoverController = PopoverController(); + PopoverController suggestionsPopoverController = PopoverController(); + PopoverController textAlignPopoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + MoreOptionCommand? tappedCommand; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + fontPopoverController.close(); + suggestionsPopoverController.close(); + textAlignPopoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final iconColor = Theme.of(context).iconTheme.color; + final child = FlowyIconButton( + width: 36, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + size: Size.square(20), + color: iconColor, + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + _kMoreOptionItemId, + LocaleKeys.document_toolbar_moreOptions.tr(), + child, + ) ?? + child; + } + + Color? getFormulaColor() { + if (isFormulaHighlight(editorState)) { + return widget.highlightColor; + } + return null; + } + + Color? getStrikethroughColor() { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return null; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return null; + } + + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection( + selection, + (delta) => + delta.isNotEmpty && + delta.everyAttributes( + (attr) => attr[MoreOptionCommand.strikethrough.name] == true, + ), + ); + return isHighlight ? widget.highlightColor : null; + } + + Widget buildPopoverContent() { + final showFormula = onlyShowInSingleSelectionAndTextType(editorState); + const fontColor = Color(0xff99A1A8); + final isNarrow = isNarrowWindow(editorState); + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: [ + if (isNarrow) ...[ + buildTurnIntoSelector(), + buildCommandItem(MoreOptionCommand.link), + buildTextAlignSelector(), + ], + buildFontSelector(), + buildCommandItem( + MoreOptionCommand.strikethrough, + rightIcon: FlowyText( + shortcutTooltips( + '⌘⇧S', + 'Ctrl⇧S', + 'Ctrl⇧S', + ).trim(), + color: fontColor, + fontSize: 12, + figmaLineHeight: 16, + fontWeight: FontWeight.w400, + ), + ), + if (showFormula) + buildCommandItem( + MoreOptionCommand.formula, + rightIcon: FlowyText( + shortcutTooltips( + '⌘⇧E', + 'Ctrl⇧E', + 'Ctrl⇧E', + ).trim(), + color: fontColor, + fontSize: 12, + figmaLineHeight: 16, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } + + Widget buildCommandItem( + MoreOptionCommand command, { + Widget? rightIcon, + VoidCallback? onTap, + }) { + final isFontCommand = command == MoreOptionCommand.font; + return SizedBox( + height: 36, + child: FlowyButton( + key: isFontCommand ? kFontFamilyToolbarItemKey : null, + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + rightIcon: rightIcon, + iconPadding: 12, + text: FlowyText( + command.title, + figmaLineHeight: 20, + fontWeight: FontWeight.w400, + ), + onTap: onTap ?? + () { + command.onExecute(editorState, context); + hideOtherPopovers(command); + if (command != MoreOptionCommand.font) { + popoverController.close(); + } + }, + ), + ); + } + + Widget buildFontSelector() { + final selection = editorState.selection!; + final String? currentFontFamily = editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); + return FontFamilyDropDown( + currentFontFamily: currentFontFamily ?? '', + offset: const Offset(-240, 0), + popoverController: fontPopoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + onFontFamilyChanged: (fontFamily) async { + fontPopoverController.close(); + popoverController.close(); + try { + await editorState.formatDelta(selection, { + AppFlowyRichTextKeys.fontFamily: fontFamily, + }); + } catch (e) { + Log.error('Failed to set font family: $e'); + } + }, + onResetFont: () async { + fontPopoverController.close(); + popoverController.close(); + await editorState + .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); + }, + child: buildCommandItem( + MoreOptionCommand.font, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + ), + ); + } + + Widget buildTurnIntoSelector() { + final selectionRects = editorState.selectionRects(); + double height = -6; + if (selectionRects.isNotEmpty) height = selectionRects.first.height; + return SuggestionsActionList( + editorState: editorState, + popoverController: suggestionsPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, height), + onSelect: () => getIt().hideToolbar(), + child: buildCommandItem( + MoreOptionCommand.suggestions, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.suggestions) return; + hideOtherPopovers(MoreOptionCommand.suggestions); + keepEditorFocusNotifier.increase(); + suggestionsPopoverController.show(); + }, + ), + ); + } + + Widget buildTextAlignSelector() { + return TextAlignActionList( + editorState: editorState, + popoverController: textAlignPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, 0), + onSelect: () => getIt().hideToolbar(), + highlightColor: highlightColor, + child: buildCommandItem( + MoreOptionCommand.textAlign, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.textAlign) return; + hideOtherPopovers(MoreOptionCommand.textAlign); + keepEditorFocusNotifier.increase(); + textAlignPopoverController.show(); + }, + ), + ); + } + + void hideOtherPopovers(MoreOptionCommand currentCommand) { + if (tappedCommand == currentCommand) return; + if (tappedCommand == MoreOptionCommand.font) { + fontPopoverController.close(); + fontPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.suggestions) { + suggestionsPopoverController.close(); + suggestionsPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.textAlign) { + textAlignPopoverController.close(); + textAlignPopoverController = PopoverController(); + } + tappedCommand = currentCommand; + } +} + +enum MoreOptionCommand { + suggestions(FlowySvgs.turninto_s), + link(FlowySvgs.toolbar_link_m), + textAlign( + FlowySvgs.toolbar_alignment_m, + ), + font(FlowySvgs.type_font_m), + strikethrough(FlowySvgs.type_strikethrough_m), + formula(FlowySvgs.type_formula_m); + + const MoreOptionCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case suggestions: + return LocaleKeys.document_toolbar_turnInto.tr(); + case link: + return LocaleKeys.document_toolbar_link.tr(); + case textAlign: + return LocaleKeys.button_align.tr(); + case font: + return LocaleKeys.document_toolbar_font.tr(); + case strikethrough: + return LocaleKeys.editor_strikethrough.tr(); + case formula: + return LocaleKeys.document_toolbar_equation.tr(); + } + } + + Future onExecute(EditorState editorState, BuildContext context) async { + final selection = editorState.selection!; + if (this == link) { + final nodes = editorState.getNodesInSelection(selection); + final isHref = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[AppFlowyRichTextKeys.href] != null, + ); + }); + getIt().hideToolbar(); + if (isHref) { + getIt().call( + HoverTriggerKey(nodes.first.id, selection), + ); + } else { + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); + } + } else if (this == strikethrough) { + await editorState.toggleAttribute(name); + } else if (this == formula) { + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = editorState.transaction; + final isHighlight = isFormulaHighlight(editorState); + if (isHighlight) { + final formula = delta + .slice(selection.startIndex, selection.endIndex) + .whereType() + .firstOrNull + ?.attributes?[InlineMathEquationKeys.formula]; + assert(formula != null); + if (formula == null) { + return; + } + // clear the format + transaction.replaceText( + node, + selection.startIndex, + selection.length, + formula, + attributes: {}, + ); + } else { + final text = editorState.getTextInSelection(selection).join(); + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + InlineMathEquationKeys.formula: text, + }, + ); + } + await editorState.apply(transaction); + } + } +} + +bool isFormulaHighlight(EditorState editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return false; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return false; + } + + final nodes = editorState.getNodesInSelection(selection); + return nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[InlineMathEquationKeys.formula] != null, + ); + }); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart new file mode 100644 index 0000000000..5778b6b8a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -0,0 +1,247 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customTextHeadingItem = ToolbarItem( + id: ToolbarId.textHeading.id, + group: 1, + isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return TextHeadingActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ); + }, +); + +class TextHeadingActionList extends StatefulWidget { + const TextHeadingActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + State createState() => _TextHeadingActionListState(); +} + +class _TextHeadingActionListState extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_format_m, + size: Size.square(20), + color: iconColor, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textHeading.id, + LocaleKeys.document_toolbar_textSize.tr(), + child, + ) ?? + child; + } + + Widget buildPopoverContent() { + final selectingCommand = getSelectingCommand(); + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: List.generate(TextHeadingCommand.values.length, (index) { + final command = TextHeadingCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + iconPadding: 12, + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: selectingCommand == command + ? FlowySvg(FlowySvgs.toolbar_check_m) + : null, + onTap: () { + if (command == selectingCommand) return; + command.onExecute(widget.editorState); + popoverController.close(); + }, + ), + ); + }), + ), + ); + } + + TextHeadingCommand? getSelectingCommand() { + final editorState = widget.editorState; + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return null; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) { + return null; + } + final nodeType = node.type; + if (nodeType == ParagraphBlockKeys.type) return TextHeadingCommand.text; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) return TextHeadingCommand.h1; + if (level == 2) return TextHeadingCommand.h2; + if (level == 3) return TextHeadingCommand.h3; + } + return null; + } +} + +enum TextHeadingCommand { + text(FlowySvgs.type_text_m), + h1(FlowySvgs.type_h1_m), + h2(FlowySvgs.type_h2_m), + h3(FlowySvgs.type_h3_m); + + const TextHeadingCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case text: + return AppFlowyEditorL10n.current.text; + case h1: + return LocaleKeys.document_toolbar_h1.tr(); + case h2: + return LocaleKeys.document_toolbar_h2.tr(); + case h3: + return LocaleKeys.document_toolbar_h3.tr(); + } + } + + void onExecute(EditorState state) { + switch (this) { + case text: + formatNodeToText(state); + break; + case h1: + _turnInto(state, 1); + break; + case h2: + _turnInto(state, 2); + break; + case h3: + _turnInto(state, 3); + break; + } + } + + Future _turnInto(EditorState state, int level) async { + final selection = state.selection!; + final node = state.getNodeAtPath(selection.start.path)!; + await BlockActionOptionCubit.turnIntoBlock( + HeadingBlockKeys.type, + node, + state, + level: level, + keepSelection: true, + ); + } +} + +void formatNodeToText(EditorState editorState) { + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!; + final delta = (node.delta ?? Delta()).toJson(); + editorState.formatNode( + selection, + (node) => node.copyWith( + type: ParagraphBlockKeys.type, + attributes: { + blockComponentDelta: delta, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + }, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart new file mode 100644 index 0000000000..48f5d3f403 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -0,0 +1,536 @@ +import 'dart:collection'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +import 'text_heading_toolbar_item.dart'; +import 'toolbar_id_enum.dart'; + +@visibleForTesting +const kSuggestionsItemKey = ValueKey('SuggestionsItem'); + +@visibleForTesting +const kSuggestionsItemListKey = ValueKey('SuggestionsItemList'); + +final ToolbarItem suggestionsItem = ToolbarItem( + id: ToolbarId.suggestions.id, + group: 3, + isActive: enableSuggestions, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return SuggestionsActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ); + }, +); + +class SuggestionsActionList extends StatefulWidget { + const SuggestionsActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; + + @override + State createState() => _SuggestionsActionListState(); +} + +class _SuggestionsActionListState extends State { + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); + + bool isSelected = false; + + final List suggestionItems = suggestions.sublist(0, 4); + final List turnIntoItems = + suggestions.sublist(4, suggestions.length); + + EditorState get editorState => widget.editorState; + + SuggestionItem currentSuggestionItem = textSuggestionItem; + + @override + void initState() { + super.initState(); + refreshSuggestions(); + editorState.selectionNotifier.addListener(refreshSuggestions); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(refreshSuggestions); + popoverController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: widget.popoverDirection, + offset: widget.showOffset, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + constraints: const BoxConstraints(maxWidth: 240, maxHeight: 400), + popupBuilder: (context) => buildPopoverContent(context), + child: widget.child ?? buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyHover( + isSelected: () => isSelected, + style: HoverStyle( + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + foregroundColorOnHover: Theme.of(context).iconTheme.color, + ), + resetHoverOnRebuild: false, + child: FlowyTooltip( + preferBelow: true, + child: RawMaterialButton( + key: kSuggestionsItemKey, + constraints: BoxConstraints(maxHeight: 32, minWidth: 60), + clipBehavior: Clip.antiAlias, + hoverElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder(borderRadius: Corners.s6Border), + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + currentSuggestionItem.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + ), + ), + ), + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.suggestions.id, + currentSuggestionItem.title, + child, + ) ?? + child; + } + + Widget buildPopoverContent(BuildContext context) { + final textColor = Color(0xff99A1A8); + return MouseRegion( + child: SingleChildScrollView( + key: kSuggestionsItemListKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildSubTitle( + LocaleKeys.document_toolbar_suggestions.tr(), + textColor, + ), + ...List.generate(suggestionItems.length, (index) { + return buildItem(suggestionItems[index]); + }), + buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), + ...List.generate(turnIntoItems.length, (index) { + return buildItem(turnIntoItems[index]); + }), + ], + ), + ), + ); + } + + Widget buildItem(SuggestionItem item) { + final isSelected = item.type == currentSuggestionItem.type; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(item.svg), + iconPadding: 12, + text: FlowyText( + item.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () { + item.onTap(widget.editorState, true); + widget.onSelect?.call(); + popoverController.close(); + }, + ), + ); + } + + Widget buildSubTitle(String text, Color color) { + return Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + text, + color: color, + figmaLineHeight: 16, + ), + ), + ); + } + + void refreshSuggestions() { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) { + return; + } + final nodeType = node.type; + SuggestionType? suggestionType; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) { + suggestionType = SuggestionType.h1; + } else if (level == 2) { + suggestionType = SuggestionType.h2; + } else if (level == 3) { + suggestionType = SuggestionType.h3; + } + } else if (nodeType == ToggleListBlockKeys.type) { + final level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + suggestionType = SuggestionType.toggle; + } else if (level == 1) { + suggestionType = SuggestionType.toggleH1; + } else if (level == 2) { + suggestionType = SuggestionType.toggleH2; + } else if (level == 3) { + suggestionType = SuggestionType.toggleH3; + } + } else { + suggestionType = nodeType2SuggestionType[nodeType]; + } + if (suggestionType == null) return; + suggestionItems.clear(); + turnIntoItems.clear(); + for (final item in suggestions) { + if (item.type.group == suggestionType.group && + item.type != suggestionType) { + suggestionItems.add(item); + } else { + turnIntoItems.add(item); + } + } + currentSuggestionItem = + suggestions.where((item) => item.type == suggestionType).first; + if (mounted) setState(() {}); + } +} + +class SuggestionItem { + SuggestionItem({ + required this.type, + required this.title, + required this.svg, + required this.onTap, + }); + + final SuggestionType type; + final String title; + final FlowySvgData svg; + final Function(EditorState state, bool keepSelection) onTap; +} + +enum SuggestionGroup { textHeading, list, toggle, quote, page } + +enum SuggestionType { + text(SuggestionGroup.textHeading), + h1(SuggestionGroup.textHeading), + h2(SuggestionGroup.textHeading), + h3(SuggestionGroup.textHeading), + checkbox(SuggestionGroup.list), + bulleted(SuggestionGroup.list), + numbered(SuggestionGroup.list), + toggle(SuggestionGroup.toggle), + toggleH1(SuggestionGroup.toggle), + toggleH2(SuggestionGroup.toggle), + toggleH3(SuggestionGroup.toggle), + callOut(SuggestionGroup.quote), + quote(SuggestionGroup.quote), + page(SuggestionGroup.page); + + const SuggestionType(this.group); + + final SuggestionGroup group; +} + +final textSuggestionItem = SuggestionItem( + type: SuggestionType.text, + title: AppFlowyEditorL10n.current.text, + svg: FlowySvgs.type_text_m, + onTap: (state, _) => formatNodeToText(state), +); + +final h1SuggestionItem = SuggestionItem( + type: SuggestionType.h1, + title: LocaleKeys.document_toolbar_h1.tr(), + svg: FlowySvgs.type_h1_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), +); + +final h2SuggestionItem = SuggestionItem( + type: SuggestionType.h2, + title: LocaleKeys.document_toolbar_h2.tr(), + svg: FlowySvgs.type_h2_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), +); + +final h3SuggestionItem = SuggestionItem( + type: SuggestionType.h3, + title: LocaleKeys.document_toolbar_h3.tr(), + svg: FlowySvgs.type_h3_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), +); + +final checkboxSuggestionItem = SuggestionItem( + type: SuggestionType.checkbox, + title: LocaleKeys.editor_checkbox.tr(), + svg: FlowySvgs.type_todo_m, + onTap: (state, keepSelection) => _turnInto( + state, + TodoListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final bulletedSuggestionItem = SuggestionItem( + type: SuggestionType.bulleted, + title: LocaleKeys.editor_bulletedListShortForm.tr(), + svg: FlowySvgs.type_bulleted_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + BulletedListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final numberedSuggestionItem = SuggestionItem( + type: SuggestionType.numbered, + title: LocaleKeys.editor_numberedListShortForm.tr(), + svg: FlowySvgs.type_numbered_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + NumberedListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final toggleSuggestionItem = SuggestionItem( + type: SuggestionType.toggle, + title: LocaleKeys.editor_toggleListShortForm.tr(), + svg: FlowySvgs.type_toggle_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final toggleH1SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH1, + title: LocaleKeys.editor_toggleHeading1ShortForm.tr(), + svg: FlowySvgs.type_toggle_h1_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), +); + +final toggleH2SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH2, + title: LocaleKeys.editor_toggleHeading2ShortForm.tr(), + svg: FlowySvgs.type_toggle_h2_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), +); + +final toggleH3SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH3, + title: LocaleKeys.editor_toggleHeading3ShortForm.tr(), + svg: FlowySvgs.type_toggle_h3_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), +); + +final callOutSuggestionItem = SuggestionItem( + type: SuggestionType.callOut, + title: LocaleKeys.document_plugins_callout.tr(), + svg: FlowySvgs.type_callout_m, + onTap: (state, keepSelection) => _turnInto( + state, + CalloutBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final quoteSuggestionItem = SuggestionItem( + type: SuggestionType.quote, + title: LocaleKeys.editor_quote.tr(), + svg: FlowySvgs.type_quote_m, + onTap: (state, keepSelection) => _turnInto( + state, + QuoteBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final pateItem = SuggestionItem( + type: SuggestionType.page, + title: LocaleKeys.editor_page.tr(), + svg: FlowySvgs.icon_document_s, + onTap: (state, keepSelection) => _turnInto( + state, + SubPageBlockKeys.type, + viewId: getIt().latestOpenView?.id, + keepSelection: keepSelection, + ), +); + +Future _turnInto( + EditorState state, + String type, { + int? level, + String? viewId, + bool keepSelection = true, +}) async { + final selection = state.selection!; + final node = state.getNodeAtPath(selection.start.path)!; + await BlockActionOptionCubit.turnIntoBlock( + type, + node, + state, + level: level, + currentViewId: viewId, + keepSelection: keepSelection, + ); +} + +final suggestions = UnmodifiableListView([ + textSuggestionItem, + h1SuggestionItem, + h2SuggestionItem, + h3SuggestionItem, + checkboxSuggestionItem, + bulletedSuggestionItem, + numberedSuggestionItem, + toggleSuggestionItem, + toggleH1SuggestionItem, + toggleH2SuggestionItem, + toggleH3SuggestionItem, + callOutSuggestionItem, + quoteSuggestionItem, + pateItem, +]); + +final nodeType2SuggestionType = UnmodifiableMapView({ + ParagraphBlockKeys.type: SuggestionType.text, + NumberedListBlockKeys.type: SuggestionType.numbered, + BulletedListBlockKeys.type: SuggestionType.bulleted, + QuoteBlockKeys.type: SuggestionType.quote, + TodoListBlockKeys.type: SuggestionType.checkbox, + CalloutBlockKeys.type: SuggestionType.callOut, +}); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart new file mode 100644 index 0000000000..8a97bb6648 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart @@ -0,0 +1,19 @@ +enum ToolbarId { + bold, + underline, + italic, + code, + highlightColor, + textColor, + link, + placeholder, + paddingPlaceHolder, + textAlign, + moreOption, + textHeading, + suggestions, +} + +extension ToolbarIdExtension on ToolbarId { + String get id => 'editor.$name'; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart index 9ea6477969..36ea3d2704 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart @@ -1,6 +1,8 @@ import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// Undo /// @@ -14,10 +16,15 @@ final CommandShortcutEvent customUndoCommand = CommandShortcutEvent( command: 'ctrl+z', macOSCommand: 'cmd+z', handler: (editorState) { - // if the selection is null, it means the keyboard service is disabled - if (editorState.selection == null) { + final context = editorState.document.root.context; + if (context == null) { return KeyEventResult.ignored; } + final editorContext = context.read(); + if (editorContext.coverTitleFocusNode.hasFocus) { + return KeyEventResult.ignored; + } + EditorNotification.undo().post(); return KeyEventResult.handled; }, @@ -35,9 +42,15 @@ final CommandShortcutEvent customRedoCommand = CommandShortcutEvent( command: 'ctrl+y,ctrl+shift+z', macOSCommand: 'cmd+shift+z', handler: (editorState) { - if (editorState.selection == null) { + final context = editorState.document.root.context; + if (context == null) { return KeyEventResult.ignored; } + final editorContext = context.read(); + if (editorContext.coverTitleFocusNode.hasFocus) { + return KeyEventResult.ignored; + } + EditorNotification.redo().post(); return KeyEventResult.handled; }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart new file mode 100644 index 0000000000..f41d4526ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart @@ -0,0 +1,6 @@ +class VideoBlockKeys { + const VideoBlockKeys._(); + + static const String type = 'video'; + static const String url = 'url'; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 31377a93b7..3664c9aee7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; @@ -27,16 +28,21 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; + class EditorStyleCustomizer { EditorStyleCustomizer({ required this.context, required this.padding, this.width, + this.editorState, }); final BuildContext context; final EdgeInsets padding; final double? width; + final EditorState? editorState; static const double maxDocumentWidth = 480 * 4; static const double minDocumentWidth = 480; @@ -56,6 +62,12 @@ class EditorStyleCustomizer { static double get optionMenuWidth => UniversalPlatform.isMobile ? 0 : 44; + static Color? toolbarHoverColor(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.secondary + : AFThemeExtension.of(context).toolbarHoverColor; + } + EditorStyle style() { if (UniversalPlatform.isDesktopOrWeb) { return desktop(); @@ -76,11 +88,15 @@ class EditorStyleCustomizer { fontFamily = appearanceFont; } + final cursorColor = (editorState?.editable ?? true) + ? (appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context)) + : Colors.transparent; + return EditorStyle.desktop( padding: padding, maxWidth: width, - cursorColor: appearance.cursorColor ?? - DefaultAppearanceSettings.getDefaultCursorColor(context), + cursorColor: cursorColor, selectionColor: appearance.selectionColor ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), defaultTextDirection: appearance.defaultTextDirection, @@ -111,13 +127,15 @@ class EditorStyleCustomizer { fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, - backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + backgroundColor: + theme.colorScheme.inverseSurface.withValues(alpha: 0.8), ), ), ), textSpanDecorator: customizeAttributeDecorator, textScaleFactor: context.watch().state.textScaleFactor, + textSpanOverlayBuilder: _buildTextSpanOverlay, ); } @@ -159,7 +177,7 @@ class EditorStyleCustomizer { fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, - backgroundColor: Colors.grey.withOpacity(0.3), + backgroundColor: Colors.grey.withValues(alpha: 0.3), ), ), applyHeightToFirstAscent: true, @@ -241,7 +259,7 @@ class EditorStyleCustomizer { fontFamily: defaultFontFamily, fontSize: fontSize, height: 1.5, - color: AFThemeExtension.of(context).onBackground.withOpacity(0.6), + color: AFThemeExtension.of(context).onBackground.withValues(alpha: 0.6), ); } @@ -273,6 +291,15 @@ class EditorStyleCustomizer { selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface, selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface, selectionMenuItemSelectedColor: afThemeExtension.greyHover, + selectionMenuUnselectedLabelColor: afThemeExtension.onBackground, + selectionMenuDividerColor: afThemeExtension.greyHover, + selectionMenuLinkBorderColor: afThemeExtension.greyHover, + selectionMenuInvalidLinkColor: afThemeExtension.onBackground, + selectionMenuButtonColor: afThemeExtension.greyHover, + selectionMenuButtonTextColor: afThemeExtension.onBackground, + selectionMenuButtonIconColor: afThemeExtension.onBackground, + selectionMenuButtonBorderColor: afThemeExtension.greyHover, + selectionMenuTabIndicatorColor: afThemeExtension.greyHover, ); } @@ -281,17 +308,13 @@ class EditorStyleCustomizer { final afThemeExtension = AFThemeExtension.of(context); return InlineActionsMenuStyle( backgroundColor: theme.cardColor, - groupTextColor: afThemeExtension.onBackground.withOpacity(.8), + groupTextColor: afThemeExtension.onBackground.withValues(alpha: .8), menuItemTextColor: afThemeExtension.onBackground, menuItemSelectedColor: theme.colorScheme.secondary, menuItemSelectedTextColor: theme.colorScheme.onSurface, ); } - FloatingToolbarStyle floatingToolbarStyleBuilder() => FloatingToolbarStyle( - backgroundColor: Theme.of(context).colorScheme.onTertiary, - ); - TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) { if (fontFamily == null || fontFamily == defaultFontFamily) { return TextStyle(fontWeight: fontWeight); @@ -320,6 +343,11 @@ class EditorStyleCustomizer { return before; } + final suggestion = attributes[AiWriterBlockKeys.suggestion] as String?; + final newStyle = suggestion == null + ? after.style + : _styleSuggestion(after.style, suggestion); + if (attributes.backgroundColor != null) { final color = EditorFontColors.fromBuiltInColors( context, @@ -328,7 +356,7 @@ class EditorStyleCustomizer { if (color != null) { return TextSpan( text: before.text, - style: after.style?.merge( + style: newStyle?.merge( TextStyle(backgroundColor: color), ), ); @@ -343,7 +371,7 @@ class EditorStyleCustomizer { } else { return TextSpan( text: before.text, - style: after.style?.merge( + style: newStyle?.merge( getGoogleFontSafely(attributes.fontFamily!), ), ); @@ -360,7 +388,7 @@ class EditorStyleCustomizer { final type = mention[MentionBlockKeys.type]; return WidgetSpan( alignment: PlaceholderAlignment.middle, - style: after.style, + style: newStyle, child: MentionBlock( key: ValueKey( switch (type) { @@ -372,7 +400,7 @@ class EditorStyleCustomizer { node: node, index: index, mention: mention, - textStyle: after.style, + textStyle: newStyle, ), ); } @@ -435,14 +463,22 @@ class EditorStyleCustomizer { ); } - return defaultTextSpanDecoratorForAttribute( - context, - node, - index, - text, - before, - after, - ); + if (suggestion != null) { + return TextSpan( + text: before.text, + style: newStyle, + ); + } + + if (href != null) { + return TextSpan( + style: before.style, + text: text.text, + mouseCursor: SystemMouseCursors.click, + ); + } else { + return before; + } } Widget buildToolbarItemTooltip( @@ -455,7 +491,7 @@ class EditorStyleCustomizer { child = FlowyTooltip( richMessage: tooltipMessage, preferBelow: false, - verticalOffset: 20, + verticalOffset: 24, child: child, ); @@ -467,10 +503,10 @@ class EditorStyleCustomizer { if (!toolbarItemsWithoutHover.contains(id)) { child = Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric(vertical: 6), child: FlowyHover( style: HoverStyle( - hoverColor: Colors.grey.withOpacity(0.3), + hoverColor: Colors.grey.withValues(alpha: 0.3), ), child: child, ), @@ -487,6 +523,10 @@ class EditorStyleCustomizer { 'italic': (LocaleKeys.toolbar_italic.tr(), 'I'), 'strikethrough': (LocaleKeys.toolbar_strike.tr(), 'Shift+S'), 'code': (LocaleKeys.toolbar_inlineCode.tr(), 'E'), + 'editor.inline_math_equation': ( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + 'Shift+E' + ), }; final markdownItemIds = markdownItemTooltips.keys.toSet(); @@ -513,14 +553,85 @@ class EditorStyleCustomizer { style: context.tooltipTextStyle(), ), TextSpan( - text: (Platform.isMacOS ? '⌘+' : 'Ctrl+\\') + tooltip.$2, - style: context - .tooltipTextStyle() - ?.copyWith(color: Theme.of(context).hintColor), + text: (Platform.isMacOS ? '⌘+' : 'Ctrl+') + tooltip.$2, + style: context.tooltipTextStyle()?.copyWith( + color: Theme.of(context).hintColor, + ), ), ], ); return textSpan; } + + TextStyle? _styleSuggestion(TextStyle? style, String suggestion) { + if (style == null) { + return null; + } + final isLight = Theme.of(context).isLightMode; + final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4); + final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4); + return switch (suggestion) { + AiWriterBlockKeys.suggestionOriginal => style.copyWith( + color: Theme.of(context).disabledColor, + decoration: TextDecoration.lineThrough, + ), + AiWriterBlockKeys.suggestionReplacement => style.copyWith( + color: textColor, + decoration: TextDecoration.underline, + decorationColor: underlineColor, + decorationThickness: 1.0, + ), + _ => style, + }; + } + + List _buildTextSpanOverlay( + BuildContext context, + Node node, + SelectableMixin delegate, + ) { + if (UniversalPlatform.isMobile) return []; + final delta = node.delta; + if (delta == null) return []; + final widgets = []; + final textInserts = delta.whereType(); + int index = 0; + final editorState = context.read(); + for (final textInsert in textInserts) { + if (textInsert.attributes?.href != null) { + final nodeSelection = Selection( + start: Position(path: node.path, offset: index), + end: Position( + path: node.path, + offset: index + textInsert.length, + ), + ); + final rectList = delegate.getRectsInSelection(nodeSelection); + if (rectList.isNotEmpty) { + for (final rect in rectList) { + widgets.add( + Positioned( + left: rect.left, + top: rect.top, + child: SizedBox( + width: rect.width, + height: rect.height, + child: LinkHoverTrigger( + editorState: editorState, + selection: nodeSelection, + attribute: textInsert.attributes!, + node: node, + size: rect.size, + ), + ), + ), + ); + } + } + } + index += textInsert.length; + } + return widgets; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart new file mode 100644 index 0000000000..9d386b36be --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart @@ -0,0 +1,69 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'emoji_menu.dart'; + +const _emojiCharacter = ':'; +final _letterRegExp = RegExp(r'^[a-zA-Z]$'); + +CharacterShortcutEvent emojiCommand(BuildContext context) => + CharacterShortcutEvent( + key: 'Opens Emoji Menu', + character: '', + regExp: _letterRegExp, + handler: (editorState) async { + return false; + }, + handlerWithCharacter: (editorState, character) { + emojiMenuService = EmojiMenu( + context: context, + editorState: editorState, + ); + return emojiCommandHandler(editorState, context, character); + }, + ); + +EmojiMenuService? emojiMenuService; + +Future emojiCommandHandler( + EditorState editorState, + BuildContext context, + String character, +) async { + final selection = editorState.selection; + + if (UniversalPlatform.isMobile || selection == null) { + return false; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || + delta == null || + delta.isEmpty || + node.type == CodeBlockKeys.type) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != _emojiCharacter) return false; + if (!context.mounted) return false; + + if (!selection.isCollapsed) return false; + + await editorState.insertTextAtPosition( + character, + position: selection.start, + ); + + emojiMenuService?.show(character); + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart new file mode 100644 index 0000000000..3ab578b961 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -0,0 +1,407 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/size.dart'; + +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +import 'emoji_menu.dart'; + +class EmojiHandler extends StatefulWidget { + const EmojiHandler({ + super.key, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.onSelectionUpdate, + required this.onEmojiSelect, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + this.initialSearchText = '', + }); + + final EditorState editorState; + final EmojiMenuService menuService; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final SelectEmojiItemHandler onEmojiSelect; + final int startCharAmount; + final String initialSearchText; + final bool Function()? cancelBySpaceHandler; + + @override + State createState() => _EmojiHandlerState(); +} + +class _EmojiHandlerState extends State { + final focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); + final scrollController = ScrollController(); + late EmojiData emojiData; + final List searchedEmojis = []; + bool loaded = false; + int invalidCounter = 0; + late int startOffset; + late String _search = widget.initialSearchText; + double emojiHeight = 36.0; + final configuration = EmojiPickerConfiguration( + defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, + ); + + set search(String search) { + _search = search; + _doSearch(); + } + + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + + startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1; + + if (kCachedEmojiData != null) { + loadEmojis(kCachedEmojiData!); + } else { + EmojiData.builtIn().then( + (value) { + kCachedEmojiData = value; + loadEmojis(value); + }, + ); + } + } + + @override + void dispose() { + focusNode.dispose(); + selectedIndexNotifier.dispose(); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final noEmojis = searchedEmojis.isEmpty; + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withAlpha(25), + ), + ], + ), + child: noEmojis ? buildLoading() : buildEmojis(), + ), + ); + } + + Widget buildLoading() { + return SizedBox( + width: 400, + height: 40, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ), + ), + ); + } + + Widget buildEmojis() { + return SizedBox( + height: + (searchedEmojis.length / configuration.perLine).ceil() * emojiHeight, + child: GridView.builder( + controller: scrollController, + itemCount: searchedEmojis.length, + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: configuration.perLine, + ), + itemBuilder: (context, index) { + final currentEmoji = searchedEmojis[index]; + final emojiId = currentEmoji.id; + final emoji = emojiData.getEmojiById( + emojiId, + skinTone: configuration.defaultSkinTone, + ); + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = value == index; + return SizedBox.square( + dimension: emojiHeight, + child: FlowyButton( + isSelected: isSelected, + margin: EdgeInsets.zero, + radius: Corners.s8Border, + text: ManualTooltip( + key: ValueKey('$emojiId-$isSelected'), + message: currentEmoji.name, + showAutomaticlly: isSelected, + preferBelow: false, + child: FlowyText.emoji( + emoji, + fontSize: configuration.emojiSize, + ), + ), + onTap: () => onSelect(index), + ), + ); + }, + ); + }, + ), + ); + } + + void changeSelectedIndex(int index) => selectedIndexNotifier.value = index; + + void loadEmojis(EmojiData data) { + emojiData = data; + searchedEmojis.clear(); + searchedEmojis.addAll(emojiData.emojis.values); + if (mounted) { + setState(() { + loaded = true; + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + _doSearch(); + }); + } + + void _doSearch() { + if (!loaded || !mounted) return; + if (_search.startsWith(' ') || _search.isEmpty) { + widget.onDismiss.call(); + return; + } + final searchEmojiData = emojiData.filterByKeyword(_search); + setState(() { + searchedEmojis.clear(); + searchedEmojis.addAll(searchEmojiData.emojis.values); + changeSelectedIndex(0); + _scrollToItem(); + }); + if (searchedEmojis.isEmpty) { + widget.onDismiss.call(); + } + } + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + const moveKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(selectedIndexNotifier.value); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + // Workaround to bring focus back to editor + widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + widget.onDismiss.call(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_search.isEmpty) { + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + widget.onDismiss.call(); + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + } else if (event.character != null && + !moveKeys.contains(event.logicalKey)) { + /// Prevents dismissal of context menu by notifying the parent + /// that the selection change occurred from the handler. + widget.onSelectionUpdate(); + + if (event.logicalKey == LogicalKeyboardKey.space) { + final cancelBySpaceHandler = widget.cancelBySpaceHandler; + if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { + return KeyEventResult.handled; + } + } + + // Interpolation to avoid having a getter for private variable + _insertCharacter(event.character!); + return KeyEventResult.handled; + } else if (moveKeys.contains(event.logicalKey)) { + _moveSelection(event.logicalKey); + return KeyEventResult.handled; + } + + return KeyEventResult.handled; + } + + void onSelect(int index) { + widget.onEmojiSelect.call( + context, + (startOffset - widget.startCharAmount, startOffset + _search.length), + emojiData.getEmojiById(searchedEmojis[index].id), + ); + widget.onDismiss.call(); + } + + void _insertCharacter(String character) { + widget.editorState.insertTextAtCurrentSelection(character); + + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; + if (delta == null) { + return; + } + + search = widget.editorState + .getTextInSelection( + selection.copyWith( + start: selection.start.copyWith(offset: startOffset), + end: selection.start + .copyWith(offset: startOffset + _search.length + 1), + ), + ) + .join(); + } + + void _moveSelection(LogicalKeyboardKey key) { + final index = selectedIndexNotifier.value, + perLine = configuration.perLine, + remainder = index % perLine, + length = searchedEmojis.length, + currentLine = index ~/ perLine, + maxLine = (length / perLine).ceil(); + + final heightBefore = currentLine * emojiHeight; + if (key == LogicalKeyboardKey.arrowUp) { + if (currentLine == 0) { + final exceptLine = max(0, maxLine - 1); + changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1)); + } else if (currentLine > 0) { + changeSelectedIndex(index - perLine); + } + } else if (key == LogicalKeyboardKey.arrowDown) { + if (currentLine == maxLine - 1) { + changeSelectedIndex(remainder); + } else if (currentLine < maxLine - 1) { + changeSelectedIndex(min(index + perLine, length - 1)); + } + } else if (key == LogicalKeyboardKey.arrowLeft) { + if (index == 0) { + changeSelectedIndex(length - 1); + } else if (index > 0) { + changeSelectedIndex(index - 1); + } + } else if (key == LogicalKeyboardKey.arrowRight) { + if (index == length - 1) { + changeSelectedIndex(0); + } else if (index < length - 1) { + changeSelectedIndex(index + 1); + } + } + final heightAfter = + (selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight; + + if (mounted && (heightAfter != heightBefore)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(); + }); + } + } + + void _scrollToItem() { + final noEmojis = searchedEmojis.isEmpty; + if (noEmojis || !mounted) return; + final currentItem = selectedIndexNotifier.value; + final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight; + final maxExtent = scrollController.position.maxScrollExtent; + final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight) + ? exceptHeight + : min(exceptHeight, maxExtent); + scrollController.animateTo( + jumpTo, + duration: Duration(milliseconds: 300), + curve: Curves.linear, + ); + } + + void _deleteCharacterAtSelection() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = widget.editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + search = delta.toPlainText().substring( + startOffset, + startOffset + _search.length - 1, + ); + } + + bool _canDeleteLastCharacter() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; + if (delta == null) { + return false; + } + + return delta.isNotEmpty; + } +} + +typedef SelectEmojiItemHandler = void Function( + BuildContext context, + (int start, int end) replacement, + String emoji, +); diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart new file mode 100644 index 0000000000..4aff4cf6cb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart @@ -0,0 +1,233 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'emoji_actions_command.dart'; +import 'emoji_handler.dart'; + +abstract class EmojiMenuService { + void show(String character); + + void dismiss(); +} + +class EmojiMenu extends EmojiMenuService { + EmojiMenu({ + required this.context, + required this.editorState, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + this.menuHeight = 400, + this.menuWidth = 300, + }); + + final BuildContext context; + final EditorState editorState; + final double menuHeight; + final double menuWidth; + final bool Function()? cancelBySpaceHandler; + + final int startCharAmount; + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + String initialCharacter = ''; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + } + + _menuEntry?.remove(); + _menuEntry = null; + + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + emojiMenuService = null; + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + @override + void show(String character) { + initialCharacter = character; + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + final Size editorSize = editorState.renderBox!.size; + + calculateSelectionMenuOffset(selectionRects.first); + + final (left, top, right, bottom) = _getPosition(); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + + // GestureDetector handles clicks outside of the context menu, + // to dismiss the context menu. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: EmojiHandler( + editorState: editorState, + menuService: this, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + initialSearchText: initialCharacter, + onEmojiSelect: ( + BuildContext context, + (int, int) replacement, + String emoji, + ) async { + final selection = editorState.selection; + + if (selection == null) return; + final node = + editorState.document.nodeAtPath(selection.end.path); + if (node == null) return; + final transaction = editorState.transaction + ..deleteText( + node, + replacement.$1, + replacement.$2 - replacement.$1, + ) + ..insertText( + node, + replacement.$1, + emoji, + ); + await editorState.apply(transaction); + }, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + selectionService.currentSelection.addListener(_onSelectionChange); + } + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (!selectionChangedByMenu) { + return dismiss(); + } + + selectionChangedByMenu = false; + } + + (double? left, double? top, double? right, double? bottom) _getPosition() { + double? left, top, right, bottom; + switch (_alignment) { + case Alignment.topLeft: + left = _offset.dx; + top = _offset.dy; + break; + case Alignment.bottomLeft: + left = _offset.dx; + bottom = _offset.dy; + break; + case Alignment.topRight: + right = _offset.dx; + top = _offset.dy; + break; + case Alignment.bottomRight: + right = _offset.dx; + bottom = _offset.dy; + break; + } + + return (left, top, right, bottom); + } + + void calculateSelectionMenuOffset(Rect rect) { + // Workaround: We can customize the padding through the [EditorStyle], + // but the coordinates of overlay are not properly converted currently. + // Just subtract the padding here as a result. + const menuOffset = Offset(0, 10); + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + // show below default + _alignment = Alignment.topLeft; + final bottomRight = rect.bottomRight; + final topRight = rect.topRight; + var offset = bottomRight + menuOffset; + _offset = Offset( + offset.dx, + offset.dy, + ); + + // show above + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + _alignment = Alignment.bottomLeft; + + _offset = Offset( + offset.dx, + editorHeight + editorOffset.dy - offset.dy, + ); + } + + // show on right + if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) { + _offset = Offset( + _offset.dx, + _offset.dy, + ); + } else if (offset.dx - editorOffset.dx > menuWidth) { + // show on left + _alignment = _alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + _offset = Offset( + editorWidth - _offset.dx + editorOffset.dx, + _offset.dy, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart index d29a1f86bf..6dbd38affb 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart @@ -24,7 +24,7 @@ class InlineChildPageService extends InlineActionsDelegate { results.add( InlineActionsMenuItem( label: LocaleKeys.inlineActions_createPage.tr(args: [search]), - icon: (_) => const FlowySvg(FlowySvgs.add_s), + iconBuilder: (_) => const FlowySvg(FlowySvgs.add_s), onSelected: (context, editorState, service, replacement) => _onSelected(context, editorState, service, replacement, search), ), @@ -71,12 +71,11 @@ class InlineChildPageService extends InlineActionsDelegate { replacement.$1, replacement.$2, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.childPage.name, - MentionBlockKeys.pageId: view.id, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: view.id, + blockId: null, + ), ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart index c7076bd255..747c8667f8 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -122,12 +122,12 @@ class DateReferenceService extends InlineActionsDelegate { start, end, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + includeTime: false, + reminderId: null, + reminderOption: null, + ), ); await editorState.apply(transaction); 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 ff9e751e3f..9853d6757c 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 @@ -221,12 +221,11 @@ class InlinePageReferenceService extends InlineActionsDelegate { replace.$1, replace.$2, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); await editorState.apply(transaction); @@ -235,12 +234,19 @@ class InlinePageReferenceService extends InlineActionsDelegate { InlineActionsMenuItem _fromView(ViewPB view) => InlineActionsMenuItem( keywords: [view.nameOrDefault.toLowerCase()], label: view.nameOrDefault, - icon: (onSelected) => view.icon.value.isNotEmpty - ? EmojiIconWidget( - emoji: view.icon.toEmojiIconData(), - emojiSize: 14, - ) - : view.defaultIcon(), + iconBuilder: (onSelected) { + final child = view.icon.value.isNotEmpty + ? RawEmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, + ) + : view.defaultIcon(size: const Size(16, 16)); + return SizedBox( + width: 16, + child: child, + ); + }, onSelected: (context, editorState, menu, replace) => insertPage ? _onInsertPageRef(view, context, editorState, replace) : _onInsertLinkRef(view, context, editorState, menu, replace), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart index 1f479fa7c5..471f1c9211 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -148,14 +148,12 @@ class ReminderReferenceService extends InlineActionsDelegate { start, end, MentionBlockKeys.mentionChar, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.reminderId: reminder.id, - MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name, - }, - }, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: reminder.id, + reminderOption: ReminderOption.atTimeOfEvent.name, + includeTime: false, + ), ); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart index 0ef4ac7c09..e0e03e7dec 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; @@ -21,13 +22,14 @@ CharacterShortcutEvent inlineActionsCommand( ); InlineActionsMenuService? selectionMenuService; + Future inlineActionsCommandHandler( EditorState editorState, InlineActionsService service, InlineActionsMenuStyle style, ) async { final selection = editorState.selection; - if (UniversalPlatform.isMobile || selection == null) { + if (selection == null) { return false; } @@ -50,15 +52,31 @@ Future inlineActionsCommandHandler( } if (service.context != null) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); - selectionMenuService?.show(); + // disable the keyboard service + editorState.service.keyboardService?.disable(); + + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } return true; diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart index dadc4ebf6f..651e739abc 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; @@ -7,7 +9,8 @@ import 'package:flutter/material.dart'; abstract class InlineActionsMenuService { InlineActionsMenuStyle get style; - void show(); + Future show(); + void dismiss(); } @@ -59,8 +62,13 @@ class InlineActionsMenu extends InlineActionsMenuService { void _onSelectionUpdate() => selectionChangedByMenu = true; @override - void show() { - WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; } void _show() { diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart index 8da9647084..1fe2703870 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart @@ -12,13 +12,13 @@ typedef SelectItemHandler = void Function( class InlineActionsMenuItem { InlineActionsMenuItem({ required this.label, - this.icon, + this.iconBuilder, this.keywords, this.onSelected, }); final String label; - final Widget Function(bool onSelected)? icon; + final Widget Function(bool onSelected)? iconBuilder; final List? keywords; final SelectItemHandler? onSelected; } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart index be9a6c2f5f..63ccb04839 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -165,7 +165,7 @@ class _InlineActionsHandlerState extends State { BoxShadow( blurRadius: 5, spreadRadius: 1, - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart index 6179d3d18b..123cfc1177 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart @@ -92,6 +92,8 @@ class InlineActionsWidget extends StatefulWidget { class _InlineActionsWidgetState extends State { @override Widget build(BuildContext context) { + final iconBuilder = widget.item.iconBuilder; + final hasIcon = iconBuilder != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( @@ -99,11 +101,20 @@ class _InlineActionsWidgetState extends State { child: FlowyButton( expand: true, isSelected: widget.isSelected, - leftIcon: widget.item.icon?.call(widget.isSelected), - text: FlowyText.regular( - widget.item.label, - figmaLineHeight: 18, - overflow: TextOverflow.ellipsis, + text: Row( + children: [ + if (hasIcon) ...[ + iconBuilder.call(widget.isSelected), + SizedBox(width: 12), + ], + Flexible( + child: FlowyText.regular( + widget.item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), onTap: _onPressed, ), diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart index ff95fe6acc..9d6adee7df 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -70,7 +70,7 @@ class ExportTab extends StatelessWidget { const VSpace(10), _ExportButton( title: LocaleKeys.shareAction_csv.tr(), - svg: FlowySvgs.database_layout_m, + svg: FlowySvgs.database_layout_s, onTap: () => _exportCSV(context), ), if (kDebugMode) ...[ @@ -105,7 +105,7 @@ class ExportTab extends StatelessWidget { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', - fileName: '${viewName.toFileName()}.md', + fileName: '${viewName.toFileName()}.zip', ); if (context.mounted && exportPath != null) { context.read().add( @@ -174,11 +174,10 @@ class ExportTab extends StatelessWidget { ClipboardServiceData(plainText: markdown), ); showToastNotification( - context, - message: LocaleKeys.grid_url_copiedNotification.tr(), + message: LocaleKeys.message_copy_success.tr(), ); }, - (error) => showToastNotification(context, message: error.msg), + (error) => showToastNotification(message: error.msg), ); } } @@ -198,7 +197,7 @@ class _ExportButton extends StatelessWidget { Widget build(BuildContext context) { final color = Theme.of(context).isLightMode ? const Color(0x1E14171B) - : Colors.white.withOpacity(0.1); + : Colors.white.withValues(alpha: 0.1); final radius = BorderRadius.circular(10.0); return FlowyButton( margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart index 960f59b07d..1c957016e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart @@ -5,7 +5,7 @@ class ShareMenuColors { static Color borderColor(BuildContext context) { final borderColor = Theme.of(context).isLightMode ? const Color(0x1E14171B) - : Colors.white.withOpacity(0.1); + : Colors.white.withValues(alpha: 0.1); return borderColor; } } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart index 68d1918c7f..244ded0bf6 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -85,11 +85,9 @@ class PublishTab extends StatelessWidget { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), @@ -97,11 +95,9 @@ class PublishTab extends StatelessWidget { } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, @@ -110,14 +106,12 @@ class PublishTab extends StatelessWidget { } else if (state.updatePathNameResult != null) { state.updatePathNameResult!.fold( (value) => showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ), (error) { Log.error('update path name failed: $error'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: error.code.publishErrorMessage, @@ -182,8 +176,7 @@ class _PublishedWidgetState extends State<_PublishedWidget> { ); showToastNotification( - context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); }, onSubmitted: (pathName) { @@ -217,7 +210,7 @@ class _PublishedWidgetState extends State<_PublishedWidget> { title: LocaleKeys.shareAction_visitSite.tr(), borderRadius: const BorderRadius.all(Radius.circular(10)), fillColor: Theme.of(context).colorScheme.primary, - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), textColor: Theme.of(context).colorScheme.onPrimary, ); } @@ -292,7 +285,6 @@ class _PublishWidgetState extends State<_PublishWidget> { // check if any database is selected if (_selectedViews.isEmpty) { showToastNotification( - context, message: LocaleKeys.publish_noDatabaseSelected.tr(), ); return; @@ -508,7 +500,7 @@ class _PublishDatabaseSelector extends StatefulWidget { class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> { final PropertyValueNotifier> _databaseStatus = PropertyValueNotifier>([]); - late final _borderColor = Theme.of(context).hintColor.withOpacity(0.3); + late final _borderColor = Theme.of(context).hintColor.withValues(alpha: 0.3); @override void initState() { @@ -611,7 +603,6 @@ class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> { // unable to deselect the primary database if (isPrimaryDatabase) { showToastNotification( - context, message: LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(), ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index c42bbda5a0..2356399b4a 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -193,7 +193,7 @@ class ShareBloc extends Bloc { Future _updatePublishStatus(Emitter emit) async { final publishInfo = await ViewBackendService.getPublishInfo(view); final enablePublish = await UserBackendService.getCurrentUserProfile().fold( - (v) => v.authenticator == AuthenticatorPB.AppFlowyCloud, + (v) => v.authType == AuthTypePB.Server, (p) => false, ); @@ -324,19 +324,21 @@ class ShareBloc extends Bloc { (f) => FlowyResult.failure(f), ); } else { - result = await documentExporter.export(type.documentExportType); + result = + await documentExporter.export(type.documentExportType, path: path); } return result.fold( (s) { if (path != null) { switch (type) { - case ShareType.markdown: case ShareType.html: case ShareType.csv: case ShareType.json: case ShareType.rawDatabaseData: File(path).writeAsStringSync(s); return FlowyResult.success(type); + case ShareType.markdown: + return FlowyResult.success(type); default: break; } @@ -387,22 +389,30 @@ enum ShareType { @freezed class ShareEvent with _$ShareEvent { const factory ShareEvent.initial() = _Initial; + const factory ShareEvent.share( ShareType type, String? path, ) = _Share; + const factory ShareEvent.publish( String nameSpace, String pageId, List selectedViewIds, ) = _Publish; + const factory ShareEvent.unPublish() = _UnPublish; + const factory ShareEvent.updateViewName(String name, String viewId) = _UpdateViewName; + const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus; + const factory ShareEvent.setPublishStatus(bool isPublished) = _SetPublishStatus; + const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName; + const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult; } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart index 7f3eff9d34..9020441b4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart @@ -30,8 +30,11 @@ class ShareButton extends StatelessWidget { ), if (view.layout.isDatabaseView) BlocProvider( - create: (context) => DatabaseTabBarBloc(view: view) - ..add(const DatabaseTabBarEvent.initial()), + create: (context) => DatabaseTabBarBloc( + view: view, + compactModeId: view.id, + enableCompactMode: false, + )..add(const DatabaseTabBarEvent.initial()), ), ], child: BlocListener( @@ -67,7 +70,6 @@ class ShareButton extends StatelessWidget { case ShareType.html: case ShareType.csv: showToastNotification( - context, message: LocaleKeys.settings_files_exportFileSuccess.tr(), ); break; @@ -78,7 +80,6 @@ class ShareButton extends StatelessWidget { void _handleExportError(BuildContext context, FlowyError error) { showToastNotification( - context, message: '${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}', ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart index 6980edce46..190fe9ddd8 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart @@ -117,8 +117,7 @@ class _ShareTabContent extends StatelessWidget { ); showToastNotification( - context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart new file mode 100644 index 0000000000..33c3bb2c0a --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart @@ -0,0 +1,197 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import 'custom_image_cache_manager.dart'; + +class FlowyNetworkSvg extends StatefulWidget { + FlowyNetworkSvg( + this.url, { + Key? key, + this.cacheKey, + this.placeholder, + this.errorWidget, + this.width, + this.height, + this.headers, + this.fit = BoxFit.contain, + this.alignment = Alignment.center, + this.matchTextDirection = false, + this.allowDrawingOutsideViewBox = false, + this.semanticsLabel, + this.excludeFromSemantics = false, + this.theme = const SvgTheme(), + this.fadeDuration = Duration.zero, + this.colorFilter, + this.placeholderBuilder, + BaseCacheManager? cacheManager, + }) : cacheManager = cacheManager ?? CustomImageCacheManager(), + super(key: key ?? ValueKey(url)); + + final String url; + final String? cacheKey; + final Widget? placeholder; + final Widget? errorWidget; + final double? width; + final double? height; + final ColorFilter? colorFilter; + final Map? headers; + final BoxFit fit; + final AlignmentGeometry alignment; + final bool matchTextDirection; + final bool allowDrawingOutsideViewBox; + final String? semanticsLabel; + final bool excludeFromSemantics; + final SvgTheme theme; + final Duration fadeDuration; + final WidgetBuilder? placeholderBuilder; + final BaseCacheManager cacheManager; + + @override + State createState() => _FlowyNetworkSvgState(); + + static Future preCache( + String imageUrl, { + String? cacheKey, + BaseCacheManager? cacheManager, + }) { + final key = cacheKey ?? _generateKeyFromUrl(imageUrl); + cacheManager ??= DefaultCacheManager(); + return cacheManager.downloadFile(key); + } + + static Future clearCacheForUrl( + String imageUrl, { + String? cacheKey, + BaseCacheManager? cacheManager, + }) { + final key = cacheKey ?? _generateKeyFromUrl(imageUrl); + cacheManager ??= DefaultCacheManager(); + return cacheManager.removeFile(key); + } + + static Future clearCache({BaseCacheManager? cacheManager}) { + cacheManager ??= DefaultCacheManager(); + return cacheManager.emptyCache(); + } + + static String _generateKeyFromUrl(String url) => url.split('?').first; +} + +class _FlowyNetworkSvgState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = false; + bool _isError = false; + File? _imageFile; + late String _cacheKey; + + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _cacheKey = + widget.cacheKey ?? FlowyNetworkSvg._generateKeyFromUrl(widget.url); + _controller = AnimationController( + vsync: this, + duration: widget.fadeDuration, + ); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); + _loadImage(); + } + + Future _loadImage() async { + try { + _setToLoadingAfter15MsIfNeeded(); + + var file = (await widget.cacheManager.getFileFromMemory(_cacheKey))?.file; + + file ??= await widget.cacheManager.getSingleFile( + widget.url, + key: _cacheKey, + headers: widget.headers ?? {}, + ); + + _imageFile = file; + _isLoading = false; + + _setState(); + + await _controller.forward(); + } catch (e) { + log('CachedNetworkSVGImage: $e'); + + _isError = true; + _isLoading = false; + + _setState(); + } + } + + void _setToLoadingAfter15MsIfNeeded() => Future.delayed( + const Duration(milliseconds: 15), + () { + if (!_isLoading && _imageFile == null && !_isError) { + _isLoading = true; + _setState(); + } + }, + ); + + void _setState() => mounted ? setState(() {}) : null; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: _buildImage(), + ); + } + + Widget _buildImage() { + if (_isLoading) return _buildPlaceholderWidget(); + + if (_isError) return _buildErrorWidget(); + + return FadeTransition( + opacity: _animation, + child: _buildSVGImage(), + ); + } + + Widget _buildPlaceholderWidget() => + Center(child: widget.placeholder ?? const SizedBox()); + + Widget _buildErrorWidget() => + Center(child: widget.errorWidget ?? const SizedBox()); + + Widget _buildSVGImage() { + if (_imageFile == null) return const SizedBox(); + + return SvgPicture.file( + _imageFile!, + fit: widget.fit, + width: widget.width, + height: widget.height, + alignment: widget.alignment, + matchTextDirection: widget.matchTextDirection, + allowDrawingOutsideViewBox: widget.allowDrawingOutsideViewBox, + colorFilter: widget.colorFilter, + semanticsLabel: widget.semanticsLabel, + excludeFromSemantics: widget.excludeFromSemantics, + placeholderBuilder: widget.placeholderBuilder, + theme: widget.theme, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart similarity index 88% rename from frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart rename to frontend/appflowy_flutter/lib/shared/error_page/error_page.dart index d395873bd7..9661fd822a 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart @@ -1,14 +1,18 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.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_svg/flowy_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; class FlowyErrorPage extends StatelessWidget { factory FlowyErrorPage.error( @@ -86,7 +90,9 @@ class FlowyErrorPage extends StatelessWidget { Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) async { - await Clipboard.setData(ClipboardData(text: message)); + await getIt().setData( + ClipboardServiceData(plainText: message), + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -188,8 +194,8 @@ class StackTracePreview extends StatelessWidget { "Copy", ), useIntrinsicWidth: true, - onTap: () => Clipboard.setData( - ClipboardData(text: stackTrace), + onTap: () => getIt().setData( + ClipboardServiceData(plainText: stackTrace), ), ), ), @@ -252,18 +258,14 @@ class GitHubRedirectButton extends StatelessWidget { Widget build(BuildContext context) { return FlowyButton( leftIconSize: const Size.square(_height), - text: const FlowyText( - "AppFlowy", - ), + text: FlowyText(LocaleKeys.appName.tr()), useIntrinsicWidth: true, leftIcon: const Padding( padding: EdgeInsets.all(4.0), child: FlowySvg(FlowySvgData('login/github-mark')), ), onTap: () async { - if (await canLaunchUrl(_gitHubNewBugUri)) { - await launchUrl(_gitHubNewBugUri); - } + await afLaunchUri(_gitHubNewBugUri); }, ); } diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart index da9f679f56..5942271206 100644 --- a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -45,7 +45,6 @@ class _MobileSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); @@ -101,7 +100,6 @@ class _DesktopSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - context, message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart index 8728c3be4a..40b9c1d6fa 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart @@ -5,7 +5,7 @@ extension PickerColors on BuildContext { Color get pickerTextColor { return Theme.of(this).isLightMode ? const Color(0x80171717) - : Colors.white.withOpacity(0.5); + : Colors.white.withValues(alpha: 0.5); } Color get pickerIconColor { @@ -15,12 +15,12 @@ extension PickerColors on BuildContext { Color get pickerSearchBarBorderColor { return Theme.of(this).isLightMode ? const Color(0x1E171717) - : Colors.white.withOpacity(0.12); + : Colors.white.withValues(alpha: 0.12); } Color get pickerButtonBoarderColor { return Theme.of(this).isLightMode ? const Color(0x1E171717) - : Colors.white.withOpacity(0.12); + : Colors.white.withValues(alpha: 0.12); } } diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart index 137ecbf94d..b04b38a45a 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart @@ -243,6 +243,7 @@ class _FlowyIconEmojiPickerState extends State Widget _buildIconUploader() { return IconUploader( documentId: widget.documentId ?? '', + ensureFocus: true, onUrl: (url) { widget.onSelectedEmoji ?.call(SelectedEmojiIconResult(EmojiIconData.custom(url), false)); diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart index 47025225b3..0d57d12d3c 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart @@ -284,83 +284,110 @@ class IconPicker extends StatefulWidget { class _IconPickerState extends State { final mutex = PopoverMutex(); + PopoverController? childPopoverController; + + @override + void dispose() { + super.dispose(); + childPopoverController = null; + } @override Widget build(BuildContext context) { - return ListView.builder( - itemCount: widget.iconGroups.length, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - itemBuilder: (context, index) { - final iconGroup = widget.iconGroups[index]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText( - iconGroup.displayName.capitalize(), - fontSize: 12, - figmaLineHeight: 18.0, - color: context.pickerTextColor, - ), - const VSpace(4.0), - GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.iconPerLine, - ), - itemCount: iconGroup.icons.length, - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemBuilder: (context, index) { - final icon = iconGroup.icons[index]; - return widget.enableBackgroundColorSelection - ? _Icon( - icon: icon, - mutex: mutex, - onSelectedColor: (context, color) { - String groupName = iconGroup.name; - if (groupName == _kRecentIconGroupName) { - groupName = getGroupName(index); - } - widget.onSelectedIcon( - IconsData( - groupName, - icon.name, - color, - ), + return GestureDetector( + onTap: hideColorSelector, + child: NotificationListener( + onNotification: (notificationInfo) { + if (notificationInfo is ScrollStartNotification) { + hideColorSelector(); + } + return true; + }, + child: ListView.builder( + itemCount: widget.iconGroups.length, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + itemBuilder: (context, index) { + final iconGroup = widget.iconGroups[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + iconGroup.displayName.capitalize(), + fontSize: 12, + figmaLineHeight: 18.0, + color: context.pickerTextColor, + ), + const VSpace(4.0), + GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.iconPerLine, + ), + itemCount: iconGroup.icons.length, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final icon = iconGroup.icons[index]; + return widget.enableBackgroundColorSelection + ? _Icon( + icon: icon, + mutex: mutex, + onOpen: (childPopoverController) { + this.childPopoverController = + childPopoverController; + }, + onSelectedColor: (context, color) { + String groupName = iconGroup.name; + if (groupName == _kRecentIconGroupName) { + groupName = getGroupName(index); + } + widget.onSelectedIcon( + IconsData( + groupName, + icon.name, + color, + ), + ); + RecentIcons.putIcon(RecentIcon(icon, groupName)); + PopoverContainer.of(context).close(); + }, + ) + : _IconNoBackground( + icon: icon, + onSelectedIcon: () { + String groupName = iconGroup.name; + if (groupName == _kRecentIconGroupName) { + groupName = getGroupName(index); + } + widget.onSelectedIcon( + IconsData( + groupName, + icon.name, + null, + ), + ); + RecentIcons.putIcon(RecentIcon(icon, groupName)); + }, ); - RecentIcons.putIcon(RecentIcon(icon, groupName)); - PopoverContainer.of(context).close(); - }, - ) - : _IconNoBackground( - icon: icon, - onSelectedIcon: () { - String groupName = iconGroup.name; - if (groupName == _kRecentIconGroupName) { - groupName = getGroupName(index); - } - widget.onSelectedIcon( - IconsData( - groupName, - icon.name, - null, - ), - ); - RecentIcons.putIcon(RecentIcon(icon, groupName)); - }, - ); - }, - ), - const VSpace(12.0), - if (index == widget.iconGroups.length - 1) ...[ - const StreamlinePermit(), - const VSpace(12.0), - ], - ], - ); - }, + }, + ), + const VSpace(12.0), + if (index == widget.iconGroups.length - 1) ...[ + const StreamlinePermit(), + const VSpace(12.0), + ], + ], + ); + }, + ), + ), ); } + void hideColorSelector() { + childPopoverController?.close(); + childPopoverController = null; + } + String getGroupName(int index) { final recentIcons = RecentIcons.getIconsSync(); try { @@ -376,9 +403,11 @@ class _IconNoBackground extends StatelessWidget { const _IconNoBackground({ required this.icon, required this.onSelectedIcon, + this.isSelected = false, }); final Icon icon; + final bool isSelected; final VoidCallback onSelectedIcon; @override @@ -387,6 +416,7 @@ class _IconNoBackground extends StatelessWidget { message: icon.displayName, preferBelow: false, child: FlowyButton( + isSelected: isSelected, useIntrinsicWidth: true, onTap: () => onSelectedIcon(), margin: const EdgeInsets.all(8.0), @@ -408,11 +438,13 @@ class _Icon extends StatefulWidget { required this.icon, required this.mutex, required this.onSelectedColor, + this.onOpen, }); final Icon icon; final PopoverMutex mutex; final void Function(BuildContext context, String color) onSelectedColor; + final ValueChanged? onOpen; @override State<_Icon> createState() => _IconState(); @@ -420,6 +452,7 @@ class _Icon extends StatefulWidget { class _IconState extends State<_Icon> { final PopoverController _popoverController = PopoverController(); + bool isSelected = false; @override void dispose() { @@ -434,10 +467,18 @@ class _IconState extends State<_Icon> { controller: _popoverController, offset: const Offset(0, 6), mutex: widget.mutex, + onClose: () { + updateIsSelected(false); + }, clickHandler: PopoverClickHandler.gestureDetector, child: _IconNoBackground( icon: widget.icon, - onSelectedIcon: () => _popoverController.show(), + isSelected: isSelected, + onSelectedIcon: () { + updateIsSelected(true); + _popoverController.show(); + widget.onOpen?.call(_popoverController); + }, ), popupBuilder: (context) { return Container( @@ -449,6 +490,12 @@ class _IconState extends State<_Icon> { }, ); } + + void updateIsSelected(bool isSelected) { + setState(() { + this.isSelected = isSelected; + }); + } } class StreamlinePermit extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart index 9ebcd78def..ff8e7b88ec 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart @@ -1,22 +1,30 @@ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; -import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; @visibleForTesting @@ -25,97 +33,232 @@ class IconUploader extends StatefulWidget { super.key, required this.onUrl, required this.documentId, + this.ensureFocus = false, }); final ValueChanged onUrl; final String documentId; + final bool ensureFocus; @override State createState() => _IconUploaderState(); } class _IconUploaderState extends State { + bool isActive = false; bool isHovering = false; bool isUploading = false; - final List pickedImages = []; + final List<_Image> pickedImages = []; + final FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] + /// this is to ensure that focus can be regained within a short period of time + if (widget.ensureFocus) { + Future.delayed(const Duration(milliseconds: 200), () { + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + enableDocumentDragNotifier.value = false; + }); + } + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.addPostFrameCallback((_) { + enableDocumentDragNotifier.value = true; + }); + focusNode.dispose(); + } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Expanded( - child: DropTarget( - /// there is an issue with multiple DropTargets - /// see https://github.com/MixinNetwork/flutter-plugins/issues/2 - enable: false, - onDragEntered: (_) => setState(() => isHovering = true), - onDragExited: (_) => setState(() => isHovering = false), - onDragDone: (details) => loadImage(details.files), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => pickImage(), - child: DottedBorder( - dashPattern: const [3, 3], - radius: const Radius.circular(8), - borderType: BorderType.RRect, - color: isHovering - ? Theme.of(context).colorScheme.primary - : Theme.of(context).hintColor, - child: Center( - child: pickedImages.isEmpty ? dragHint() : previewImage(), + return Shortcuts( + shortcuts: { + LogicalKeySet( + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyV, + ): _PasteIntent(), + }, + child: Actions( + actions: { + _PasteIntent: CallbackAction<_PasteIntent>( + onInvoke: (intent) => pasteAsAnImage(), + ), + }, + child: Focus( + autofocus: true, + focusNode: focusNode, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Expanded( + child: DropTarget( + onDragEntered: (_) => setState(() => isActive = true), + onDragExited: (_) => setState(() => isActive = false), + onDragDone: (details) => loadImage(details.files), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: pickImage, + child: DottedBorder( + dashPattern: const [3, 3], + radius: const Radius.circular(8), + borderType: BorderType.RRect, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor, + child: Container( + alignment: Alignment.center, + decoration: isHovering + ? BoxDecoration( + color: Color(0x0F1F2329), + borderRadius: BorderRadius.circular(8), + ) + : null, + child: pickedImages.isEmpty + ? (isActive + ? hoveringWidget() + : dragHint(context)) + : previewImage(), + ), + ), + ), ), ), ), - ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + children: [ + Spacer(), + if (pickedImages.isNotEmpty) + Padding( + padding: EdgeInsets.only(right: 8), + child: _ChangeIconButton( + onTap: pickImage, + ), + ), + _ConfirmButton( + onTap: uploadImage, + enable: pickedImages.isNotEmpty, + ), + ], + ), + ), + ], ), ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: Align( - alignment: Alignment.centerRight, - child: _ConfirmButton( - onTap: uploadImage, - enable: pickedImages.isNotEmpty, - ), - ), - ), - ], + ), ), ); } - Widget dragHint() => FlowyText( - LocaleKeys.document_imageBlock_upload_placeholder.tr(), - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context).hintColor, - ); + Widget hoveringWidget() { + return Container( + color: Color(0xffE0F8FF), + child: Center( + child: FlowyText( + LocaleKeys.emojiIconPicker_iconUploader_dropToUpload.tr(), + ), + ), + ); + } - Widget previewImage() => Image.file( - File(pickedImages.first), + Widget dragHint(BuildContext context) { + final style = TextStyle( + fontSize: 14, + color: Color(0xff666D76), + fontWeight: FontWeight.w500, + ); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.emojiIconPicker_iconUploader_placeholderLeft.tr(), + ), + TextSpan( + text: LocaleKeys.emojiIconPicker_iconUploader_placeholderUpload + .tr(), + style: style.copyWith(color: Color(0xff00BCF0)), + ), + TextSpan( + text: + LocaleKeys.emojiIconPicker_iconUploader_placeholderRight.tr(), + mouseCursor: SystemMouseCursors.click, + ), + ], + style: style, + ), + ), + ); + } + + Widget previewImage() { + final image = pickedImages.first; + final url = image.url; + if (image is _FileImage) { + if (url.endsWith(_svgSuffix)) { + return SvgPicture.file( + File(url), + width: 200, + height: 200, + ); + } + return Image.file( + File(url), width: 200, height: 200, - fit: BoxFit.cover, ); + } else if (image is _NetworkImage) { + if (url.endsWith(_svgSuffix)) { + return FlowyNetworkSvg( + url, + width: 200, + height: 200, + ); + } + return FlowyNetworkImage( + width: 200, + height: 200, + url: url, + ); + } + return const SizedBox.shrink(); + } void loadImage(List files) { final imageFiles = files .where( (file) => file.mimeType?.startsWith('image/') ?? - false || imgExtensionRegex.hasMatch(file.name), + false || + imgExtensionRegex.hasMatch(file.name) || + file.name.endsWith(_svgSuffix), ) .toList(); if (imageFiles.isEmpty) return; if (mounted) { setState(() { pickedImages.clear(); - pickedImages.add(imageFiles.first.path); + pickedImages.add(_FileImage(imageFiles.first.path)); }); } } @@ -126,7 +269,7 @@ class _IconUploaderState extends State { final result = await getIt().pickFiles( dialogTitle: '', type: FileType.custom, - allowedExtensions: defaultImageExtensions, + allowedExtensions: List.of(defaultImageExtensions)..add('svg'), ); loadImage(result?.files.map((f) => f.xFile).toList() ?? const []); } else { @@ -151,25 +294,91 @@ class _IconUploaderState extends State { (userProfile) => userProfile, (l) => null, ); - final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == - AuthenticatorPB.Local; + final isLocalMode = + (userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local; if (isLocalMode) { - result = await saveImageToLocalStorage(pickedImages.first); + result = await pickedImages.first.saveToLocal(); } else { - final (url, errorMsg) = await saveImageToCloudStorage( - pickedImages.first, - widget.documentId, - ); - result = url; - if (errorMsg?.isNotEmpty ?? false) { - Log.error('upload icon image error :$errorMsg'); - } + result = await pickedImages.first.uploadToCloud(widget.documentId); } isUploading = false; if (result?.isNotEmpty ?? false) { widget.onUrl.call(result!); } } + + Future pasteAsAnImage() async { + final data = await getIt().getData(); + final plainText = data.plainText; + Log.info('pasteAsAnImage plainText:$plainText'); + if (plainText == null) return; + if (isURL(plainText) && (await validateImage(plainText))) { + setState(() { + pickedImages.clear(); + pickedImages.add(_NetworkImage(plainText)); + }); + } + } + + Future validateImage(String imageUrl) async { + Response res; + try { + res = await get(Uri.parse(imageUrl)); + } catch (e) { + return false; + } + if (res.statusCode != 200) return false; + final Map data = res.headers; + return checkIfImage(data['content-type']); + } + + bool checkIfImage(String? param) { + if (param == 'image/jpeg' || + param == 'image/png' || + param == 'image/gif' || + param == 'image/tiff' || + param == 'image/webp' || + param == 'image/svg+xml' || + param == 'image/svg') { + return true; + } + return false; + } +} + +class _ChangeIconButton extends StatelessWidget { + const _ChangeIconButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return SizedBox( + height: 32, + width: 84, + child: FlowyButton( + text: FlowyText( + LocaleKeys.emojiIconPicker_iconUploader_change.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 20.0, + color: isDark ? Colors.white : Color(0xff1F2329), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + margin: const EdgeInsets.symmetric(horizontal: 14.0), + backgroundColor: Theme.of(context).colorScheme.surface, + hoverColor: + (isDark ? Colors.white : Color(0xffD1D8E0)).withValues(alpha: 0.9), + decoration: BoxDecoration( + border: Border.all(color: isDark ? Colors.white : Color(0xffD1D8E0)), + borderRadius: BorderRadius.circular(10), + ), + onTap: onTap, + ), + ); + } } class _ConfirmButton extends StatelessWidget { @@ -186,9 +395,72 @@ class _ConfirmButton extends StatelessWidget { opacity: enable ? 1.0 : 0.5, child: PrimaryRoundedButton( text: LocaleKeys.button_confirm.tr(), + figmaLineHeight: 20.0, onTap: enable ? onTap : null, ), ), ); } } + +const _svgSuffix = '.svg'; + +class _PasteIntent extends Intent {} + +abstract class _Image { + String get url; + + Future saveToLocal(); + + Future uploadToCloud(String documentId); + + String get pureUrl => url.split('?').first; +} + +class _FileImage extends _Image { + _FileImage(this.url); + + @override + final String url; + + @override + Future saveToLocal() => saveImageToLocalStorage(url); + + @override + Future uploadToCloud(String documentId) async { + final (url, errorMsg) = await saveImageToCloudStorage( + this.url, + documentId, + ); + if (errorMsg?.isNotEmpty ?? false) { + Log.error('upload icon image :${this.url} error :$errorMsg'); + } + return url; + } +} + +class _NetworkImage extends _Image { + _NetworkImage(this.url); + + @override + final String url; + + @override + Future saveToLocal() async { + final file = await CustomImageCacheManager().downloadFile(pureUrl); + return file.file.path; + } + + @override + Future uploadToCloud(String documentId) async { + final file = await CustomImageCacheManager().downloadFile(pureUrl); + final (url, errorMsg) = await saveImageToCloudStorage( + file.file.path, + documentId, + ); + if (errorMsg?.isNotEmpty ?? false) { + Log.error('upload icon image :${this.url} error :$errorMsg'); + } + return url; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart index 5763906b57..912f96bd05 100644 --- a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart +++ b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart @@ -1,5 +1,14 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:share_plus/share_plus.dart'; Document customMarkdownToDocument( String markdown, { @@ -14,17 +23,75 @@ Document customMarkdownToDocument( ); } -String customDocumentToMarkdown(Document document) { - return documentToMarkdown( - document, - customParsers: [ - const MathEquationNodeParser(), - const CalloutNodeParser(), - const ToggleListNodeParser(), - const CustomImageNodeParser(), - const SimpleTableNodeParser(), - const LinkPreviewNodeParser(), - const FileBlockNodeParser(), - ], - ); +Future customDocumentToMarkdown( + Document document, { + String path = '', + AsyncValueSetter? onArchive, + String lineBreak = '', +}) async { + final List> fileFutures = []; + + /// create root Archive and directory + final id = document.root.id, + archive = Archive(), + resourceDir = ArchiveFile('$id/', 0, null)..isFile = false, + fileName = p.basenameWithoutExtension(path), + dirName = resourceDir.name; + + String markdown = ''; + try { + markdown = documentToMarkdown( + document, + lineBreak: lineBreak, + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + CustomImageNodeFileParser(fileFutures, dirName), + CustomMultiImageNodeFileParser(fileFutures, dirName), + GridNodeParser(fileFutures, dirName), + BoardNodeParser(fileFutures, dirName), + CalendarNodeParser(fileFutures, dirName), + const CustomParagraphNodeParser(), + const SubPageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); + } catch (e) { + Log.error('documentToMarkdown error: $e'); + } + + /// create resource directory + if (fileFutures.isNotEmpty) archive.addFile(resourceDir); + + for (final fileFuture in fileFutures) { + archive.addFile(await fileFuture); + } + + /// add markdown file to Archive + final dataBytes = utf8.encode(markdown); + archive.addFile(ArchiveFile('$fileName-$id.md', dataBytes.length, dataBytes)); + + if (archive.isNotEmpty && path.isNotEmpty) { + if (onArchive == null) { + final zipEncoder = ZipEncoder(); + final zip = zipEncoder.encode(archive); + if (zip != null) { + final zipFile = await File(path).writeAsBytes(zip); + if (Platform.isIOS) { + await Share.shareUri(zipFile.uri); + await zipFile.delete(); + } else if (Platform.isAndroid) { + await Share.shareXFiles([XFile(zipFile.path)]); + await zipFile.delete(); + } + Log.info('documentToMarkdownFiles to $path'); + } + } else { + await onArchive.call(archive); + } + } + return markdown; } diff --git a/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart b/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart index df260bad71..4b5ad56ab1 100644 --- a/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart +++ b/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart @@ -9,9 +9,9 @@ import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_d import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:universal_platform/universal_platform.dart'; class PermissionChecker { static Future checkPhotoPermission(BuildContext context) async { @@ -48,7 +48,7 @@ class PermissionChecker { } else if (status.isDenied) { // https://github.com/Baseflow/flutter-permission-handler/issues/1262#issuecomment-2006340937 Permission permission = Permission.photos; - if (defaultTargetPlatform == TargetPlatform.android && + if (UniversalPlatform.isAndroid && ApplicationInfo.androidSDKVersion <= 32) { permission = Permission.storage; } 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 1e9aa1a3c3..786d666060 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 @@ -1613,7 +1613,7 @@ class _PopupMenuDefaultsM3 extends PopupMenuThemeData { return WidgetStateProperty.resolveWith((Set states) { final TextStyle style = _textTheme.labelLarge!; if (states.contains(WidgetState.disabled)) { - return style.apply(color: _colors.onSurface.withOpacity(0.38)); + return style.apply(color: _colors.onSurface.withValues(alpha: 0.38)); } return style.apply(color: _colors.onSurface); }); diff --git a/frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart b/frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart new file mode 100644 index 0000000000..6e50a922a7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; +import 'package:universal_platform/universal_platform.dart'; +import 'package:xml/xml.dart' as xml; + +final versionChecker = VersionChecker(); + +/// Version checker class to handle update checks using appcast XML feeds +class VersionChecker { + factory VersionChecker() => _instance; + + VersionChecker._internal(); + String? _feedUrl; + + static final VersionChecker _instance = VersionChecker._internal(); + + /// Sets the appcast XML feed URL + void setFeedUrl(String url) { + _feedUrl = url; + + if (UniversalPlatform.isWindows || UniversalPlatform.isMacOS) { + autoUpdater.setFeedURL(url); + // disable the auto update check + autoUpdater.setScheduledCheckInterval(0); + } + } + + /// Checks for updates by fetching and parsing the appcast XML + /// Returns a list of [AppcastItem] or throws an exception if the feed URL is not set + Future checkForUpdateInformation() async { + if (_feedUrl == null) { + Log.error('Feed URL is not set'); + return null; + } + + try { + final response = await http.get(Uri.parse(_feedUrl!)); + if (response.statusCode != 200) { + Log.info('Failed to fetch appcast XML: ${response.statusCode}'); + return null; + } + + // Parse XML content + final document = xml.XmlDocument.parse(response.body); + final items = document.findAllElements('item'); + + // Convert XML items to AppcastItem objects + return items + .map(_parseAppcastItem) + .nonNulls + .firstWhereOrNull((e) => e.os == ApplicationInfo.os); + } catch (e) { + Log.info('Failed to check for updates: $e'); + } + + return null; + } + + /// For Windows and macOS, calling this API will trigger the auto updater to check for updates + /// For Linux, it will open the official website in the browser if there is a new version + + Future checkForUpdate() async { + if (UniversalPlatform.isLinux) { + // open the official website in the browser + await afLaunchUrlString('https://appflowy.com/download'); + } else { + await autoUpdater.checkForUpdates(); + } + } + + AppcastItem? _parseAppcastItem(xml.XmlElement item) { + final enclosure = item.findElements('enclosure').firstOrNull; + return AppcastItem.fromJson({ + 'title': item.findElements('title').firstOrNull?.innerText, + 'versionString': item + .findElements('sparkle:shortVersionString') + .firstOrNull + ?.innerText, + 'displayVersionString': item + .findElements('sparkle:shortVersionString') + .firstOrNull + ?.innerText, + 'releaseNotesUrl': + item.findElements('releaseNotesLink').firstOrNull?.innerText, + 'pubDate': item.findElements('pubDate').firstOrNull?.innerText, + 'fileURL': enclosure?.getAttribute('url') ?? '', + 'os': enclosure?.getAttribute('sparkle:os') ?? '', + 'criticalUpdate': + enclosure?.getAttribute('sparkle:criticalUpdate') ?? false, + }); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index a0321e7e66..5a8c0fa651 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -3,19 +3,16 @@ import 'package:appflowy/core/network_monitor.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; -import 'package:appflowy/ai/service/ai_client.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_listener.dart'; -import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; @@ -83,16 +80,6 @@ void _resolveCommonService( () => mode.isTest ? MockApplicationDataStorage() : ApplicationDataStorage(), ); - getIt.registerFactoryAsync( - () async { - final result = await UserBackendService.getCurrentUserProfile(); - return result.fold( - (s) => AppFlowyAIService(), - (e) => throw Exception('Failed to get user profile: ${e.msg}'), - ); - }, - ); - getIt.registerFactory( () => ClipboardService(), ); @@ -115,7 +102,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { case AuthenticatorType.local: getIt.registerFactory( () => BackendAuthService( - AuthenticatorPB.Local, + AuthTypePB.Local, ), ); break; diff --git a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart index 920a994927..5bb08e3fdf 100644 --- a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart @@ -1,4 +1,4 @@ -library flowy_plugin; +library; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index ac0be447c0..7a282b3856 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/startup/tasks/feature_flag_task.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; @@ -120,7 +121,7 @@ class FlowyRunner { // this task should be second task, for handling memory leak. // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. MemoryLeakDetectorTask(), - const DebugTask(), + DebugTask(), const FeatureFlagTask(), // localization @@ -139,6 +140,8 @@ class FlowyRunner { // The DeviceOrApplicationInfoTask should be placed before the AppWidgetTask to fetch the app information. // It is unable to get the device information from the test environment. const ApplicationInfoTask(), + // The auto update task should be placed after the ApplicationInfoTask to fetch the latest version. + if (!mode.isIntegrationTest) AutoUpdateTask(), const HotKeyTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), const InitAppWidgetTask(), @@ -184,6 +187,10 @@ Future initGetIt( ); getIt.registerSingleton(PluginSandbox()); getIt.registerSingleton(ViewExpanderRegistry()); + getIt.registerSingleton(LinkHoverTriggers()); + getIt.registerSingleton( + FloatingToolbarController(), + ); await DependencyResolver.resolve(getIt, mode); } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index a398db3061..98b76802d4 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -17,9 +17,9 @@ import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_b import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -64,7 +64,6 @@ class InitAppWidgetTask extends LaunchTask { child: widget, ); - Bloc.observer = ApplicationBlocObserver(); runApp( EasyLocalization( supportedLocales: const [ @@ -100,6 +99,7 @@ class InitAppWidgetTask extends LaunchTask { Locale('zh', 'TW'), Locale('fa'), Locale('hin'), + Locale('mr', 'IN'), ], path: 'assets/translations', fallbackLocale: const Locale('en'), @@ -226,22 +226,6 @@ class _ApplicationWidgetState extends State { } }, child: MaterialApp.router( - builder: (context, child) => MediaQuery( - // use the 1.0 as the textScaleFactor to avoid the text size - // affected by the system setting. - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(state.textScaleFactor), - ), - child: overlayManagerBuilder( - context, - !UniversalPlatform.isMobile && FeatureFlag.search.isOn - ? CommandPalette( - notifier: _commandPaletteNotifier, - child: child, - ) - : child, - ), - ), debugShowCheckedModeBanner: false, theme: state.lightTheme, darkTheme: state.darkTheme, @@ -250,6 +234,34 @@ class _ApplicationWidgetState extends State { supportedLocales: context.supportedLocales, locale: state.locale, routerConfig: routerConfig, + builder: (context, child) { + final themeBuilder = AppFlowyDefaultTheme(); + final brightness = Theme.of(context).brightness; + + return AnimatedAppFlowyTheme( + data: brightness == Brightness.light + ? themeBuilder.light() + : themeBuilder.dark(), + child: MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: + TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !UniversalPlatform.isMobile && + FeatureFlag.search.isOn + ? CommandPalette( + notifier: _commandPaletteNotifier, + child: child, + ) + : child, + ), + ), + ); + }, ), ), ), @@ -283,14 +295,6 @@ class AppGlobals { static BuildContext get context => rootNavKey.currentContext!; } -class ApplicationBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - Log.debug(error); - super.onError(bloc, error, stackTrace); - } -} - Future appTheme(String themeName) async { if (themeName.isEmpty) { return AppTheme.fallback; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart index 7d41f2dceb..5636ed70cb 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -12,6 +12,10 @@ class WindowSizeManager { static const double maxWindowHeight = 8192.0; static const double maxWindowWidth = 8192.0; + // Default windows size + static const double defaultWindowHeight = 960.0; + static const double defaultWindowWidth = 1280.0; + static const double maxScaleFactor = 2.0; static const double minScaleFactor = 0.5; @@ -36,8 +40,8 @@ class WindowSizeManager { Future getSize() async { final defaultWindowSize = jsonEncode( { - WindowSizeManager.height: minWindowHeight, - WindowSizeManager.width: minWindowWidth, + WindowSizeManager.height: defaultWindowHeight, + WindowSizeManager.width: defaultWindowWidth, }, ); final windowSize = await getIt().get(KVKeys.windowSize); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 2c22b8a01e..362b27a85a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -83,6 +83,13 @@ class AppFlowyCloudDeepLink { void unsubscribeDeepLinkLoadingState(VoidCallback listener) => _stateNotifier?.removeListener(listener); + Future passGotrueTokenResponse( + GotrueTokenResponsePB gotrueTokenResponse, + ) async { + final uri = _buildDeepLinkUri(gotrueTokenResponse); + await _handleUri(uri); + } + Future _handleUri( Uri? uri, ) async { @@ -105,7 +112,7 @@ class AppFlowyCloudDeepLink { (_) async { final deviceId = await getDeviceId(); final payload = OauthSignInPB( - authenticator: AuthenticatorPB.AppFlowyCloud, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: uri.toString(), AuthServiceMapKeys.deviceId: deviceId, @@ -129,7 +136,6 @@ class AppFlowyCloudDeepLink { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( - context, message: err.msg, ); } @@ -173,6 +179,57 @@ class AppFlowyCloudDeepLink { bool _isPaymentSuccessUri(Uri uri) { return uri.host == 'payment-success'; } + + Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { + final params = {}; + + if (gotrueTokenResponse.hasAccessToken() && + gotrueTokenResponse.accessToken.isNotEmpty) { + params['access_token'] = gotrueTokenResponse.accessToken; + } + + if (gotrueTokenResponse.hasExpiresAt()) { + params['expires_at'] = gotrueTokenResponse.expiresAt.toString(); + } + + if (gotrueTokenResponse.hasExpiresIn()) { + params['expires_in'] = gotrueTokenResponse.expiresIn.toString(); + } + + if (gotrueTokenResponse.hasProviderRefreshToken() && + gotrueTokenResponse.providerRefreshToken.isNotEmpty) { + params['provider_refresh_token'] = + gotrueTokenResponse.providerRefreshToken; + } + + if (gotrueTokenResponse.hasProviderAccessToken() && + gotrueTokenResponse.providerAccessToken.isNotEmpty) { + params['provider_token'] = gotrueTokenResponse.providerAccessToken; + } + + if (gotrueTokenResponse.hasRefreshToken() && + gotrueTokenResponse.refreshToken.isNotEmpty) { + params['refresh_token'] = gotrueTokenResponse.refreshToken; + } + + if (gotrueTokenResponse.hasTokenType() && + gotrueTokenResponse.tokenType.isNotEmpty) { + params['token_type'] = gotrueTokenResponse.tokenType; + } + + if (params.isEmpty) { + return null; + } + + final fragment = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return Uri.parse('appflowy-flutter://login-callback#$fragment'); + } } class InitAppFlowyCloudTask extends LaunchTask { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart new file mode 100644 index 0000000000..b666392544 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../startup.dart'; + +class AutoUpdateTask extends LaunchTask { + AutoUpdateTask(); + + static const _feedUrl = + 'https://github.com/AppFlowy-IO/AppFlowy/releases/latest/download/appcast-{os}-{arch}.xml'; + final _listener = _AppFlowyAutoUpdaterListener(); + + @override + Future initialize(LaunchContext context) async { + // the auto updater is not supported on mobile + if (UniversalPlatform.isMobile) { + return; + } + + // don't use await here, because the auto updater is not a blocking operation + unawaited(_setupAutoUpdater()); + + ApplicationInfo.isCriticalUpdateNotifier.addListener( + _showCriticalUpdateDialog, + ); + } + + @override + Future dispose() async { + autoUpdater.removeListener(_listener); + + ApplicationInfo.isCriticalUpdateNotifier.removeListener( + _showCriticalUpdateDialog, + ); + } + + // On macOS and windows, we use auto_updater to check for updates. + // On linux, we use the version checker to check for updates because the auto_updater is not supported. + Future _setupAutoUpdater() async { + Log.info( + '[AutoUpdate] current version: ${ApplicationInfo.applicationVersion}, current cpu architecture: ${ApplicationInfo.architecture}', + ); + + // Since the appcast.xml is not supported the arch, we separate the feed url by os and arch. + final feedUrl = _feedUrl + .replaceAll('{os}', ApplicationInfo.os) + .replaceAll('{arch}', ApplicationInfo.architecture); + + // the auto updater is only supported on macOS and windows, so we don't need to check the platform + if (UniversalPlatform.isMacOS || UniversalPlatform.isWindows) { + autoUpdater.addListener(_listener); + } + + Log.info('[AutoUpdate] feed url: $feedUrl'); + + versionChecker.setFeedUrl(feedUrl); + final item = await versionChecker.checkForUpdateInformation(); + if (item != null) { + ApplicationInfo.latestAppcastItem = item; + ApplicationInfo.latestVersionNotifier.value = + item.displayVersionString ?? ''; + } + } + + void _showCriticalUpdateDialog() { + showCustomConfirmDialog( + context: AppGlobals.rootNavKey.currentContext!, + title: LocaleKeys.autoUpdate_criticalUpdateTitle.tr(), + description: LocaleKeys.autoUpdate_criticalUpdateDescription.tr( + namedArgs: { + 'currentVersion': ApplicationInfo.applicationVersion, + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + builder: (context) => const SizedBox.shrink(), + // if the update is critical, dont allow the user to dismiss the dialog + barrierDismissible: false, + showCloseButton: false, + enableKeyboardListener: false, + closeOnConfirm: false, + confirmLabel: LocaleKeys.autoUpdate_criticalUpdateButton.tr(), + onConfirm: () async { + await versionChecker.checkForUpdate(); + }, + ); + } +} + +class _AppFlowyAutoUpdaterListener extends UpdaterListener { + @override + void onUpdaterBeforeQuitForUpdate(AppcastItem? item) {} + + @override + void onUpdaterCheckingForUpdate(Appcast? appcast) { + // Due to the reason documented in the following link, the update will not be found if the user has skipped the update. + // We have to check the skipped version manually. + // https://sparkle-project.org/documentation/api-reference/Classes/SPUUpdater.html#/c:objc(cs)SPUUpdater(im)checkForUpdateInformation + final items = appcast?.items; + if (items != null) { + final String? currentPlatform; + if (UniversalPlatform.isMacOS) { + currentPlatform = 'macos'; + } else if (UniversalPlatform.isWindows) { + currentPlatform = 'windows'; + } else { + currentPlatform = null; + } + + final matchingItem = items.firstWhereOrNull( + (item) => item.os == currentPlatform, + ); + + if (matchingItem != null) { + _updateVersionNotifier(matchingItem); + + Log.info( + '[AutoUpdate] latest version: ${matchingItem.displayVersionString}', + ); + } + } + } + + @override + void onUpdaterError(UpdaterError? error) { + Log.error('[AutoUpdate] On update error: $error'); + } + + @override + void onUpdaterUpdateNotAvailable(UpdaterError? error) { + Log.info('[AutoUpdate] Update not available $error'); + } + + @override + void onUpdaterUpdateAvailable(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update available: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateDownloaded(AppcastItem? item) { + Log.info('[AutoUpdate] Update downloaded: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateCancelled(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update cancelled: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateInstalled(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update installed: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateSkipped(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update skipped: ${item?.displayVersionString}'); + } + + void _updateVersionNotifier(AppcastItem? item) { + if (item != null) { + ApplicationInfo.latestAppcastItem = item; + ApplicationInfo.latestVersionNotifier.value = + item.displayVersionString ?? ''; + } + } +} + +class AppFlowyAutoUpdateVersion { + AppFlowyAutoUpdateVersion({ + required this.latestVersion, + required this.currentVersion, + required this.isForceUpdate, + }); + + factory AppFlowyAutoUpdateVersion.initial() => AppFlowyAutoUpdateVersion( + latestVersion: '0.0.0', + currentVersion: '0.0.0', + isForceUpdate: false, + ); + + final String latestVersion; + final String currentVersion; + + final bool isForceUpdate; + + bool get isUpdateAvailable => latestVersion != currentVersion; +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart index 082e25e250..9a34e84f70 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart @@ -1,18 +1,45 @@ +import 'package:appflowy/startup/startup.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_bloc_logger/talker_bloc_logger.dart'; import 'package:universal_platform/universal_platform.dart'; -import '../startup.dart'; - class DebugTask extends LaunchTask { - const DebugTask(); + DebugTask(); + + final Talker talker = Talker(); @override Future initialize(LaunchContext context) async { - // the hotkey manager is not supported on mobile + // hide the keyboard on mobile if (UniversalPlatform.isMobile && kDebugMode) { await SystemChannels.textInput.invokeMethod('TextInput.hide'); } + + // log the bloc events + if (kDebugMode) { + Bloc.observer = TalkerBlocObserver( + talker: talker, + settings: TalkerBlocLoggerSettings( + // Disabled by default to prevent mixing with AppFlowy logs + // Enable to observe all bloc events + enabled: false, + printEventFullData: false, + printStateFullData: false, + printChanges: true, + printClosings: true, + printCreations: true, + transitionFilter: (_, transition) { + // By default, observe all transitions + // You can add your own filter here if needed + // when you want to observer a specific bloc + return true; + }, + ), + ); + } } @override diff --git a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart index 1558eefa53..2c90afbdda 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:version/version.dart'; import '../startup.dart'; @@ -11,10 +14,42 @@ class ApplicationInfo { static String applicationVersion = ''; static String buildNumber = ''; static String deviceId = ''; + static String architecture = ''; + static String os = ''; // macOS major version static int? macOSMajorVersion; static int? macOSMinorVersion; + + // latest version + static ValueNotifier latestVersionNotifier = ValueNotifier(''); + // the version number is like 0.9.0 + static String get latestVersion => latestVersionNotifier.value; + + // If the latest version is greater than the current version, it means there is an update available + static bool get isUpdateAvailable { + try { + if (latestVersion.isEmpty) { + return false; + } + return Version.parse(latestVersion) > Version.parse(applicationVersion); + } catch (e) { + return false; + } + } + + // the latest appcast item + static AppcastItem? _latestAppcastItem; + static AppcastItem? get latestAppcastItem => _latestAppcastItem; + static set latestAppcastItem(AppcastItem? value) { + _latestAppcastItem = value; + + isCriticalUpdateNotifier.value = value?.criticalUpdate == true; + } + + // is critical update + static ValueNotifier isCriticalUpdateNotifier = ValueNotifier(false); + static bool get isCriticalUpdate => isCriticalUpdateNotifier.value; } class ApplicationInfoTask extends LaunchTask { @@ -36,38 +71,54 @@ class ApplicationInfoTask extends LaunchTask { ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt; } - if (Platform.isAndroid || Platform.isIOS) { - ApplicationInfo.applicationVersion = packageInfo.version; - ApplicationInfo.buildNumber = packageInfo.buildNumber; - } + ApplicationInfo.applicationVersion = packageInfo.version; + ApplicationInfo.buildNumber = packageInfo.buildNumber; String? deviceId; + String? architecture; + String? os; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo; deviceId = androidInfo.device; + architecture = androidInfo.supportedAbis.firstOrNull; + os = 'android'; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; deviceId = iosInfo.identifierForVendor; + architecture = iosInfo.utsname.machine; + os = 'ios'; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo; deviceId = macInfo.systemGUID; + architecture = macInfo.arch; + os = 'macos'; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfoPlugin.windowsInfo; deviceId = windowsInfo.deviceId; + // we only support x86_64 on Windows + architecture = 'x86_64'; + os = 'windows'; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfoPlugin.linuxInfo; deviceId = linuxInfo.machineId; + // we only support x86_64 on Linux + architecture = 'x86_64'; + os = 'linux'; } else { deviceId = null; + architecture = null; + os = null; } } catch (e) { Log.error('Failed to get platform version, $e'); } ApplicationInfo.deviceId = deviceId ?? ''; + ApplicationInfo.architecture = architecture ?? ''; + ApplicationInfo.os = os ?? ''; } @override diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index b326276c56..e64e0f98de 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -51,7 +51,6 @@ GoRouter generateRouter(Widget child) { // Routes in both desktop and mobile _signInScreenRoute(), _skipLogInScreenRoute(), - _encryptSecretScreenRoute(), _workspaceErrorScreenRoute(), // Desktop only if (UniversalPlatform.isDesktop) _desktopHomeScreenRoute(), @@ -120,18 +119,6 @@ GoRouter generateRouter(Widget child) { ); }, ), - GoRoute( - path: SignUpScreen.routeName, - pageBuilder: (context, state) { - return CustomTransitionPage( - child: SignUpScreen( - router: getIt(), - ), - transitionsBuilder: _buildFadeTransition, - transitionDuration: _slowDuration, - ); - }, - ), ], ); } @@ -471,23 +458,6 @@ GoRoute _workspaceErrorScreenRoute() { ); } -GoRoute _encryptSecretScreenRoute() { - return GoRoute( - path: EncryptSecretScreen.routeName, - pageBuilder: (context, state) { - final args = state.extra as Map; - return CustomTransitionPage( - child: EncryptSecretScreen( - user: args[EncryptSecretScreen.argUser], - key: args[EncryptSecretScreen.argKey], - ), - transitionsBuilder: _buildFadeTransition, - transitionDuration: _slowDuration, - ); - }, - ); -} - GoRoute _skipLogInScreenRoute() { return GoRoute( path: SkipLogInScreen.routeName, diff --git a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart index 4be5f0f6f7..9e8f9df49a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart @@ -1,7 +1,9 @@ export 'app_widget.dart'; export 'appflowy_cloud_task.dart'; +export 'auto_update_task.dart'; export 'debug_task.dart'; export 'device_info_task.dart'; +export 'feature_flag_task.dart'; export 'generate_router.dart'; export 'hot_key.dart'; export 'load_plugin.dart'; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index 58d6aacbc3..c406dd161a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -5,9 +5,8 @@ import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; -import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; import '../startup.dart'; @@ -29,7 +28,6 @@ class InitRustSDKTask extends LaunchTask { final dir = customApplicationPath ?? applicationPath; final deviceId = await getDeviceId(); - debugPrint('application path: ${applicationPath.path}'); // Pass the environment variables to the Rust SDK final env = _makeAppFlowyConfiguration( root.path, @@ -75,10 +73,10 @@ Future appFlowyApplicationDataDirectory() async { case IntegrationMode.develop: final Directory documentsDir = await getApplicationSupportDirectory() .then((directory) => directory.create()); - return Directory(path.join(documentsDir.path, 'data_dev')).create(); + return Directory(path.join(documentsDir.path, 'data_dev')); case IntegrationMode.release: final Directory documentsDir = await getApplicationSupportDirectory(); - return Directory(path.join(documentsDir.path, 'data')).create(); + return Directory(path.join(documentsDir.path, 'data')); case IntegrationMode.unitTest: case IntegrationMode.integrationTest: return Directory(path.join(Directory.current.path, '.sandbox')); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 149bddc951..4f4cece9bb 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -18,7 +18,7 @@ class AppFlowyCloudAuthService implements AuthService { AppFlowyCloudAuthService(); final BackendAuthService _backendAuthService = BackendAuthService( - AuthenticatorPB.AppFlowyCloud, + AuthTypePB.Server, ); @override @@ -32,12 +32,17 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { - throw UnimplementedError(); + return _backendAuthService.signInWithEmailPassword( + email: email, + password: password, + params: params, + ); } @override @@ -106,6 +111,17 @@ class AppFlowyCloudAuthService implements AuthService { ); } + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return _backendAuthService.signInWithPasscode( + email: email, + passcode: passcode, + ); + } + @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart index 5f8ea7cac6..8be71dc648 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart @@ -20,7 +20,7 @@ class AppFlowyCloudMockAuthService implements AuthService { final String userEmail; final BackendAuthService _appFlowyAuthService = - BackendAuthService(AuthenticatorPB.AppFlowyCloud); + BackendAuthService(AuthTypePB.Server); @override Future> signUp({ @@ -33,7 +33,8 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -47,7 +48,7 @@ class AppFlowyCloudMockAuthService implements AuthService { Map params = const {}, }) async { final payload = SignInUrlPayloadPB.create() - ..authenticator = AuthenticatorPB.AppFlowyCloud + ..authenticator = AuthTypePB.Server // don't use nanoid here, the gotrue server will transform the email ..email = userEmail; @@ -57,7 +58,7 @@ class AppFlowyCloudMockAuthService implements AuthService { return getSignInURLResult.fold( (urlPB) async { final payload = OauthSignInPB( - authenticator: AuthenticatorPB.AppFlowyCloud, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: urlPB.signInUrl, AuthServiceMapKeys.deviceId: deviceId, @@ -106,4 +107,12 @@ class AppFlowyCloudMockAuthService implements AuthService { Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + throw UnimplementedError(); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart index 90c6954afe..9879b9a18e 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart @@ -1,5 +1,5 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class AuthServiceMapKeys { @@ -23,7 +23,8 @@ abstract class AuthService { /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params, @@ -75,6 +76,17 @@ abstract class AuthService { Map params, }); + /// Authenticates a user with a passcode sent to their email. + /// + /// - `email`: The email address of the user. + /// - `passcode`: The passcode of the user. + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signInWithPasscode({ + required String email, + required String passcode, + }); + /// Signs out the currently authenticated user. Future signOut(); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index 9147fb4fb9..cab8cd170c 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -6,9 +6,9 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.dart'; import '../../../generated/locale_keys.g.dart'; import 'device_id.dart'; @@ -16,10 +16,11 @@ import 'device_id.dart'; class BackendAuthService implements AuthService { BackendAuthService(this.authType); - final AuthenticatorPB authType; + final AuthTypePB authType; @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -29,8 +30,7 @@ class BackendAuthService implements AuthService { ..password = password ..authType = authType ..deviceId = await getDeviceId(); - final response = UserEventSignInWithEmailPassword(request).send(); - return response.then((value) => value); + return UserEventSignInWithEmailPassword(request).send(); } @override @@ -65,15 +65,14 @@ class BackendAuthService implements AuthService { Map params = const {}, }) async { const password = "Guest!@123456"; - final uid = uuid(); - final userEmail = "$uid@appflowy.io"; + final userEmail = "anon@appflowy.io"; final request = SignUpPayloadPB.create() ..name = LocaleKeys.defaultUsername.tr() ..email = userEmail ..password = password // When sign up as guest, the auth type is always local. - ..authType = AuthenticatorPB.Local + ..authType = AuthTypePB.Local ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( (value) => value, @@ -84,7 +83,7 @@ class BackendAuthService implements AuthService { @override Future> signUpWithOAuth({ required String platform, - AuthenticatorPB authType = AuthenticatorPB.Local, + AuthTypePB authType = AuthTypePB.Local, Map params = const {}, }) async { return FlowyResult.failure( @@ -107,4 +106,12 @@ class BackendAuthService implements AuthService { // No need to pass the redirect URL. return UserBackendService.signInWithMagicLink(email, ''); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return UserBackendService.signInWithPasscode(email, passcode); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart deleted file mode 100644 index 19b8101ae8..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:appflowy/plugins/database/application/defines.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'auth/auth_service.dart'; - -part 'encrypt_secret_bloc.freezed.dart'; - -class EncryptSecretBloc extends Bloc { - EncryptSecretBloc({required this.user}) - : super(EncryptSecretState.initial()) { - _dispatch(); - } - - final UserProfilePB user; - - void _dispatch() { - on((event, emit) async { - await event.when( - setEncryptSecret: (secret) async { - if (isLoading()) { - return; - } - - final payload = UserSecretPB.create() - ..encryptionSecret = secret - ..encryptionSign = user.encryptionSign - ..encryptionType = user.encryptionType - ..userId = user.id; - final result = await UserEventSetEncryptionSecret(payload).send(); - if (!isClosed) { - add(EncryptSecretEvent.didFinishCheck(result)); - } - emit( - state.copyWith( - loadingState: const LoadingState.loading(), - successOrFail: null, - ), - ); - }, - cancelInputSecret: () async { - await getIt().signOut(); - emit( - state.copyWith( - successOrFail: null, - isSignOut: true, - ), - ); - }, - didFinishCheck: (result) { - result.fold( - (unit) { - emit( - state.copyWith( - loadingState: const LoadingState.loading(), - successOrFail: result, - ), - ); - }, - (err) { - emit( - state.copyWith( - loadingState: LoadingState.finish(FlowyResult.failure(err)), - successOrFail: result, - ), - ); - }, - ); - }, - ); - }); - } - - bool isLoading() { - final loadingState = state.loadingState; - if (loadingState != null) { - return loadingState.when( - loading: () => true, - finish: (_) => false, - idle: () => false, - ); - } - return false; - } -} - -@freezed -class EncryptSecretEvent with _$EncryptSecretEvent { - const factory EncryptSecretEvent.setEncryptSecret(String secret) = - _SetEncryptSecret; - const factory EncryptSecretEvent.didFinishCheck( - FlowyResult result, - ) = _DidFinishCheck; - const factory EncryptSecretEvent.cancelInputSecret() = _CancelInputSecret; -} - -@freezed -class EncryptSecretState with _$EncryptSecretState { - const factory EncryptSecretState({ - required FlowyResult? successOrFail, - required bool isSignOut, - LoadingState? loadingState, - }) = _EncryptSecretState; - - factory EncryptSecretState.initial() => const EncryptSecretState( - successOrFail: null, - isSignOut: false, - ); -} diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart new file mode 100644 index 0000000000..b85efe38ae --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/user/application/password/password_http_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'password_bloc.freezed.dart'; + +class PasswordBloc extends Bloc { + PasswordBloc(this.userProfile) : super(PasswordState.initial()) { + on( + (event, emit) async { + await event.when( + init: () async => _init(), + changePassword: (oldPassword, newPassword) async => _onChangePassword( + emit, + oldPassword: oldPassword, + newPassword: newPassword, + ), + setupPassword: (newPassword) async => _onSetupPassword( + emit, + newPassword: newPassword, + ), + forgotPassword: (email) async => _onForgotPassword( + emit, + email: email, + ), + checkHasPassword: () async => _onCheckHasPassword( + emit, + ), + cancel: () {}, + ); + }, + ); + } + + final UserProfilePB userProfile; + late final PasswordHttpService passwordHttpService; + + bool _isInitialized = false; + + Future _init() async { + if (userProfile.authType == AuthTypePB.Local) { + Log.debug('PasswordBloc: skip init because user is local authenticator'); + return; + } + + final baseUrl = await getAppFlowyCloudUrl(); + try { + final authToken = jsonDecode(userProfile.token)['access_token']; + passwordHttpService = PasswordHttpService( + baseUrl: baseUrl, + authToken: authToken, + ); + _isInitialized = true; + } catch (e) { + Log.error('PasswordBloc: _init: error: $e'); + } + } + + Future _onChangePassword( + Emitter emit, { + required String oldPassword, + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('changePassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('changePassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.changePassword( + currentPassword: oldPassword, + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + changePasswordResult: result, + ), + ); + } + + Future _onSetupPassword( + Emitter emit, { + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('setupPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('setupPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.setupPassword( + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => true, + (error) => false, + ), + setupPasswordResult: result, + ), + ); + } + + Future _onForgotPassword( + Emitter emit, { + required String email, + }) async { + if (!_isInitialized) { + Log.info('forgotPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('forgotPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.forgotPassword(email: email); + + emit( + state.copyWith( + isSubmitting: false, + forgotPasswordResult: result, + ), + ); + } + + Future _onCheckHasPassword(Emitter emit) async { + if (!_isInitialized) { + Log.info('checkHasPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('checkHasPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.checkHasPassword(); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => success, + (error) => false, + ), + checkHasPasswordResult: result, + ), + ); + } + + void _clearState(Emitter emit, bool isSubmitting) { + emit( + state.copyWith( + isSubmitting: isSubmitting, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ), + ); + } +} + +@freezed +class PasswordEvent with _$PasswordEvent { + const factory PasswordEvent.init() = Init; + + // Change password + const factory PasswordEvent.changePassword({ + required String oldPassword, + required String newPassword, + }) = ChangePassword; + + // Setup password + const factory PasswordEvent.setupPassword({ + required String newPassword, + }) = SetupPassword; + + // Forgot password + const factory PasswordEvent.forgotPassword({ + required String email, + }) = ForgotPassword; + + // Check has password + const factory PasswordEvent.checkHasPassword() = CheckHasPassword; + + // Cancel operation + const factory PasswordEvent.cancel() = Cancel; +} + +@freezed +class PasswordState with _$PasswordState { + const factory PasswordState({ + required bool isSubmitting, + required bool hasPassword, + required FlowyResult? changePasswordResult, + required FlowyResult? setupPasswordResult, + required FlowyResult? forgotPasswordResult, + required FlowyResult? checkHasPasswordResult, + }) = _PasswordState; + + factory PasswordState.initial() => const PasswordState( + isSubmitting: false, + hasPassword: false, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart new file mode 100644 index 0000000000..723ded57e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:http/http.dart' as http; + +enum PasswordEndpoint { + changePassword, + forgotPassword, + setupPassword, + checkHasPassword; + + String get path { + switch (this) { + case PasswordEndpoint.changePassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.forgotPassword: + return '/gotrue/user/recover'; + case PasswordEndpoint.setupPassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.checkHasPassword: + return '/gotrue/user/auth-info'; + } + } + + String get method { + switch (this) { + case PasswordEndpoint.changePassword: + case PasswordEndpoint.setupPassword: + case PasswordEndpoint.forgotPassword: + return 'POST'; + case PasswordEndpoint.checkHasPassword: + return 'GET'; + } + } + + Uri uri(String baseUrl) => Uri.parse('$baseUrl$path'); +} + +class PasswordHttpService { + PasswordHttpService({ + required this.baseUrl, + required this.authToken, + }); + + final String baseUrl; + final String authToken; + + final http.Client client = http.Client(); + + Map get headers => { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }; + + /// Changes the user's password + /// + /// [currentPassword] - The user's current password + /// [newPassword] - The new password to set + Future> changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.changePassword, + body: { + 'current_password': currentPassword, + 'password': newPassword, + }, + errorMessage: 'Failed to change password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sends a password reset email to the user + /// + /// [email] - The email address of the user + Future> forgotPassword({ + required String email, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.forgotPassword, + body: {'email': email}, + errorMessage: 'Failed to send password reset email', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sets up a password for a user that doesn't have one + /// + /// [newPassword] - The new password to set + Future> setupPassword({ + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.setupPassword, + body: {'password': newPassword}, + errorMessage: 'Failed to setup password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Checks if the user has a password set + Future> checkHasPassword() async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.checkHasPassword, + errorMessage: 'Failed to check password status', + ); + + return result.fold( + (data) => FlowyResult.success(data['has_password'] ?? false), + (error) => FlowyResult.failure(error), + ); + } + + /// Makes a request to the specified endpoint with the given body + Future> _makeRequest({ + required PasswordEndpoint endpoint, + Map? body, + String errorMessage = 'Request failed', + }) async { + try { + final uri = endpoint.uri(baseUrl); + http.Response response; + + if (endpoint.method == 'POST') { + response = await client.post( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + } else if (endpoint.method == 'GET') { + response = await client.get( + uri, + headers: headers, + ); + } else { + return FlowyResult.failure( + FlowyError(msg: 'Invalid request method: ${endpoint.method}'), + ); + } + + if (response.statusCode == 200) { + if (response.body.isNotEmpty) { + return FlowyResult.success(jsonDecode(response.body)); + } + return FlowyResult.success(true); + } else { + final errorBody = + response.body.isNotEmpty ? jsonDecode(response.body) : {}; + + Log.info( + '${endpoint.name} request failed: ${response.statusCode}, $errorBody ', + ); + + return FlowyResult.failure( + FlowyError( + msg: errorBody['msg'] ?? errorMessage, + ), + ); + } + } catch (e) { + Log.error('${endpoint.name} request failed: error: $e'); + + return FlowyResult.failure( + FlowyError(msg: 'Network error: ${e.toString()}'), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 6fda156567..9691a1269b 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -30,12 +30,26 @@ class SignInBloc extends Bloc { on( (event, emit) async { await event.when( - signedInWithUserEmailAndPassword: () async => _onSignIn(emit), - signedInWithOAuth: (platform) async => - _onSignInWithOAuth(emit, platform), - signedInAsGuest: () async => _onSignInAsGuest(emit), - signedWithMagicLink: (email) async => - _onSignInWithMagicLink(emit, email), + signInWithEmailAndPassword: (email, password) async => + _onSignInWithEmailAndPassword( + emit, + email: email, + password: password, + ), + signInWithOAuth: (platform) async => _onSignInWithOAuth( + emit, + platform: platform, + ), + signInAsGuest: () async => _onSignInAsGuest(emit), + signInWithMagicLink: (email) async => _onSignInWithMagicLink( + emit, + email: email, + ), + signInWithPasscode: (email, passcode) async => _onSignInWithPasscode( + emit, + email: email, + passcode: passcode, + ), deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result), cancel: () { emit( @@ -119,26 +133,34 @@ class SignInBloc extends Bloc { } } - Future _onSignIn(Emitter emit) async { + Future _onSignInWithEmailAndPassword( + Emitter emit, { + required String email, + required String password, + }) async { final result = await authService.signInWithEmailPassword( - email: state.email ?? '', - password: state.password ?? '', + email: email, + password: password, ); emit( result.fold( - (userProfile) => state.copyWith( - isSubmitting: false, - successOrFail: FlowyResult.success(userProfile), - ), + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, (error) => _stateFromCode(error), ), ); } Future _onSignInWithOAuth( - Emitter emit, - String platform, - ) async { + Emitter emit, { + required String platform, + }) async { emit( state.copyWith( isSubmitting: true, @@ -161,9 +183,16 @@ class SignInBloc extends Bloc { } Future _onSignInWithMagicLink( - Emitter emit, - String email, - ) async { + Emitter emit, { + required String email, + }) async { + if (state.isSubmitting) { + Log.error('Sign in with magic link is already in progress'); + return; + } + + Log.info('Sign in with magic link: $email'); + emit( state.copyWith( isSubmitting: true, @@ -177,7 +206,50 @@ class SignInBloc extends Bloc { emit( result.fold( - (userProfile) => state.copyWith(isSubmitting: true), + (userProfile) => state.copyWith( + isSubmitting: false, + ), + (error) => _stateFromCode(error), + ), + ); + } + + Future _onSignInWithPasscode( + Emitter emit, { + required String email, + required String passcode, + }) async { + if (state.isSubmitting) { + Log.error('Sign in with passcode is already in progress'); + return; + } + + Log.info('Sign in with passcode: $email, $passcode'); + + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signInWithPasscode( + email: email, + passcode: passcode, + ); + + emit( + result.fold( + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, (error) => _stateFromCode(error), ), ); @@ -224,10 +296,20 @@ class SignInBloc extends Bloc { emailError: null, ); case ErrorCode.UserUnauthorized: + final errorMsg = error.msg; + String msg = LocaleKeys.signIn_generalError.tr(); + if (errorMsg.contains('rate limit') || + errorMsg.contains('For security purposes')) { + msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr(); + } else if (errorMsg.contains('invalid')) { + msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); + } else if (errorMsg.contains('Invalid login credentials')) { + msg = LocaleKeys.signIn_invalidLoginCredentials.tr(); + } return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure( - FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()), + FlowyError(msg: msg), ), ); default: @@ -243,19 +325,35 @@ class SignInBloc extends Bloc { @freezed class SignInEvent with _$SignInEvent { - const factory SignInEvent.signedInWithUserEmailAndPassword() = - SignedInWithUserEmailAndPassword; - const factory SignInEvent.signedInWithOAuth(String platform) = - SignedInWithOAuth; - const factory SignInEvent.signedInAsGuest() = SignedInAsGuest; - const factory SignInEvent.signedWithMagicLink(String email) = - SignedWithMagicLink; - const factory SignInEvent.emailChanged(String email) = EmailChanged; - const factory SignInEvent.passwordChanged(String password) = PasswordChanged; + // Sign in methods + const factory SignInEvent.signInWithEmailAndPassword({ + required String email, + required String password, + }) = SignInWithEmailAndPassword; + const factory SignInEvent.signInWithOAuth({ + required String platform, + }) = SignInWithOAuth; + const factory SignInEvent.signInAsGuest() = SignInAsGuest; + const factory SignInEvent.signInWithMagicLink({ + required String email, + }) = SignInWithMagicLink; + const factory SignInEvent.signInWithPasscode({ + required String email, + required String passcode, + }) = SignInWithPasscode; + + // Event handlers + const factory SignInEvent.emailChanged({ + required String email, + }) = EmailChanged; + const factory SignInEvent.passwordChanged({ + required String password, + }) = PasswordChanged; const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) = DeepLinkStateChange; - const factory SignInEvent.cancel() = _Cancel; - const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType; + + const factory SignInEvent.cancel() = Cancel; + const factory SignInEvent.switchLoginType(LoginType type) = SwitchLoginType; } // we support sign in directly without sign up, but we want to allow the users to sign up if they want to diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart index 36d6039d40..d3ebe0201b 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -24,7 +24,7 @@ typedef DidUpdateUserWorkspacesCallback = void Function( ); typedef UserProfileNotifyValue = FlowyResult; typedef DidUpdateUserWorkspaceSetting = void Function( - UseAISettingPB settings, + WorkspaceSettingsPB settings, ); class UserListener { @@ -101,10 +101,10 @@ class UserListener { result.map( (r) => onUserWorkspaceUpdated?.call(UserWorkspacePB.fromBuffer(r)), ); - case user.UserNotification.DidUpdateAISetting: + case user.UserNotification.DidUpdateWorkspaceSetting: result.map( - (r) => - onUserWorkspaceSettingUpdated?.call(UseAISettingPB.fromBuffer(r)), + (r) => onUserWorkspaceSettingUpdated + ?.call(WorkspaceSettingsPB.fromBuffer(r)), ); break; default: @@ -113,22 +113,21 @@ class UserListener { } } -typedef WorkspaceSettingNotifyValue - = FlowyResult; +typedef WorkspaceLatestNotifyValue = FlowyResult; class FolderListener { FolderListener(); - final PublishNotifier _settingChangedNotifier = + final PublishNotifier _latestChangedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ - void Function(WorkspaceSettingNotifyValue)? onSettingUpdated, + void Function(WorkspaceLatestNotifyValue)? onLatestUpdated, }) { - if (onSettingUpdated != null) { - _settingChangedNotifier.addPublishListener(onSettingUpdated); + if (onLatestUpdated != null) { + _latestChangedNotifier.addPublishListener(onLatestUpdated); } // The "current-workspace" is predefined in the backend. Do not try to @@ -146,9 +145,9 @@ class FolderListener { switch (ty) { case FolderNotification.DidUpdateWorkspaceSetting: result.fold( - (payload) => _settingChangedNotifier.value = - FlowyResult.success(WorkspaceSettingPB.fromBuffer(payload)), - (error) => _settingChangedNotifier.value = FlowyResult.failure(error), + (payload) => _latestChangedNotifier.value = + FlowyResult.success(WorkspaceLatestPB.fromBuffer(payload)), + (error) => _latestChangedNotifier.value = FlowyResult.failure(error), ); break; default: @@ -158,6 +157,6 @@ class FolderListener { Future stop() async { await _listener?.stop(); - _settingChangedNotifier.dispose(); + _latestChangedNotifier.dispose(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 5a75a4df3e..3ec181e009 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -40,8 +40,6 @@ class UserBackendService implements IUserBackendService { String? password, String? email, String? iconUrl, - String? openAIKey, - String? stabilityAiKey, }) { final payload = UpdateUserProfilePayloadPB.create()..id = userId; @@ -61,14 +59,6 @@ class UserBackendService implements IUserBackendService { payload.iconUrl = iconUrl; } - if (openAIKey != null) { - payload.openaiKey = openAIKey; - } - - if (stabilityAiKey != null) { - payload.stabilityAiKey = stabilityAiKey; - } - return UserEventUpdateUserProfile(payload).send(); } @@ -86,6 +76,26 @@ class UserBackendService implements IUserBackendService { return UserEventMagicLinkSignIn(payload).send(); } + static Future> + signInWithPasscode( + String email, + String passcode, + ) async { + final payload = PasscodeSignInPB(email: email, passcode: passcode); + return UserEventPasscodeSignIn(payload).send(); + } + + Future> signInWithPassword( + String email, + String password, + ) { + final payload = SignInPayloadPB( + email: email, + password: password, + ); + return UserEventSignInWithEmailPassword(payload).send(); + } + static Future> signOut() { return UserEventSignOut().send(); } @@ -111,8 +121,13 @@ class UserBackendService implements IUserBackendService { }); } - Future> openWorkspace(String workspaceId) { - final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + Future> openWorkspace( + String workspaceId, + AuthTypePB authType, + ) { + final payload = OpenUserWorkspacePB() + ..workspaceId = workspaceId + ..authType = authType; return UserEventOpenWorkspace(payload).send(); } @@ -125,25 +140,13 @@ class UserBackendService implements IUserBackendService { }); } - Future> createWorkspace( - String name, - String desc, - ) { - final request = CreateWorkspacePayloadPB.create() - ..name = name - ..desc = desc; - return FolderEventCreateFolderWorkspace(request).send().then((result) { - return result.fold( - (workspace) => FlowyResult.success(workspace), - (error) => FlowyResult.failure(error), - ); - }); - } - Future> createUserWorkspace( String name, + AuthTypePB authType, ) { - final request = CreateWorkspacePB.create()..name = name; + final request = CreateWorkspacePB.create() + ..name = name + ..authType = authType; return UserEventCreateWorkspace(request).send(); } diff --git a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart index ce51fdd10b..7ff50dbd02 100644 --- a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart @@ -1,9 +1,7 @@ import 'package:appflowy/plugins/database/application/defines.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -22,20 +20,10 @@ class WorkspaceErrorBloc void _dispatch() { on( (event, emit) async { - await event.when( + event.when( init: () { // _loadSnapshots(); }, - resetWorkspace: () async { - emit(state.copyWith(loadingState: const LoadingState.loading())); - final payload = ResetWorkspacePB.create() - ..workspaceId = userFolder.workspaceId - ..uid = userFolder.uid; - final result = await UserEventResetWorkspace(payload).send(); - if (!isClosed) { - add(WorkspaceErrorEvent.didResetWorkspace(result)); - } - }, didResetWorkspace: (result) { result.fold( (_) { @@ -68,7 +56,6 @@ class WorkspaceErrorBloc class WorkspaceErrorEvent with _$WorkspaceErrorEvent { const factory WorkspaceErrorEvent.init() = _Init; const factory WorkspaceErrorEvent.logout() = _DidLogout; - const factory WorkspaceErrorEvent.resetWorkspace() = _ResetWorkspace; const factory WorkspaceErrorEvent.didResetWorkspace( FlowyResult result, ) = _DidResetWorkspace; diff --git a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart index a9b11cb42e..c8744fb304 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart @@ -74,9 +74,8 @@ class AnonUserItem extends StatelessWidget { @override Widget build(BuildContext context) { final icon = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; - final isDisabled = - isSelected || user.authenticator != AuthenticatorPB.Local; - final desc = "${user.name}\t ${user.authenticator}\t"; + final isDisabled = isSelected || user.authType != AuthTypePB.Local; + final desc = "${user.name}\t ${user.authType}\t"; final child = SizedBox( height: 30, child: FlowyButton( diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 0aeb92fc18..ccad6c0a26 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -15,16 +15,14 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { getIt().pushWorkspaceErrorScreen(context, userFolder, error); break; case ErrorCode.InvalidEncryptSecret: - case ErrorCode.HttpError: + case ErrorCode.NetworkError: showToastNotification( - context, message: error.msg, type: ToastificationType.error, ); break; default: showToastNotification( - context, message: error.msg, type: ToastificationType.error, callbacks: ToastificationCallbacks( diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart deleted file mode 100644 index 9abd417df3..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:appflowy/user/presentation/helpers/helpers.dart'; -import 'package:appflowy/user/presentation/presentation.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/material.dart'; - -void handleUserProfileResult( - FlowyResult userProfileResult, - BuildContext context, - AuthRouter authRouter, -) { - userProfileResult.fold( - (userProfile) { - if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { - authRouter.pushEncryptionScreen(context, userProfile); - } else { - authRouter.goHomeScreen(context, userProfile); - } - }, - (error) { - handleOpenWorkspaceError(context, error); - }, - ); -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart index 084a360666..11f321232e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart @@ -1,2 +1 @@ export 'handle_open_workspace_error.dart'; -export 'handle_user_profile_result.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart index 370d9c2062..339c2f29f7 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/router.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -21,10 +21,6 @@ class AuthRouter { getIt().pushWorkspaceStartScreen(context, userProfile); } - void pushSignUpScreen(BuildContext context) { - context.push(SignUpScreen.routeName); - } - /// Navigates to the home screen based on the current workspace and platform. /// /// This function takes in a [BuildContext] and a [UserProfilePB] object to @@ -61,20 +57,6 @@ class AuthRouter { ); } - void pushEncryptionScreen( - BuildContext context, - UserProfilePB userProfile, - ) { - // After log in,push EncryptionScreen on the top SignInScreen - context.push( - EncryptSecretScreen.routeName, - extra: { - EncryptSecretScreen.argUser: userProfile, - EncryptSecretScreen.argKey: ValueKey(userProfile.id), - }, - ); - } - Future pushWorkspaceErrorScreen( BuildContext context, UserFolderPB userFolder, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart deleted file mode 100644 index f0b79ed9d2..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/presentation/helpers/helpers.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../application/encrypt_secret_bloc.dart'; - -class EncryptSecretScreen extends StatefulWidget { - const EncryptSecretScreen({required this.user, super.key}); - - final UserProfilePB user; - - static const routeName = '/EncryptSecretScreen'; - - // arguments used in GoRouter - static const argUser = 'user'; - static const argKey = 'key'; - - @override - State createState() => _EncryptSecretScreenState(); -} - -class _EncryptSecretScreenState extends State { - final TextEditingController _textEditingController = TextEditingController(); - - @override - void dispose() { - _textEditingController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: BlocProvider( - create: (context) => EncryptSecretBloc(user: widget.user), - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (previous, current) => - previous.isSignOut != current.isSignOut, - listener: (context, state) async { - if (state.isSignOut) { - await runAppFlowy(); - } - }, - ), - BlocListener( - listenWhen: (previous, current) => - previous.successOrFail != current.successOrFail, - listener: (context, state) async { - await state.successOrFail?.fold( - (unit) async { - await runAppFlowy(); - }, - (error) { - handleOpenWorkspaceError(context, error); - }, - ); - }, - ), - ], - child: BlocBuilder( - builder: (context, state) { - final indicator = state.loadingState?.when( - loading: () => const Center( - child: CircularProgressIndicator.adaptive(), - ), - finish: (result) => const SizedBox.shrink(), - idle: () => const SizedBox.shrink(), - ) ?? - const SizedBox.shrink(); - return Center( - child: SizedBox( - width: 300, - height: 160, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Opacity( - opacity: 0.6, - child: FlowyText.medium( - "${LocaleKeys.settings_menu_inputEncryptPrompt.tr()} ${widget.user.email}", - fontSize: 14, - maxLines: 10, - ), - ), - const VSpace(6), - SizedBox( - width: 300, - child: FlowyTextField( - controller: _textEditingController, - hintText: - LocaleKeys.settings_menu_inputTextFieldHint.tr(), - onChanged: (_) {}, - ), - ), - OkCancelButton( - alignment: MainAxisAlignment.end, - onOkPressed: () => - context.read().add( - EncryptSecretEvent.setEncryptSecret( - _textEditingController.text, - ), - ), - onCancelPressed: () => context - .read() - .add(const EncryptSecretEvent.cancelInputSecret()), - mode: TextButtonMode.normal, - ), - const VSpace(6), - indicator, - ], - ), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart index 088da38978..2aeba87995 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart @@ -1,7 +1,5 @@ export 'sign_in_screen/sign_in_screen.dart'; export 'skip_log_in_screen.dart'; export 'splash_screen.dart'; -export 'sign_up_screen.dart'; -export 'encrypt_secret_screen.dart'; export 'workspace_error_screen.dart'; export 'workspace_start_screen/workspace_start_screen.dart'; 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 94b4347869..40901e92e1 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 @@ -1,11 +1,14 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/settings/show_settings.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -19,9 +22,11 @@ class DesktopSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const indicatorMinHeight = 4.0; + final theme = AppFlowyTheme.of(context); + return BlocBuilder( builder: (context, state) { + final bottomPadding = UniversalPlatform.isDesktop ? 20.0 : 24.0; return Scaffold( appBar: _buildAppBar(), body: Center( @@ -29,39 +34,31 @@ class DesktopSignInScreen extends StatelessWidget { children: [ const Spacer(), - const VSpace(20), - // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), - logoSize: const Size(60, 60), + logoSize: Size.square(36), ), - const VSpace(20), + VSpace(theme.spacing.xxl), - // magic link sign in - const SignInWithMagicLinkButtons(), - const VSpace(20), + // continue with email and password + isLocalAuthEnabled + ? const SignInAnonymousButtonV3() + : const ContinueWithEmailAndPassword(), + + VSpace(theme.spacing.xxl), // third-party sign in. if (isAuthEnabled) ...[ const _OrDivider(), - const VSpace(20), + VSpace(theme.spacing.xxl), const ThirdPartySignInButtons(), - const VSpace(20), + VSpace(theme.spacing.xxl), ], // sign in agreement const SignInAgreement(), - // loading status - const VSpace(indicatorMinHeight), - state.isSubmitting - ? const LinearProgressIndicator( - minHeight: indicatorMinHeight, - ) - : const VSpace(indicatorMinHeight), - const VSpace(20), - const Spacer(), // anonymous sign in and settings @@ -69,11 +66,11 @@ class DesktopSignInScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ DesktopSignInSettingsButton(), - HSpace(42), + HSpace(20), SignInAnonymousButtonV2(), ], ), - const VSpace(16), + VSpace(bottomPadding), ], ), ), @@ -99,18 +96,24 @@ class DesktopSignInSettingsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - // fontWeight: FontWeight.w500, - color: Colors.grey, - decoration: TextDecoration.underline, + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), - onTap: () { - showSimpleSettingsDialog(context); + onTap: () => showSimpleSettingsDialog(context), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); }, ); } @@ -121,14 +124,30 @@ class _OrDivider extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Row( children: [ - const Flexible(child: Divider(thickness: 1)), + Flexible( + child: Divider( + thickness: 1, + color: theme.borderColorScheme.greyTertiary, + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + child: Text( + LocaleKeys.signIn_or.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), + ), + Flexible( + child: Divider( + thickness: 1, + color: theme.borderColorScheme.greyTertiary, + ), ), - const Flexible(child: Divider(thickness: 1)), ], ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 863aadc49c..9eb7d5a965 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -5,7 +5,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/user/presentation/widgets/flowy_logo_title.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -19,32 +22,29 @@ class MobileSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const double spacing = 16; - final colorScheme = Theme.of(context).colorScheme; return BlocBuilder( builder: (context, state) { + final theme = AppFlowyTheme.of(context); return Scaffold( resizeToAvoidBottomInset: false, body: Padding( padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ - const Spacer(flex: 4), - _buildLogo(), - const VSpace(spacing), - _buildAppNameText(colorScheme), - const VSpace(spacing * 2), - const SignInWithMagicLinkButtons(), - const VSpace(spacing), - if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), - const VSpace(spacing * 1.5), - const SignInAgreement(), - const VSpace(spacing), - if (!isAuthEnabled) const Spacer(flex: 2), - const Spacer(flex: 2), const Spacer(), - Expanded(child: _buildSettingsButton(context)), - if (Platform.isAndroid) const Spacer(), + FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()), + VSpace(theme.spacing.xxl), + isLocalAuthEnabled + ? const SignInAnonymousButtonV3() + : const ContinueWithEmailAndPassword(), + VSpace(theme.spacing.xxl), + if (isAuthEnabled) ...[ + _buildThirdPartySignInButtons(context), + VSpace(theme.spacing.xxl), + ], + const SignInAgreement(), + const Spacer(), + _buildSettingsButton(context), ], ), ), @@ -53,25 +53,8 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildLogo() { - return const FlowySvg( - FlowySvgs.flowy_logo_xl, - size: Size.square(56), - blendMode: null, - ); - } - - Widget _buildAppNameText(ColorScheme colorScheme) { - return FlowyText( - LocaleKeys.appName.tr(), - textAlign: TextAlign.center, - fontSize: 28, - color: const Color(0xFF00BCF0), - fontWeight: FontWeight.w700, - ); - } - - Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) { + Widget _buildThirdPartySignInButtons(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ Row( @@ -80,10 +63,12 @@ class MobileSignInScreen extends StatelessWidget { const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: FlowyText( + child: Text( LocaleKeys.signIn_or.tr(), - fontSize: 12, - color: colorScheme.onSecondary, + style: TextStyle( + fontSize: 16, + color: theme.textColorScheme.secondary, + ), ), ), const Expanded(child: Divider()), @@ -100,25 +85,34 @@ class MobileSignInScreen extends StatelessWidget { } Widget _buildSettingsButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( mainAxisSize: MainAxisSize.min, children: [ - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - // fontWeight: FontWeight.w500, - color: Colors.grey, - decoration: TextDecoration.underline, + AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), - onTap: () { - context.push(MobileLaunchSettingsPage.routeName); + onTap: () => context.push(MobileLaunchSettingsPage.routeName), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); }, ), const HSpace(24), - const SignInAnonymousButtonV2(), + isLocalAuthEnabled + ? const ChangeCloudModeButton() + : const SignInAnonymousButtonV2(), ], ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index 5b99ad83f3..b359b2e217 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -2,14 +2,12 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; -import '../../helpers/helpers.dart'; - class SignInScreen extends StatelessWidget { const SignInScreen({super.key}); @@ -22,13 +20,9 @@ class SignInScreen extends StatelessWidget { child: BlocConsumer( listener: _showSignInError, builder: (context, state) { - final isLoading = context.read().state.isSubmitting; - if (UniversalPlatform.isMobile) { - return isLoading - ? const MobileLoadingScreen() - : const MobileSignInScreen(); - } - return const DesktopSignInScreen(); + return UniversalPlatform.isDesktop + ? const DesktopSignInScreen() + : const MobileSignInScreen(); }, ), ); @@ -37,10 +31,13 @@ class SignInScreen extends StatelessWidget { void _showSignInError(BuildContext context, SignInState state) { final successOrFail = state.successOrFail; if (successOrFail != null) { - handleUserProfileResult( - successOrFail, - context, - getIt(), + successOrFail.fold( + (userProfile) { + getIt().goHomeScreen(context, userProfile); + }, + (error) { + Log.error('Sign in error: $error'); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart new file mode 100644 index 0000000000..a7a1b9722d --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/anon_user_bloc.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SignInAnonymousButtonV3 extends StatelessWidget { + const SignInAnonymousButtonV3({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, signInState) { + return BlocProvider( + create: (context) => AnonUserBloc() + ..add( + const AnonUserEvent.initial(), + ), + child: BlocListener( + listener: (context, state) async { + if (state.openedAnonUser != null) { + await runAppFlowy(); + } + }, + child: BlocBuilder( + builder: (context, state) { + final text = LocaleKeys.signIn_continueWithLocalModel.tr(); + final onTap = state.anonUsers.isEmpty + ? () { + context + .read() + .add(const SignInEvent.signInAsGuest()); + } + : () { + final bloc = context.read(); + final user = bloc.state.anonUsers.first; + bloc.add(AnonUserEvent.openAnonUser(user)); + }; + return AFFilledTextButton.primary( + text: text, + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart new file mode 100644 index 0000000000..351527137f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart @@ -0,0 +1,16 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AnonymousSignInButton extends StatelessWidget { + const AnonymousSignInButton({super.key}); + + @override + Widget build(BuildContext context) { + return AFGhostButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) { + return const Placeholder(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart new file mode 100644 index 0000000000..c4cf504ef5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart @@ -0,0 +1,23 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class ContinueWithEmail extends StatelessWidget { + const ContinueWithEmail({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFFilledTextButton.primary( + text: LocaleKeys.signIn_continueWithEmail.tr(), + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart new file mode 100644 index 0000000000..5027874418 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +class ContinueWithEmailAndPassword extends StatefulWidget { + const ContinueWithEmailAndPassword({super.key}); + + @override + State createState() => + _ContinueWithEmailAndPasswordState(); +} + +class _ContinueWithEmailAndPasswordState + extends State { + final controller = TextEditingController(); + final focusNode = FocusNode(); + final emailKey = GlobalKey(); + + bool _hasPushedContinueWithMagicLinkOrPasscodePage = false; + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + // only push the continue with magic link or passcode page if the magic link is sent successfully + if (successOrFail != null) { + successOrFail.fold( + (_) => emailKey.currentState?.clearError(), + (error) => emailKey.currentState?.syncError( + errorText: error.msg, + ), + ); + } else if (successOrFail == null && !state.isSubmitting) { + emailKey.currentState?.clearError(); + } + }, + child: Column( + children: [ + AFTextField( + key: emailKey, + controller: controller, + hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), + onSubmitted: (value) => _signInWithEmail( + context, + value, + ), + ), + VSpace(theme.spacing.l), + ContinueWithEmail( + onTap: () => _signInWithEmail( + context, + controller.text, + ), + ), + VSpace(theme.spacing.l), + ContinueWithPassword( + onTap: () { + final email = controller.text; + + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + _pushContinueWithPasswordPage( + context, + email, + ); + }, + ), + ], + ), + ); + } + + void _signInWithEmail(BuildContext context, String email) { + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); + + _pushContinueWithMagicLinkOrPasscodePage( + context, + email, + ); + } + + void _pushContinueWithMagicLinkOrPasscodePage( + BuildContext context, + String email, + ) { + if (_hasPushedContinueWithMagicLinkOrPasscodePage) { + return; + } + + final signInBloc = context.read(); + + // push the a continue with magic link or passcode screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithMagicLinkOrPasscodePage( + email: email, + backToLogin: () { + Navigator.pop(context); + + emailKey.currentState?.clearError(); + + _hasPushedContinueWithMagicLinkOrPasscodePage = false; + }, + onEnterPasscode: (passcode) { + signInBloc.add( + SignInEvent.signInWithPasscode( + email: email, + passcode: passcode, + ), + ); + }, + ), + ), + ), + ); + + _hasPushedContinueWithMagicLinkOrPasscodePage = true; + } + + void _pushContinueWithPasswordPage( + BuildContext context, + String email, + ) { + final signInBloc = context.read(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithPasswordPage( + email: email, + backToLogin: () { + emailKey.currentState?.clearError(); + Navigator.pop(context); + }, + onEnterPassword: (password) => signInBloc.add( + SignInEvent.signInWithEmailAndPassword( + email: email, + password: password, + ), + ), + onForgotPassword: () { + // todo: implement forgot password + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart new file mode 100644 index 0000000000..ec4fd1bbee --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -0,0 +1,226 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ContinueWithMagicLinkOrPasscodePage extends StatefulWidget { + const ContinueWithMagicLinkOrPasscodePage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPasscode, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPasscode; + + @override + State createState() => + _ContinueWithMagicLinkOrPasscodePageState(); +} + +class _ContinueWithMagicLinkOrPasscodePageState + extends State { + final passcodeController = TextEditingController(); + + bool isEnteringPasscode = false; + + ToastificationItem? toastificationItem; + + final inputPasscodeKey = GlobalKey(); + + @override + void dispose() { + passcodeController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(), + ); + }); + } + }, + child: Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo, title and description + ..._buildLogoTitleAndDescription(), + + // Enter code manually + ..._buildEnterCodeManually(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ), + ); + } + + List _buildEnterCodeManually() { + // todo: ask designer to provide the spacing + final spacing = VSpace(20); + + if (!isEnteringPasscode) { + return [ + AFFilledTextButton.primary( + text: LocaleKeys.signIn_enterCodeManually.tr(), + onTap: () => setState(() => isEnteringPasscode = true), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + spacing, + ]; + } + + return [ + // Enter code manually + AFTextField( + key: inputPasscodeKey, + controller: passcodeController, + hintText: LocaleKeys.signIn_enterCode.tr(), + keyboardType: TextInputType.number, + autoFocus: true, + onSubmitted: (passcode) { + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, + ), + // todo: ask designer to provide the spacing + VSpace(12), + + // continue to login + AFFilledTextButton.primary( + text: LocaleKeys.signIn_continueToSignIn.tr(), + onTap: () { + final passcode = passcodeController.text; + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, + size: AFButtonSize.l, + alignment: Alignment.center, + ), + + spacing, + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: LocaleKeys.signIn_backToLogin.tr(), + size: AFButtonSize.s, + onTap: widget.backToLogin, + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ]; + } + + List _buildLogoTitleAndDescription() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + if (!isEnteringPasscode) { + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_checkYourEmail.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // description + Text( + LocaleKeys.signIn_temporaryVerificationLinkSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; + } else { + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_enterCode.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // description + Text( + LocaleKeys.signIn_temporaryVerificationCodeSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart new file mode 100644 index 0000000000..5bfd191e22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ContinueWithPassword extends StatelessWidget { + const ContinueWithPassword({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFOutlinedTextButton.normal( + text: 'Continue with password', + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart new file mode 100644 index 0000000000..1e2ed6e100 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -0,0 +1,196 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ContinueWithPasswordPage extends StatefulWidget { + const ContinueWithPasswordPage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPassword, + required this.onForgotPassword, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPassword; + final VoidCallback onForgotPassword; + + @override + State createState() => + _ContinueWithPasswordPageState(); +} + +class _ContinueWithPasswordPageState extends State { + final passwordController = TextEditingController(); + final inputPasswordKey = GlobalKey(); + + @override + void dispose() { + passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasswordKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), + ); + }); + } else if (state.passwordError != null) { + inputPasswordKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), + ); + } else { + inputPasswordKey.currentState?.clearError(); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo and title + ..._buildLogoAndTitle(), + + // Password input and buttons + ..._buildPasswordSection(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ), + ); + } + + List _buildLogoAndTitle() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_enterPassword.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // email display + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.signIn_loginAs.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + ), + TextSpan( + text: ' ${widget.email}', + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + ], + ), + ), + spacing, + ]; + } + + List _buildPasswordSection() { + final theme = AppFlowyTheme.of(context); + final iconSize = 20.0; + return [ + // Password input + AFTextField( + key: inputPasswordKey, + controller: passwordController, + hintText: LocaleKeys.signIn_enterPassword.tr(), + autoFocus: true, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + inputPasswordKey.currentState?.syncObscured(!isObscured); + }, + ), + onSubmitted: widget.onEnterPassword, + ), + // todo: ask designer to provide the spacing + VSpace(8), + + // Forgot password button + Align( + alignment: Alignment.centerLeft, + child: AFGhostTextButton( + text: LocaleKeys.signIn_forgotPassword.tr(), + size: AFButtonSize.s, + padding: EdgeInsets.zero, + onTap: widget.onForgotPassword, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ), + VSpace(20), + + // Continue button + AFFilledTextButton.primary( + text: LocaleKeys.web_continue.tr(), + onTap: () => widget.onEnterPassword(passwordController.text), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + VSpace(20), + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: LocaleKeys.signIn_backToLogin.tr(), + size: AFButtonSize.s, + onTap: widget.backToLogin, + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart new file mode 100644 index 0000000000..8e126db7ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/material.dart'; + +class AFLogo extends StatelessWidget { + const AFLogo({ + super.key, + this.size = const Size.square(36), + }); + + final Size size; + + @override + Widget build(BuildContext context) { + return FlowySvg( + FlowySvgs.app_logo_xl, + blendMode: null, + size: size, + ); + } +} 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 0486d67838..45e4fe7273 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 @@ -64,14 +64,16 @@ class _SignInWithMagicLinkButtonsState void _sendMagicLink(BuildContext context, String email) { if (!isEmail(email)) { - return showToastNotification( - context, + showToastNotification( message: LocaleKeys.signIn_invalidEmail.tr(), type: ToastificationType.error, ); + return; } - context.read().add(SignInEvent.signedWithMagicLink(email)); + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); showConfirmDialog( context: context, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index 7351871b6a..76ce87ffc1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -1,5 +1,6 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -11,39 +12,38 @@ class SignInAgreement extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final textStyle = theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ); + final underlinedTextStyle = theme.textStyle.caption.underline( + color: theme.textColorScheme.secondary, + ); return RichText( textAlign: TextAlign.center, text: TextSpan( children: [ TextSpan( - text: '${LocaleKeys.web_signInAgreement.tr()} ', - style: const TextStyle(color: Colors.grey, fontSize: 12), + text: LocaleKeys.web_signInAgreement.tr(), + style: textStyle, ), TextSpan( text: '${LocaleKeys.web_termOfUse.tr()} ', - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - decoration: TextDecoration.underline, - ), + style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString('https://appflowy.io/terms'), + ..onTap = () => afLaunchUrlString('https://appflowy.com/terms'), ), TextSpan( text: '${LocaleKeys.web_and.tr()} ', - style: const TextStyle(color: Colors.grey, fontSize: 12), + style: textStyle, ), TextSpan( text: LocaleKeys.web_privacyPolicy.tr(), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - decoration: TextDecoration.underline, - ), + style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'), + ..onTap = () => afLaunchUrlString('https://appflowy.com/privacy'), ), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index bce22a714d..33ef1d7bb0 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -1,90 +1,13 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:universal_platform/universal_platform.dart'; - -/// Used in DesktopSignInScreen and MobileSignInScreen -class SignInAnonymousButton extends StatelessWidget { - const SignInAnonymousButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final isMobile = UniversalPlatform.isMobile; - - return BlocBuilder( - builder: (context, signInState) { - return BlocProvider( - create: (context) => AnonUserBloc() - ..add( - const AnonUserEvent.initial(), - ), - child: BlocListener( - listener: (context, state) async { - if (state.openedAnonUser != null) { - await runAppFlowy(); - } - }, - child: BlocBuilder( - builder: (context, state) { - final text = state.anonUsers.isEmpty - ? LocaleKeys.signIn_loginStartWithAnonymous.tr() - : LocaleKeys.signIn_continueAnonymousUser.tr(); - final onTap = state.anonUsers.isEmpty - ? () { - context - .read() - .add(const SignInEvent.signedInAsGuest()); - } - : () { - final bloc = context.read(); - final user = bloc.state.anonUsers.first; - bloc.add(AnonUserEvent.openAnonUser(user)); - }; - // SignInAnonymousButton in mobile - if (isMobile) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), - ), - onPressed: onTap, - child: FlowyText( - LocaleKeys.signIn_loginStartWithAnonymous.tr(), - fontSize: 14, - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.w500, - ), - ); - } - // SignInAnonymousButton in desktop - return SizedBox( - height: 48, - child: FlowyButton( - isSelected: true, - disable: signInState.isSubmitting, - text: FlowyText.medium( - text, - textAlign: TextAlign.center, - ), - radius: Corners.s6Border, - onTap: onTap, - ), - ); - }, - ), - ), - ); - }, - ); - } -} class SignInAnonymousButtonV2 extends StatelessWidget { const SignInAnonymousButtonV2({ @@ -108,27 +31,35 @@ class SignInAnonymousButtonV2 extends StatelessWidget { }, child: BlocBuilder( builder: (context, state) { - final text = LocaleKeys.signIn_anonymous.tr(); + final theme = AppFlowyTheme.of(context); final onTap = state.anonUsers.isEmpty ? () { context .read() - .add(const SignInEvent.signedInAsGuest()); + .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; - return FlowyButton( - useIntrinsicWidth: true, - onTap: onTap, - text: FlowyText( - text, - color: Colors.grey, - decoration: TextDecoration.underline, - fontSize: 12, + return AFGhostIconTextButton( + text: LocaleKeys.signIn_anonymousMode.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), + size: AFButtonSize.s, + onTap: onTap, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.anonymous_mode_m, + color: theme.textColorScheme.secondary, + ); + }, ); }, ), @@ -138,3 +69,39 @@ class SignInAnonymousButtonV2 extends StatelessWidget { ); } } + +class ChangeCloudModeButton extends StatelessWidget { + const ChangeCloudModeButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: LocaleKeys.signIn_switchToAppFlowyCloud.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, + ), + onTap: () async { + await useAppFlowyBetaCloudWithURL( + kAppflowyCloudUrl, + AuthenticatorType.appflowyCloud, + ); + await runAppFlowy(); + }, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.cloud_mode_m, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); + }, + ); + } +} 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 5146e29962..7067844500 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 @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class MobileLogoutButton extends StatelessWidget { @@ -18,50 +18,19 @@ class MobileLogoutButton extends StatelessWidget { @override Widget build(BuildContext context) { - final style = Theme.of(context); - return GestureDetector( + return AFOutlinedIconTextButton.normal( + text: text, onTap: onPressed, - child: Container( - height: 38, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(4), - ), - border: Border.all( - color: textColor ?? style.colorScheme.outline, - width: 0.5, - ), - ), - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) ...[ - SizedBox( - // The icon could be in different height as original aspect ratio, we use a fixed sizebox to wrap it to make sure they all occupy the same space. - width: 30, - height: 30, - child: Center( - child: SizedBox( - width: 24, - child: FlowySvg( - icon!, - blendMode: null, - ), - ), - ), - ), - const HSpace(8), - ], - FlowyText( - text, - fontSize: 14.0, - fontWeight: FontWeight.w400, - color: textColor, - ), - ], - ), - ), + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + if (icon == null) { + return const SizedBox.shrink(); + } + return FlowySvg( + icon!, + size: Size.square(18), + ); + }, ); } } 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/third_party_sign_in_button.dart similarity index 53% rename from frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart rename to frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart index 35d16b031f..9a7234ab6b 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/third_party_sign_in_button.dart @@ -1,10 +1,7 @@ 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:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum ThirdPartySignInButtonType { @@ -102,118 +99,55 @@ class MobileThirdPartySignInButton extends StatelessWidget { super.key, this.height = 38, this.fontSize = 14.0, - required this.onPressed, + required this.onTap, required this.type, }); - final VoidCallback onPressed; + final VoidCallback onTap; final double height; final double fontSize; final ThirdPartySignInButtonType type; @override Widget build(BuildContext context) { - final style = Theme.of(context); - - return AnimatedGestureDetector( - scaleFactor: 1.0, - onTapUp: onPressed, - child: Container( - height: height, - decoration: BoxDecoration( - color: type.backgroundColor(context), - borderRadius: const BorderRadius.all( - Radius.circular(4), - ), - border: Border.all( - color: style.colorScheme.outline, - width: 0.5, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (type != ThirdPartySignInButtonType.anonymous) - FlowySvg( - type.icon, - size: Size.square(fontSize), - blendMode: type.blendMode, - color: type.textColor(context), - ), - const HSpace(8.0), - FlowyText( - type.labelText, - fontSize: fontSize, - color: type.textColor(context), - ), - ], - ), - ), + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(16), + blendMode: type.blendMode, + ); + }, ); } } -class DesktopSignInButton extends StatelessWidget { - const DesktopSignInButton({ +class DesktopThirdPartySignInButton extends StatelessWidget { + const DesktopThirdPartySignInButton({ super.key, required this.type, - required this.onPressed, + required this.onTap, }); final ThirdPartySignInButtonType type; - final VoidCallback onPressed; + final VoidCallback onTap; @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, - ), + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(18), + blendMode: type.blendMode, + ); + }, ); } } 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_button/third_party_sign_in_buttons.dart similarity index 67% rename from frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart rename to frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart index 7baa243e5f..8d27846c46 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_button/third_party_sign_in_buttons.dart @@ -1,8 +1,7 @@ import 'dart:io'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -40,7 +39,7 @@ class ThirdPartySignInButtons extends StatelessWidget { void _signIn(BuildContext context, String provider) { context.read().add( - SignInEvent.signedInWithOAuth(provider), + SignInEvent.signInWithOAuth(platform: provider), ); } } @@ -58,23 +57,22 @@ class _DesktopThirdPartySignIn extends StatefulWidget { } class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { - static const padding = 12.0; - bool isExpanded = false; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ - DesktopSignInButton( + DesktopThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), - const VSpace(padding), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], @@ -82,38 +80,39 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { } List _buildExpandedButtons() { + final theme = AppFlowyTheme.of(context); return [ - const VSpace(padding * 1.5), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.github, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), - const VSpace(padding), - DesktopSignInButton( + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); return [ - const VSpace(padding), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - setState(() { - isExpanded = !isExpanded; - }); - }, - child: FlowyText( - LocaleKeys.signIn_continueAnotherWay.tr(), - color: Theme.of(context).colorScheme.onSurface, - decoration: TextDecoration.underline, - fontSize: 14, - ), - ), + VSpace(theme.spacing.l), + AFGhostTextButton( + text: 'More options', + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, ), ]; } @@ -153,14 +152,14 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { if (Platform.isIOS) ...[ MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), const VSpace(padding), ], MobileThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], @@ -172,31 +171,33 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.github, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, - onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); return [ const VSpace(padding * 2), - GestureDetector( + AFGhostTextButton( + text: 'More options', + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, onTap: () { setState(() { isExpanded = !isExpanded; }); }, - child: FlowyText( - LocaleKeys.signIn_continueAnotherWay.tr(), - color: Theme.of(context).colorScheme.onSurface, - decoration: TextDecoration.underline, - fontSize: 14, - ), ), ]; } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart index 18e260a472..6d79b896c1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart @@ -1,7 +1,7 @@ -export 'magic_link_sign_in_buttons.dart'; +export 'continue_with/continue_with_email_and_password.dart'; +export 'sign_in_agreement.dart'; export 'sign_in_anonymous_button.dart'; export 'sign_in_or_logout_button.dart'; -export 'third_party_sign_in_button.dart'; +export 'third_party_sign_in_button/third_party_sign_in_button.dart'; // export 'switch_sign_in_sign_up_button.dart'; -export 'third_party_sign_in_buttons.dart'; -export 'sign_in_agreement.dart'; +export 'third_party_sign_in_button/third_party_sign_in_buttons.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart deleted file mode 100644 index 8aea8dde55..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -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/sign_up_bloc.dart'; -import 'package:appflowy/user/presentation/router.dart'; -import 'package:appflowy/user/presentation/widgets/widgets.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' - show UserProfilePB; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SignUpScreen extends StatelessWidget { - const SignUpScreen({ - super.key, - required this.router, - }); - - static const routeName = '/SignUpScreen'; - final AuthRouter router; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(), - child: BlocListener( - listener: (context, state) { - final successOrFail = state.successOrFail; - if (successOrFail != null) { - _handleSuccessOrFail(context, successOrFail); - } - }, - child: const Scaffold(body: SignUpForm()), - ), - ); - } - - void _handleSuccessOrFail( - BuildContext context, - FlowyResult result, - ) { - result.fold( - (user) => router.pushWorkspaceStartScreen(context, user), - (error) => showSnapBar(context, error.msg), - ); - } -} - -class SignUpForm extends StatelessWidget { - const SignUpForm({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Align( - child: AuthFormContainer( - children: [ - FlowyLogoTitle( - title: LocaleKeys.signUp_title.tr(), - logoSize: const Size(60, 60), - ), - const VSpace(30), - const EmailTextField(), - const VSpace(5), - const PasswordTextField(), - const VSpace(5), - const RepeatPasswordTextField(), - const VSpace(30), - const SignUpButton(), - const VSpace(10), - const SignUpPrompt(), - if (context.read().state.isSubmitting) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(), - ], - ], - ), - ); - } -} - -class SignUpPrompt extends StatelessWidget { - const SignUpPrompt({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyText.medium( - LocaleKeys.signUp_alreadyHaveAnAccount.tr(), - color: Theme.of(context).hintColor, - ), - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.bodyMedium, - ), - onPressed: () => Navigator.pop(context), - child: FlowyText.medium( - LocaleKeys.signIn_buttonText.tr(), - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ); - } -} - -class SignUpButton extends StatelessWidget { - const SignUpButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return RoundedTextButton( - title: LocaleKeys.signUp_getStartedText.tr(), - height: 48, - onPressed: () { - context - .read() - .add(const SignUpEvent.signUpWithUserEmailAndPassword()); - }, - ); - } -} - -class PasswordTextField extends StatelessWidget { - const PasswordTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.passwordError != current.passwordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: const FlowySvg(FlowySvgs.hide_m), - obscureHideIcon: const FlowySvg(FlowySvgs.show_m), - hintText: LocaleKeys.signUp_passwordHint.tr(), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.passwordError ?? '', - onChanged: (value) => context - .read() - .add(SignUpEvent.passwordChanged(value)), - ); - }, - ); - } -} - -class RepeatPasswordTextField extends StatelessWidget { - const RepeatPasswordTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.repeatPasswordError != current.repeatPasswordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: const FlowySvg(FlowySvgs.hide_m), - obscureHideIcon: const FlowySvg(FlowySvgs.show_m), - hintText: LocaleKeys.signUp_repeatPasswordHint.tr(), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.repeatPasswordError ?? '', - onChanged: (value) => context - .read() - .add(SignUpEvent.repeatPasswordChanged(value)), - ); - }, - ); - } -} - -class EmailTextField extends StatelessWidget { - const EmailTextField({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - previous.emailError != current.emailError, - builder: (context, state) { - return RoundedInputField( - hintText: LocaleKeys.signUp_emailHint.tr(), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - normalBorderColor: Theme.of(context).colorScheme.outline, - errorBorderColor: Theme.of(context).colorScheme.error, - cursorColor: Theme.of(context).colorScheme.primary, - errorText: context.read().state.emailError ?? '', - onChanged: (value) => - context.read().add(SignUpEvent.emailChanged(value)), - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart index 146bf06df1..4062cedf8e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -8,7 +8,6 @@ import 'package:appflowy/user/presentation/helpers/helpers.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -61,32 +60,15 @@ class SplashScreen extends StatelessWidget { BuildContext context, Authenticated authenticated, ) async { - final userProfile = authenticated.userProfile; - - /// After a user is authenticated, this function checks if encryption is required. - final result = await UserEventCheckEncryptionSign().send(); - await result.fold( - (check) async { - /// If encryption is needed, the user is navigated to the encryption screen. - /// Otherwise, it fetches the current workspace for the user and navigates them - if (check.requireSecret) { - getIt().pushEncryptionScreen(context, userProfile); - } else { - final result = await FolderEventGetCurrentWorkspaceSetting().send(); - result.fold( - (workspaceSetting) { - // After login, replace Splash screen by corresponding home screen - getIt().goHomeScreen( - context, - ); - }, - (error) => handleOpenWorkspaceError(context, error), - ); - } - }, - (err) { - Log.error(err); + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSetting) { + // After login, replace Splash screen by corresponding home screen + getIt().goHomeScreen( + context, + ); }, + (error) => handleOpenWorkspaceError(context, error), ); } @@ -115,7 +97,7 @@ class Body extends StatelessWidget { return Container( alignment: Alignment.center, child: UniversalPlatform.isMobile - ? const FlowySvg(FlowySvgs.flowy_logo_xl, blendMode: null) + ? const FlowySvg(FlowySvgs.app_logo_xl, blendMode: null) : const _DesktopSplashBody(), ); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart index d79127e04c..af6d4ad770 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -1,7 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -86,7 +85,6 @@ class WorkspaceErrorScreen extends StatelessWidget { const VSpace(50), const LogoutButton(), const VSpace(20), - const ResetWorkspaceButton(), ]); return Center( @@ -157,43 +155,3 @@ class LogoutButton extends StatelessWidget { ); } } - -class ResetWorkspaceButton extends StatelessWidget { - const ResetWorkspaceButton({super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - height: 40, - child: BlocBuilder( - builder: (context, state) { - final isLoading = state.loadingState?.isLoading() ?? false; - final icon = isLoading - ? const Center( - child: CircularProgressIndicator.adaptive(), - ) - : null; - - return FlowyButton( - text: FlowyText.medium( - LocaleKeys.workspace_reset.tr(), - textAlign: TextAlign.center, - ), - onTap: () { - NavigatorAlertDialog( - title: LocaleKeys.workspace_resetWorkspacePrompt.tr(), - confirm: () { - context.read().add( - const WorkspaceErrorEvent.resetWorkspace(), - ); - }, - ).show(context); - }, - rightIcon: icon, - ); - }, - ), - ); - } -} 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 59b61aa54b..a6124da60b 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 @@ -57,7 +57,7 @@ class _MobileWorkspaceStartScreenState children: [ const Spacer(), const FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, size: Size.square(64), blendMode: null, ), 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 8ce09a5b7f..c0b8e7e5ae 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 @@ -8,7 +8,7 @@ class AuthFormContainer extends StatelessWidget { final List children; - static const double width = 340; + static const double width = 320; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart index c2a13eac82..14b1c896a9 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart @@ -1,8 +1,7 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra/size.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; class FlowyLogoTitle extends StatelessWidget { const FlowyLogoTitle({ @@ -16,24 +15,19 @@ class FlowyLogoTitle extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return SizedBox( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox.fromSize( - size: logoSize, - child: const FlowySvg( - FlowySvgs.flowy_logo_xl, - blendMode: null, - ), - ), + AFLogo(size: logoSize), const VSpace(20), - FlowyText.regular( + Text( title, - fontSize: FontSizes.s24, - fontFamily: - GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily, - color: Theme.of(context).colorScheme.tertiary, + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), ), ], ), diff --git a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart index 34925235cb..61694367bb 100644 --- a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart +++ b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart @@ -5,12 +5,17 @@ import 'package:flutter/material.dart'; extension ColorExtension on Color { /// return a hex string in 0xff000000 format String toHexString() { - return '0x${value.toRadixString(16).padLeft(8, '0')}'; + final alpha = (a * 255).toInt().toRadixString(16).padLeft(2, '0'); + final red = (r * 255).toInt().toRadixString(16).padLeft(2, '0'); + final green = (g * 255).toInt().toRadixString(16).padLeft(2, '0'); + final blue = (b * 255).toInt().toRadixString(16).padLeft(2, '0'); + + return '0x$alpha$red$green$blue'.toLowerCase(); } /// return a random color static Color random({double opacity = 1.0}) { return Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) - .withOpacity(opacity); + .withValues(alpha: opacity); } } diff --git a/frontend/appflowy_flutter/lib/util/default_extensions.dart b/frontend/appflowy_flutter/lib/util/default_extensions.dart index 36bbdcb6b4..603a66d6cf 100644 --- a/frontend/appflowy_flutter/lib/util/default_extensions.dart +++ b/frontend/appflowy_flutter/lib/util/default_extensions.dart @@ -13,3 +13,11 @@ const List defaultImageExtensions = [ 'webp', 'bmp', ]; + +bool isNotImageUrl(String url) { + final nonImageSuffixRegex = RegExp( + r'(\.(io|html|php|json|txt|js|css|xml|md|log)(\?.*)?(#.*)?$)|/$', + caseSensitive: false, + ); + return nonImageSuffixRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index d7e7b6ce87..b8dd390627 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -25,7 +25,6 @@ Future shareLogFiles(BuildContext? context) async { if (archiveLogFiles.isEmpty) { if (context != null && context.mounted) { showToastNotification( - context, message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -42,7 +41,6 @@ Future shareLogFiles(BuildContext? context) async { if (zip == null) { if (context != null && context.mounted) { showToastNotification( - context, message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); @@ -72,7 +70,6 @@ Future shareLogFiles(BuildContext? context) async { } catch (e) { if (context != null && context.mounted) { showToastNotification( - context, message: e.toString(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/util/throttle.dart b/frontend/appflowy_flutter/lib/util/throttle.dart index c8c6dcf0ca..0aaa9f2d3a 100644 --- a/frontend/appflowy_flutter/lib/util/throttle.dart +++ b/frontend/appflowy_flutter/lib/util/throttle.dart @@ -16,6 +16,10 @@ class Throttler { }); } + void cancel() { + _timer?.cancel(); + } + void dispose() { _timer?.cancel(); _timer = null; diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart index d900afd6eb..c3190a8e40 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; /// A class for the default appearance settings for the app class DefaultAppearanceSettings { @@ -15,6 +14,6 @@ class DefaultAppearanceSettings { } static Color getDefaultSelectionColor(BuildContext context) { - return Theme.of(context).colorScheme.primary.withOpacity(0.2); + return Theme.of(context).colorScheme.primary.withValues(alpha: 0.2); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index d952c09221..01f638fe7a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -2,10 +2,8 @@ import 'dart:async'; import 'package:appflowy/plugins/trash/application/trash_listener.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; -import 'package:appflowy/workspace/application/command_palette/search_listener.dart'; import 'package:appflowy/workspace/application/command_palette/search_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; @@ -13,184 +11,338 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; -const _searchChannel = 'CommandPalette'; +class Debouncer { + Debouncer({required this.delay}); + + final Duration delay; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + } +} class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { - _searchListener.start( - onResultsChanged: _onResultsChanged, - ); + on<_SearchChanged>(_onSearchChanged); + on<_PerformSearch>(_onPerformSearch); + on<_NewSearchStream>(_onNewSearchStream); + on<_ResultsChanged>(_onResultsChanged); + on<_TrashChanged>(_onTrashChanged); + on<_WorkspaceChanged>(_onWorkspaceChanged); + on<_ClearSearch>(_onClearSearch); _initTrash(); - _dispatch(); } - Timer? _debounceOnChanged; - final TrashService _trashService = TrashService(); - final SearchListener _searchListener = SearchListener( - channel: _searchChannel, + final Debouncer _searchDebouncer = Debouncer( + delay: const Duration(milliseconds: 300), ); + final TrashService _trashService = TrashService(); final TrashListener _trashListener = TrashListener(); - String? _oldQuery; + String? _activeQuery; String? _workspaceId; - int _messagesReceived = 0; @override Future close() { _trashListener.close(); - _searchListener.stop(); - _debounceOnChanged?.cancel(); + _searchDebouncer.dispose(); + state.searchResponseStream?.dispose(); return super.close(); } - void _dispatch() { - on((event, emit) async { - event.when( - searchChanged: _debounceOnSearchChanged, - trashChanged: (trash) async { - if (trash != null) { - return emit(state.copyWith(trash: trash)); - } - - final trashOrFailure = await _trashService.readTrash(); - final trashRes = trashOrFailure.fold( - (trash) => trash, - (error) => null, - ); - - if (trashRes != null) { - emit(state.copyWith(trash: trashRes.items)); - } - }, - performSearch: (search) async { - if (search.isNotEmpty && search != state.query) { - _oldQuery = state.query; - emit(state.copyWith(query: search, isLoading: true)); - await SearchBackendService.performSearch( - search, - workspaceId: _workspaceId, - channel: _searchChannel, - ); - } else { - emit(state.copyWith(query: null, isLoading: false, results: [])); - } - }, - resultsChanged: (results) { - if (state.query != _oldQuery) { - emit(state.copyWith(results: [], isLoading: true)); - _oldQuery = state.query; - _messagesReceived = 0; - } - - if (state.query != results.query) { - return; - } - - _messagesReceived++; - - emit( - state.copyWith( - results: _filterDuplicates(results.items), - isLoading: _messagesReceived != results.sends.toInt(), - ), - ); - }, - workspaceChanged: (workspaceId) { - _workspaceId = workspaceId; - emit(state.copyWith(results: [], query: '', isLoading: false)); - }, - clearSearch: () { - emit(state.copyWith(results: [], query: '', isLoading: false)); - }, - ); - }); - } - Future _initTrash() async { _trashListener.start( - trashUpdated: (trashOrFailed) { - final trash = trashOrFailed.toNullable(); - add(CommandPaletteEvent.trashChanged(trash: trash)); - }, + trashUpdated: (trashOrFailed) => add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailed.toNullable(), + ), + ), ); final trashOrFailure = await _trashService.readTrash(); - final trash = trashOrFailure.toNullable(); - - add(CommandPaletteEvent.trashChanged(trash: trash?.items)); - } - - void _debounceOnSearchChanged(String value) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer( - const Duration(milliseconds: 300), - () => _performSearch(value), + trashOrFailure.fold( + (trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)), + (error) => debugPrint('Failed to load trash: $error'), ); } - List _filterDuplicates(List results) { - final currentItems = [...state.results]; - final res = [...results]; - - for (final item in results) { - final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id); - if (duplicateIndex == -1) { - continue; - } - - final duplicate = currentItems[duplicateIndex]; - if (item.score < duplicate.score) { - res.remove(item); - } else { - currentItems.remove(duplicate); - } - } - - return res..addAll(currentItems); + FutureOr _onSearchChanged( + _SearchChanged event, + Emitter emit, + ) { + _searchDebouncer.run( + () { + if (!isClosed) { + add(CommandPaletteEvent.performSearch(search: event.search)); + } + }, + ); } - void _performSearch(String value) => - add(CommandPaletteEvent.performSearch(search: value)); + FutureOr _onPerformSearch( + _PerformSearch event, + Emitter emit, + ) async { + if (event.search.isEmpty && event.search != state.query) { + emit( + state.copyWith( + query: null, + searching: false, + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + generatingAIOverview: false, + ), + ); + } else { + emit(state.copyWith(query: event.search, searching: true)); + _activeQuery = event.search; - void _onResultsChanged(SearchResultNotificationPB results) => - add(CommandPaletteEvent.resultsChanged(results: results)); + unawaited( + SearchBackendService.performSearch( + event.search, + workspaceId: _workspaceId, + ).then( + (result) => result.fold( + (stream) { + if (!isClosed && _activeQuery == event.search) { + add(CommandPaletteEvent.newSearchStream(stream: stream)); + } + }, + (error) { + debugPrint('Search error: $error'); + if (!isClosed) { + add( + CommandPaletteEvent.resultsChanged( + searchId: '', + searching: false, + generatingAIOverview: false, + ), + ); + } + }, + ), + ), + ); + } + } + + FutureOr _onNewSearchStream( + _NewSearchStream event, + Emitter emit, + ) { + state.searchResponseStream?.dispose(); + emit( + state.copyWith( + searchId: event.stream.searchId, + searchResponseStream: event.stream, + ), + ); + + event.stream.listen( + onLocalItems: (items, searchId) => _handleResultsUpdate( + searchId: searchId, + localItems: items, + ), + onServerItems: (items, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( + searchId: searchId, + serverItems: items, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + onSummaries: (summaries, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( + searchId: searchId, + summaries: summaries, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + onFinished: (searchId) => _handleResultsUpdate( + searchId: searchId, + searching: false, + ), + ); + } + + void _handleResultsUpdate({ + required String searchId, + List? serverItems, + List? localItems, + List? summaries, + bool searching = true, + bool generatingAIOverview = false, + }) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + searchId: searchId, + serverItems: serverItems, + localItems: localItems, + summaries: summaries, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + ); + } + } + + FutureOr _onResultsChanged( + _ResultsChanged event, + Emitter emit, + ) async { + if (state.searchId != event.searchId) return; + + final combinedItems = {}; + for (final item in event.serverItems ?? state.serverResponseItems) { + combinedItems[item.id] = SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: item.content, + workspaceId: item.workspaceId, + ); + } + + for (final item in event.localItems ?? state.localResponseItems) { + combinedItems.putIfAbsent( + item.id, + () => SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: '', + workspaceId: item.workspaceId, + ), + ); + } + + emit( + state.copyWith( + serverResponseItems: event.serverItems ?? state.serverResponseItems, + localResponseItems: event.localItems ?? state.localResponseItems, + resultSummaries: event.summaries ?? state.resultSummaries, + combinedResponseItems: combinedItems, + searching: event.searching, + generatingAIOverview: event.generatingAIOverview, + ), + ); + } + + FutureOr _onTrashChanged( + _TrashChanged event, + Emitter emit, + ) async { + if (event.trash != null) { + emit(state.copyWith(trash: event.trash!)); + } else { + final trashOrFailure = await _trashService.readTrash(); + trashOrFailure.fold((trash) { + emit(state.copyWith(trash: trash.items)); + }, (error) { + // Optionally handle error; otherwise, we simply do nothing. + }); + } + } + + FutureOr _onWorkspaceChanged( + _WorkspaceChanged event, + Emitter emit, + ) { + _workspaceId = event.workspaceId; + emit( + state.copyWith( + query: '', + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + searching: false, + generatingAIOverview: false, + ), + ); + } + + FutureOr _onClearSearch( + _ClearSearch event, + Emitter emit, + ) { + emit(CommandPaletteState.initial().copyWith(trash: state.trash)); + } + + bool _isActiveSearch(String searchId) => + !isClosed && state.searchId == searchId; } @freezed class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.searchChanged({required String search}) = _SearchChanged; - const factory CommandPaletteEvent.performSearch({required String search}) = _PerformSearch; - + const factory CommandPaletteEvent.newSearchStream({ + required SearchResponseStream stream, + }) = _NewSearchStream; const factory CommandPaletteEvent.resultsChanged({ - required SearchResultNotificationPB results, + required String searchId, + required bool searching, + required bool generatingAIOverview, + List? serverItems, + List? localItems, + List? summaries, }) = _ResultsChanged; const factory CommandPaletteEvent.trashChanged({ @Default(null) List? trash, }) = _TrashChanged; - const factory CommandPaletteEvent.workspaceChanged({ @Default(null) String? workspaceId, }) = _WorkspaceChanged; - const factory CommandPaletteEvent.clearSearch() = _ClearSearch; } +class SearchResultItem { + const SearchResultItem({ + required this.id, + required this.icon, + required this.content, + required this.displayName, + this.workspaceId, + }); + + final String id; + final String content; + final ResultIconPB icon; + final String displayName; + final String? workspaceId; +} + @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); - const factory CommandPaletteState({ @Default(null) String? query, - required List results, - required bool isLoading, + @Default([]) List serverResponseItems, + @Default([]) List localResponseItems, + @Default({}) Map combinedResponseItems, + @Default([]) List resultSummaries, + @Default(null) SearchResponseStream? searchResponseStream, + required bool searching, + required bool generatingAIOverview, @Default([]) List trash, + @Default(null) String? searchId, }) = _CommandPaletteState; - factory CommandPaletteState.initial() => - const CommandPaletteState(results: [], isLoading: false); + factory CommandPaletteState.initial() => const CommandPaletteState( + searching: false, + generatingAIOverview: false, + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart deleted file mode 100644 index b22630eb74..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:appflowy/core/notification/search_notification.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flowy_infra/notifier.dart'; - -// Do not modify! -const _searchObjectId = "SEARCH_IDENTIFIER"; - -class SearchListener { - SearchListener({this.channel}); - - /// Use this to filter out search results from other channels. - /// - /// If null, it will receive search results from all - /// channels, otherwise it will only receive search results from the specified - /// channel. - /// - final String? channel; - - PublishNotifier? _updateNotifier = - PublishNotifier(); - PublishNotifier? _updateDidCloseNotifier = - PublishNotifier(); - SearchNotificationListener? _listener; - - void start({ - void Function(SearchResultNotificationPB)? onResultsChanged, - void Function(SearchResultNotificationPB)? onResultsClosed, - }) { - if (onResultsChanged != null) { - _updateNotifier?.addPublishListener(onResultsChanged); - } - - if (onResultsClosed != null) { - _updateDidCloseNotifier?.addPublishListener(onResultsClosed); - } - - _listener = SearchNotificationListener( - objectId: _searchObjectId, - handler: _handler, - channel: channel, - ); - } - - void _handler( - SearchNotification ty, - FlowyResult result, - ) { - switch (ty) { - case SearchNotification.DidUpdateResults: - result.fold( - (payload) => _updateNotifier?.value = - SearchResultNotificationPB.fromBuffer(payload), - (err) => Log.error(err), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _updateNotifier?.dispose(); - _updateNotifier = null; - _updateDidCloseNotifier?.dispose(); - _updateDidCloseNotifier = null; - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart index 610c666667..6b6ea6d5c0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart @@ -5,19 +5,19 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; -extension GetIcon on SearchResultPB { +extension GetIcon on ResultIconPB { Widget? getIcon() { - final iconValue = icon.value, iconType = icon.ty; + final iconValue = value, iconType = ty; if (iconType == ResultIconTypePB.Emoji) { return iconValue.isNotEmpty ? FlowyText.emoji(iconValue, fontSize: 18) : null; - } else if (icon.ty == ResultIconTypePB.Icon) { + } else if (ty == ResultIconTypePB.Icon) { if (_resultIconValueTypes.contains(iconValue)) { - return FlowySvg(icon.getViewSvg(), size: const Size.square(18)); + return FlowySvg(getViewSvg(), size: const Size.square(18)); } return RawEmojiIconWidget( - emoji: EmojiIconData(iconType.toFlowyIconType(), icon.value), + emoji: EmojiIconData(iconType.toFlowyIconType(), value), emojiSize: 18, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart new file mode 100644 index 0000000000..e5953ae61b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_result_list_bloc.freezed.dart'; + +class SearchResultListBloc + extends Bloc { + SearchResultListBloc() : super(SearchResultListState.initial()) { + // Register event handlers + on<_OnHoverSummary>(_onHoverSummary); + on<_OnHoverResult>(_onHoverResult); + on<_OpenPage>(_onOpenPage); + } + + FutureOr _onHoverSummary( + _OnHoverSummary event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: event.summary, + hoveredResult: null, + userHovered: event.userHovered, + openPageId: null, + ), + ); + } + + FutureOr _onHoverResult( + _OnHoverResult event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: null, + hoveredResult: event.item, + userHovered: event.userHovered, + openPageId: null, + ), + ); + } + + FutureOr _onOpenPage( + _OpenPage event, + Emitter emit, + ) { + emit(state.copyWith(openPageId: event.pageId)); + } +} + +@freezed +class SearchResultListEvent with _$SearchResultListEvent { + const factory SearchResultListEvent.onHoverSummary({ + required SearchSummaryPB summary, + required bool userHovered, + }) = _OnHoverSummary; + const factory SearchResultListEvent.onHoverResult({ + required SearchResultItem item, + required bool userHovered, + }) = _OnHoverResult; + + const factory SearchResultListEvent.openPage({ + required String pageId, + }) = _OpenPage; +} + +@freezed +class SearchResultListState with _$SearchResultListState { + const SearchResultListState._(); + const factory SearchResultListState({ + @Default(null) SearchSummaryPB? hoveredSummary, + @Default(null) SearchResultItem? hoveredResult, + @Default(null) String? openPageId, + @Default(false) bool userHovered, + }) = _SearchResultListState; + + factory SearchResultListState.initial() => const SearchResultListState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart index 53a229ae66..89e5b604f8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -1,22 +1,131 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:nanoid/nanoid.dart'; +import 'package:fixnum/fixnum.dart'; class SearchBackendService { - static Future> performSearch( + static Future> performSearch( String keyword, { String? workspaceId, - String? channel, }) async { + final searchId = nanoid(6); + final stream = SearchResponseStream(searchId: searchId); + final filter = SearchFilterPB(workspaceId: workspaceId); final request = SearchQueryPB( search: keyword, filter: filter, - channel: channel, + searchId: searchId, + streamPort: Int64(stream.nativePort), ); - return SearchEventSearch(request).send(); + unawaited(SearchEventSearch(request).send()); + return FlowyResult.success(stream); + } +} + +class SearchResponseStream { + SearchResponseStream({required this.searchId}) { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (Uint8List data) => _onResultsChanged(data), + ); + } + + final String searchId; + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + void Function( + List items, + String searchId, + bool searching, + bool generatingAIOverview, + )? _onServerItems; + void Function( + List summaries, + String searchId, + bool searching, + bool generatingAIOverview, + )? _onSummaries; + + void Function( + List items, + String searchId, + )? _onLocalItems; + + void Function(String searchId)? _onFinished; + int get nativePort => _port.sendPort.nativePort; + + Future dispose() async { + await _subscription.cancel(); + _port.close(); + } + + void _onResultsChanged(Uint8List data) { + final searchState = SearchStatePB.fromBuffer(data); + + if (searchState.hasResponse()) { + if (searchState.response.hasSearchResult()) { + _onServerItems?.call( + searchState.response.searchResult.items, + searchId, + searchState.response.searching, + searchState.response.generatingAiSummary, + ); + } + if (searchState.response.hasSearchSummary()) { + _onSummaries?.call( + searchState.response.searchSummary.items, + searchId, + searchState.response.searching, + searchState.response.generatingAiSummary, + ); + } + + if (searchState.response.hasLocalSearchResult()) { + _onLocalItems?.call( + searchState.response.localSearchResult.items, + searchId, + ); + } + } else { + _onFinished?.call(searchId); + } + } + + void listen({ + required void Function( + List items, + String searchId, + bool isLoading, + bool generatingAIOverview, + )? onServerItems, + required void Function( + List summaries, + String searchId, + bool isLoading, + bool generatingAIOverview, + )? onSummaries, + required void Function( + List items, + String searchId, + )? onLocalItems, + required void Function(String searchId)? onFinished, + }) { + _onServerItems = onServerItems; + _onSummaries = onSummaries; + _onLocalItems = onLocalItems; + _onFinished = onFinished; } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart index df46ab97e7..a17b5741bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart @@ -25,12 +25,13 @@ class DocumentExporter { final ViewPB view; Future> export( - DocumentExportType type, - ) async { + DocumentExportType type, { + String? path, + }) async { final documentService = DocumentService(); final result = await documentService.openDocument(documentId: view.id); return result.fold( - (r) { + (r) async { final document = r.toDocument(); if (document == null) { return FlowyResult.failure( @@ -43,8 +44,14 @@ class DocumentExporter { case DocumentExportType.json: return FlowyResult.success(jsonEncode(document)); case DocumentExportType.markdown: - final markdown = customDocumentToMarkdown(document); - return FlowyResult.success(markdown); + if (path != null) { + await customDocumentToMarkdown(document, path: path); + return FlowyResult.success(''); + } else { + return FlowyResult.success( + await customDocumentToMarkdown(document), + ); + } case DocumentExportType.text: throw UnimplementedError(); case DocumentExportType.html: diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart index f5106ad0c4..546b9ba13d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart @@ -91,7 +91,9 @@ class FavoriteBloc extends Bloc { await _service.toggleFavorite(view.item.id); await _service.toggleFavorite(view.item.id); } - add(const FavoriteEvent.fetchFavorites()); + if (!isClosed) { + add(const FavoriteEvent.fetchFavorites()); + } isReordering = false; }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart index 1afc253ab7..531e797ff5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart @@ -3,14 +3,14 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' - show WorkspaceSettingPB; + show WorkspaceLatestPB; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'home_bloc.freezed.dart'; class HomeBloc extends Bloc { - HomeBloc(WorkspaceSettingPB workspaceSetting) + HomeBloc(WorkspaceLatestPB workspaceSetting) : _workspaceListener = FolderListener(), super(HomeState.initial(workspaceSetting)) { _dispatch(workspaceSetting); @@ -24,7 +24,7 @@ class HomeBloc extends Bloc { return super.close(); } - void _dispatch(WorkspaceSettingPB workspaceSetting) { + void _dispatch(WorkspaceLatestPB workspaceSetting) { on( (event, emit) async { await event.map( @@ -36,10 +36,9 @@ class HomeBloc extends Bloc { }); _workspaceListener.start( - onSettingUpdated: (result) { + onLatestUpdated: (result) { result.fold( - (setting) => - add(HomeEvent.didReceiveWorkspaceSetting(setting)), + (latest) => add(HomeEvent.didReceiveWorkspaceSetting(latest)), (r) => Log.error(r), ); }, @@ -78,7 +77,7 @@ class HomeEvent with _$HomeEvent { const factory HomeEvent.initial() = _Initial; const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; const factory HomeEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; } @@ -86,11 +85,11 @@ class HomeEvent with _$HomeEvent { class HomeState with _$HomeState { const factory HomeState({ required bool isLoading, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, ViewPB? latestView, }) = _HomeState; - factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState( + factory HomeState.initial(WorkspaceLatestPB workspaceSetting) => HomeState( isLoading: false, workspaceSetting: workspaceSetting, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart index 657f2592d7..cde67045b9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' - show WorkspaceSettingPB; + show WorkspaceLatestPB; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,7 +12,7 @@ part 'home_setting_bloc.freezed.dart'; class HomeSettingBloc extends Bloc { HomeSettingBloc( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsCubit appearanceSettingsCubit, double screenWidthPx, ) : _listener = FolderListener(), @@ -124,7 +124,7 @@ class HomeSettingEvent with _$HomeSettingEvent { _ShowEditPanel; const factory HomeSettingEvent.dismissEditPanel() = _DismissEditPanel; const factory HomeSettingEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; const factory HomeSettingEvent.collapseMenu() = _CollapseMenu; const factory HomeSettingEvent.checkScreenSize(double screenWidthPx) = @@ -139,7 +139,7 @@ class HomeSettingEvent with _$HomeSettingEvent { class HomeSettingState with _$HomeSettingState { const factory HomeSettingState({ required EditPanelContext? panelContext, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, required bool unauthorized, required bool isMenuCollapsed, required bool keepMenuCollapsed, @@ -150,7 +150,7 @@ class HomeSettingState with _$HomeSettingState { }) = _HomeSettingState; factory HomeSettingState.initial( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsState appearanceSettingsState, double screenWidthPx, ) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart index 5a20b29c09..d6a6a73578 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart @@ -244,7 +244,10 @@ class SidebarSectionsBloc } void _initial(UserProfilePB userProfile, String workspaceId) { - _workspaceService = WorkspaceService(workspaceId: workspaceId); + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); _listener = WorkspaceSectionsListener( user: userProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart index 5418eb2b1c..3f9657c5cf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart @@ -1,8 +1,11 @@ import 'package:flutter/foundation.dart'; - import 'package:local_notifier/local_notifier.dart'; -const _appName = "AppFlowy"; +/// The app name used in the local notification. +/// +/// DO NOT Use i18n here, because the i18n plugin is not ready +/// before the local notification is initialized. +const _localNotifierAppName = 'AppFlowy'; /// Manages Local Notifications /// @@ -13,7 +16,11 @@ const _appName = "AppFlowy"; /// class NotificationService { static Future initialize() async { - await localNotifier.setup(appName: _appName); + await localNotifier.setup( + appName: _localNotifierAppName, + // Don't create a shortcut on Windows, because the setup.exe will create a shortcut + shortcutPolicy: ShortcutPolicy.requireNoCreate, + ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart deleted file mode 100644 index 8be68e813e..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:async'; -import 'dart:ffi'; -import 'dart:isolate'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:bloc/bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:fixnum/fixnum.dart'; -part 'download_model_bloc.freezed.dart'; - -class DownloadModelBloc extends Bloc { - DownloadModelBloc(LLMModelPB model) - : super(DownloadModelState.initial(model)) { - on(_handleEvent); - } - - Future _handleEvent( - DownloadModelEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final downloadStream = DownloadingStream(); - downloadStream.listen( - onModelPercentage: (name, percent) { - if (!isClosed) { - add( - DownloadModelEvent.updatePercent(name, percent), - ); - } - }, - onPluginPercentage: (percent) { - if (!isClosed) { - add(DownloadModelEvent.updatePercent("AppFlowy Plugin", percent)); - } - }, - onFinish: () { - add(const DownloadModelEvent.downloadFinish()); - }, - onError: (err) { - Log.error(err); - }, - ); - - final payload = - DownloadLLMPB(progressStream: Int64(downloadStream.nativePort)); - final result = await AIEventDownloadLLMResource(payload).send(); - result.fold((_) { - emit( - state.copyWith( - downloadStream: downloadStream, - loadingState: const ChatLoadingState.finish(), - downloadError: null, - ), - ); - }, (err) { - emit( - state.copyWith( - loadingState: ChatLoadingState.finish(error: err), - ), - ); - }); - }, - updatePercent: (String object, double percent) { - emit(state.copyWith(object: object, percent: percent)); - }, - downloadFinish: () { - emit(state.copyWith(isFinish: true)); - }, - ); - } - - @override - Future close() async { - await state.downloadStream?.dispose(); - return super.close(); - } -} - -@freezed -class DownloadModelEvent with _$DownloadModelEvent { - const factory DownloadModelEvent.started() = _Started; - const factory DownloadModelEvent.updatePercent( - String object, - double percent, - ) = _UpdatePercent; - const factory DownloadModelEvent.downloadFinish() = _DownloadFinish; -} - -@freezed -class DownloadModelState with _$DownloadModelState { - const factory DownloadModelState({ - required LLMModelPB model, - DownloadingStream? downloadStream, - String? downloadError, - @Default("") String object, - @Default(0) double percent, - @Default(false) bool isFinish, - String? bigFileDownloadPrompt, - @Default(ChatLoadingState.loading()) ChatLoadingState loadingState, - }) = _DownloadModelState; - - factory DownloadModelState.initial(LLMModelPB model) { - // bigger than 1 GB then show download big file prompt - String? bigFileDownloadPrompt; - if (model.fileSize > 1 * 1024 * 1024 * 1024) { - bigFileDownloadPrompt = - LocaleKeys.settings_aiPage_keys_downloadBigFilePrompt.tr(); - } - return DownloadModelState( - model: model, - bigFileDownloadPrompt: bigFileDownloadPrompt, - ); - } -} - -class DownloadingStream { - DownloadingStream() { - _port.handler = _controller.add; - } - - final RawReceivePort _port = RawReceivePort(); - StreamSubscription? _sub; - final StreamController _controller = StreamController.broadcast(); - int get nativePort => _port.sendPort.nativePort; - - Future dispose() async { - await _sub?.cancel(); - await _controller.close(); - _port.close(); - } - - void listen({ - void Function(String modelName, double percent)? onModelPercentage, - void Function(double percent)? onPluginPercentage, - void Function(String data)? onError, - void Function()? onFinish, - }) { - _sub = _controller.stream.listen((text) { - if (text.contains(':progress:')) { - final progressIndex = text.indexOf(':progress:'); - final modelName = text.substring(0, progressIndex); - final progressValue = text - .substring(progressIndex + 10); // 10 is the length of ":progress:" - final percent = double.tryParse(progressValue); - if (percent != null) { - onModelPercentage?.call(modelName, percent); - } - } else if (text.startsWith('plugin:progress:')) { - final percent = double.tryParse(text.substring(16)); - if (percent != null) { - onPluginPercentage?.call(percent); - } - } else if (text.startsWith('finish')) { - onFinish?.call(); - } else if (text.startsWith('error:')) { - // substring 6 to remove "error:" - onError?.call(text.substring(6)); - } - }); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart deleted file mode 100644 index 829bd2f62a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; -part 'download_offline_ai_app_bloc.freezed.dart'; - -class DownloadOfflineAIBloc - extends Bloc { - DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { - on(_handleEvent); - } - - Future _handleEvent( - DownloadOfflineAIEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetOfflineAIAppLink().send(); - await result.fold( - (app) async { - await launchUrl(Uri.parse(app.link)); - }, - (err) {}, - ); - }, - ); - } -} - -@freezed -class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { - const factory DownloadOfflineAIEvent.started() = _Started; -} - -@freezed -class DownloadOfflineAIState with _$DownloadOfflineAIState { - const factory DownloadOfflineAIState() = _DownloadOfflineAIState; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index 3c3d20039d..a90f319a94 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -1,93 +1,128 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'local_llm_listener.dart'; + part 'local_ai_bloc.freezed.dart'; -class LocalAIToggleBloc extends Bloc { - LocalAIToggleBloc() : super(const LocalAIToggleState()) { - on(_handleEvent); +class LocalAiPluginBloc extends Bloc { + LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { + on(_handleEvent); + _startListening(); + _getLocalAiState(); + } + + final listener = LocalAIStateListener(); + + @override + Future close() async { + await listener.stop(); + return super.close(); } Future _handleEvent( - LocalAIToggleEvent event, - Emitter emit, + LocalAiPluginEvent event, + Emitter emit, ) async { await event.when( - started: () async { - final result = await AIEventGetLocalAIState().send(); - _handleResult(emit, result); + didReceiveAiState: (aiState) { + emit( + LocalAiPluginState.ready( + isEnabled: aiState.enabled, + version: aiState.pluginVersion, + runningState: aiState.state, + lackOfResource: + aiState.hasLackOfResource() ? aiState.lackOfResource : null, + ), + ); + }, + didReceiveLackOfResources: (resources) { + state.maybeMap( + ready: (readyState) { + emit(readyState.copyWith(lackOfResource: resources)); + }, + orElse: () {}, + ); }, toggle: () async { - emit( - state.copyWith( - pageIndicator: const LocalAIToggleStateIndicator.loading(), - ), - ); - unawaited( - AIEventToggleLocalAI().send().then( - (result) { - if (!isClosed) { - add(LocalAIToggleEvent.handleResult(result)); - } - }, - ), + emit(LocalAiPluginState.loading()); + await AIEventToggleLocalAI().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, ); }, - handleResult: (result) { - _handleResult(emit, result); + restart: () async { + emit(LocalAiPluginState.loading()); + await AIEventRestartLocalAI().send(); }, ); } - void _handleResult( - Emitter emit, - FlowyResult result, - ) { - result.fold( - (localAI) { - emit( - state.copyWith( - pageIndicator: LocalAIToggleStateIndicator.ready(localAI.enabled), - ), - ); + void _startListening() { + listener.start( + stateCallback: (pluginState) { + add(LocalAiPluginEvent.didReceiveAiState(pluginState)); }, - (err) { - emit( - state.copyWith( - pageIndicator: LocalAIToggleStateIndicator.error(err), - ), - ); + resourceCallback: (data) { + add(LocalAiPluginEvent.didReceiveLackOfResources(data)); }, ); } + + void _getLocalAiState() { + AIEventGetLocalAIState().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, + ); + } } @freezed -class LocalAIToggleEvent with _$LocalAIToggleEvent { - const factory LocalAIToggleEvent.started() = _Started; - const factory LocalAIToggleEvent.toggle() = _Toggle; - const factory LocalAIToggleEvent.handleResult( - FlowyResult result, - ) = _HandleResult; +class LocalAiPluginEvent with _$LocalAiPluginEvent { + const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = + _DidReceiveAiState; + const factory LocalAiPluginEvent.didReceiveLackOfResources( + LackOfAIResourcePB resources, + ) = _DidReceiveLackOfResources; + const factory LocalAiPluginEvent.toggle() = _Toggle; + const factory LocalAiPluginEvent.restart() = _Restart; } @freezed -class LocalAIToggleState with _$LocalAIToggleState { - const factory LocalAIToggleState({ - @Default(LocalAIToggleStateIndicator.loading()) - LocalAIToggleStateIndicator pageIndicator, - }) = _LocalAIToggleState; -} +class LocalAiPluginState with _$LocalAiPluginState { + const LocalAiPluginState._(); -@freezed -class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator { - // when start downloading the model - const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError; - const factory LocalAIToggleStateIndicator.ready(bool isEnabled) = _Ready; - const factory LocalAIToggleStateIndicator.loading() = _Loading; + const factory LocalAiPluginState.ready({ + required bool isEnabled, + required String version, + required RunningStatePB runningState, + required LackOfAIResourcePB? lackOfResource, + }) = ReadyLocalAiPluginState; + + const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState; + + bool get isEnabled { + return maybeWhen( + ready: (isEnabled, _, __, ___) => isEnabled, + orElse: () => false, + ); + } + + bool get showIndicator { + return maybeWhen( + ready: (isEnabled, _, runningState, lackOfResource) => + runningState != RunningStatePB.Running || lackOfResource != null, + orElse: () => false, + ); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart deleted file mode 100644 index 7f1df258ea..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'local_ai_chat_bloc.freezed.dart'; - -class LocalAIChatSettingBloc - extends Bloc { - LocalAIChatSettingBloc() - : listener = LocalLLMListener(), - super(const LocalAIChatSettingState()) { - listener.start( - stateCallback: (newState) { - if (!isClosed) { - add(LocalAIChatSettingEvent.updatePluginState(newState)); - } - }, - ); - - on(_handleEvent); - } - - final LocalLLMListener listener; - - /// Handles incoming events and dispatches them to the appropriate handler. - Future _handleEvent( - LocalAIChatSettingEvent event, - Emitter emit, - ) async { - await event.when( - refreshAISetting: _handleStarted, - didLoadModelInfo: (FlowyResult result) { - result.fold( - (modelInfo) { - _fetchCurremtLLMState(); - emit( - state.copyWith( - modelInfo: modelInfo, - models: modelInfo.models, - selectedLLMModel: modelInfo.selectedModel, - aiModelProgress: const AIModelProgress.finish(), - ), - ); - }, - (err) { - emit( - state.copyWith( - aiModelProgress: AIModelProgress.finish(error: err), - ), - ); - }, - ); - }, - selectLLMConfig: (LLMModelPB llmModel) async { - final result = await AIEventUpdateLocalLLM(llmModel).send(); - result.fold( - (llmResource) { - // If all resources are downloaded, show reload plugin - if (llmResource.pendingResources.isNotEmpty) { - emit( - state.copyWith( - selectedLLMModel: llmModel, - progressIndicator: LocalAIProgress.showDownload( - llmResource, - llmModel, - ), - selectLLMState: const ChatLoadingState.finish(), - ), - ); - } else { - emit( - state.copyWith( - selectedLLMModel: llmModel, - selectLLMState: const ChatLoadingState.finish(), - progressIndicator: const LocalAIProgress.checkPluginState(), - ), - ); - } - }, - (err) { - emit( - state.copyWith( - selectLLMState: ChatLoadingState.finish(error: err), - ), - ); - }, - ); - }, - refreshLLMState: (LocalModelResourcePB llmResource) { - if (state.selectedLLMModel == null) { - Log.error( - 'Unexpected null selected config. It should be set already', - ); - return; - } - - // reload plugin if all resources are downloaded - if (llmResource.pendingResources.isEmpty) { - emit( - state.copyWith( - progressIndicator: const LocalAIProgress.checkPluginState(), - ), - ); - } else { - if (state.selectedLLMModel != null) { - // Go to download page if the selected model is downloading - if (llmResource.isDownloading) { - emit( - state.copyWith( - progressIndicator: - LocalAIProgress.startDownloading(state.selectedLLMModel!), - selectLLMState: const ChatLoadingState.finish(), - ), - ); - return; - } else { - emit( - state.copyWith( - progressIndicator: LocalAIProgress.showDownload( - llmResource, - state.selectedLLMModel!, - ), - selectLLMState: const ChatLoadingState.finish(), - ), - ); - } - } - } - }, - startDownloadModel: (LLMModelPB llmModel) { - emit( - state.copyWith( - progressIndicator: LocalAIProgress.startDownloading(llmModel), - selectLLMState: const ChatLoadingState.finish(), - ), - ); - }, - cancelDownload: () async { - final _ = await AIEventCancelDownloadLLMResource().send(); - _fetchCurremtLLMState(); - }, - finishDownload: () async { - emit( - state.copyWith( - progressIndicator: const LocalAIProgress.finishDownload(), - ), - ); - }, - updatePluginState: (LocalAIPluginStatePB pluginState) { - if (pluginState.offlineAiReady) { - AIEventRefreshLocalAIModelInfo().send().then((result) { - if (!isClosed) { - add(LocalAIChatSettingEvent.didLoadModelInfo(result)); - } - }); - - if (pluginState.state == RunningStatePB.Stopped) { - emit( - state.copyWith( - runningState: pluginState.state, - progressIndicator: const LocalAIProgress.checkPluginState(), - ), - ); - } else { - emit( - state.copyWith( - runningState: pluginState.state, - ), - ); - } - } else { - emit( - state.copyWith( - progressIndicator: const LocalAIProgress.startOfflineAIApp(), - ), - ); - } - }, - ); - } - - void _fetchCurremtLLMState() async { - final result = await AIEventGetLocalLLMState().send(); - result.fold( - (llmResource) { - if (!isClosed) { - add(LocalAIChatSettingEvent.refreshLLMState(llmResource)); - } - }, - (err) { - Log.error(err); - }, - ); - } - - /// Handles the event to fetch local AI settings when the application starts. - Future _handleStarted() async { - final result = await AIEventGetLocalAIPluginState().send(); - result.fold( - (pluginState) async { - if (!isClosed) { - add(LocalAIChatSettingEvent.updatePluginState(pluginState)); - if (pluginState.offlineAiReady) { - final result = await AIEventRefreshLocalAIModelInfo().send(); - if (!isClosed) { - add(LocalAIChatSettingEvent.didLoadModelInfo(result)); - } - } - } - }, - (err) => Log.error(err.toString()), - ); - } - - @override - Future close() async { - await listener.stop(); - return super.close(); - } -} - -@freezed -class LocalAIChatSettingEvent with _$LocalAIChatSettingEvent { - const factory LocalAIChatSettingEvent.refreshAISetting() = _RefreshAISetting; - const factory LocalAIChatSettingEvent.didLoadModelInfo( - FlowyResult result, - ) = _ModelInfo; - const factory LocalAIChatSettingEvent.selectLLMConfig(LLMModelPB config) = - _SelectLLMConfig; - - const factory LocalAIChatSettingEvent.refreshLLMState( - LocalModelResourcePB llmResource, - ) = _RefreshLLMResource; - const factory LocalAIChatSettingEvent.startDownloadModel( - LLMModelPB llmModel, - ) = _StartDownloadModel; - - const factory LocalAIChatSettingEvent.cancelDownload() = _CancelDownload; - const factory LocalAIChatSettingEvent.finishDownload() = _FinishDownload; - const factory LocalAIChatSettingEvent.updatePluginState( - LocalAIPluginStatePB pluginState, - ) = _PluginState; -} - -@freezed -class LocalAIChatSettingState with _$LocalAIChatSettingState { - const factory LocalAIChatSettingState({ - LLMModelInfoPB? modelInfo, - LLMModelPB? selectedLLMModel, - LocalAIProgress? progressIndicator, - @Default(AIModelProgress.init()) AIModelProgress aiModelProgress, - @Default(ChatLoadingState.loading()) ChatLoadingState selectLLMState, - @Default([]) List models, - @Default(RunningStatePB.Connecting) RunningStatePB runningState, - }) = _LocalAIChatSettingState; -} - -@freezed -class LocalAIProgress with _$LocalAIProgress { - // when user comes back to the setting page, it will auto detect current llm state - const factory LocalAIProgress.showDownload( - LocalModelResourcePB llmResource, - LLMModelPB llmModel, - ) = _DownloadNeeded; - - // when start downloading the model - const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) = - _Downloading; - const factory LocalAIProgress.finishDownload() = _Finish; - const factory LocalAIProgress.checkPluginState() = _CheckPluginState; - const factory LocalAIProgress.startOfflineAIApp() = _StartOfflineAIApp; -} - -@freezed -class AIModelProgress with _$AIModelProgress { - const factory AIModelProgress.init() = _AIModelProgressInit; - const factory AIModelProgress.loading() = _AIModelDownloading; - const factory AIModelProgress.finish({FlowyError? error}) = _AIModelFinish; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart deleted file mode 100644 index 4feac1247a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'local_ai_chat_toggle_bloc.freezed.dart'; - -class LocalAIChatToggleBloc - extends Bloc { - LocalAIChatToggleBloc() : super(const LocalAIChatToggleState()) { - on(_handleEvent); - } - - Future _handleEvent( - LocalAIChatToggleEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIChatState().send(); - _handleResult(emit, result); - }, - toggle: () async { - emit( - state.copyWith( - pageIndicator: const LocalAIChatToggleStateIndicator.loading(), - ), - ); - unawaited( - AIEventToggleLocalAIChat().send().then( - (result) { - if (!isClosed) { - add(LocalAIChatToggleEvent.handleResult(result)); - } - }, - ), - ); - }, - handleResult: (result) { - _handleResult(emit, result); - }, - ); - } - - void _handleResult( - Emitter emit, - FlowyResult result, - ) { - result.fold( - (localAI) { - emit( - state.copyWith( - pageIndicator: - LocalAIChatToggleStateIndicator.ready(localAI.enabled), - ), - ); - }, - (err) { - emit( - state.copyWith( - pageIndicator: LocalAIChatToggleStateIndicator.error(err), - ), - ); - }, - ); - } -} - -@freezed -class LocalAIChatToggleEvent with _$LocalAIChatToggleEvent { - const factory LocalAIChatToggleEvent.started() = _Started; - const factory LocalAIChatToggleEvent.toggle() = _Toggle; - const factory LocalAIChatToggleEvent.handleResult( - FlowyResult result, - ) = _HandleResult; -} - -@freezed -class LocalAIChatToggleState with _$LocalAIChatToggleState { - const factory LocalAIChatToggleState({ - @Default(LocalAIChatToggleStateIndicator.loading()) - LocalAIChatToggleStateIndicator pageIndicator, - }) = _LocalAIChatToggleState; -} - -@freezed -class LocalAIChatToggleStateIndicator with _$LocalAIChatToggleStateIndicator { - const factory LocalAIChatToggleStateIndicator.error(FlowyError error) = - _OnError; - const factory LocalAIChatToggleStateIndicator.ready(bool isEnabled) = _Ready; - const factory LocalAIChatToggleStateIndicator.loading() = _Loading; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart index 2c1bf34a87..3bb26a182b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc _dispatch(); } - Future _onPaymentSuccessful() async { + void _onPaymentSuccessful() { if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart index a7778d7d99..99c90faeb5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart @@ -8,11 +8,11 @@ import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; -typedef PluginStateCallback = void Function(LocalAIPluginStatePB state); -typedef LocalAIChatCallback = void Function(LocalAIChatPB chatState); +typedef PluginStateCallback = void Function(LocalAIPB state); +typedef PluginResourceCallback = void Function(LackOfAIResourcePB data); -class LocalLLMListener { - LocalLLMListener() { +class LocalAIStateListener { + LocalAIStateListener() { _parser = ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback); _subscription = RustStreamReceiver.listen( @@ -24,15 +24,14 @@ class LocalLLMListener { ChatNotificationParser? _parser; PluginStateCallback? stateCallback; - LocalAIChatCallback? chatStateCallback; - void Function()? finishStreamingCallback; + PluginResourceCallback? resourceCallback; void start({ PluginStateCallback? stateCallback, - LocalAIChatCallback? chatStateCallback, + PluginResourceCallback? resourceCallback, }) { this.stateCallback = stateCallback; - this.chatStateCallback = chatStateCallback; + this.resourceCallback = resourceCallback; } void _callback( @@ -41,11 +40,11 @@ class LocalLLMListener { ) { result.map((r) { switch (ty) { - case ChatNotification.UpdateChatPluginState: - stateCallback?.call(LocalAIPluginStatePB.fromBuffer(r)); + case ChatNotification.UpdateLocalAIState: + stateCallback?.call(LocalAIPB.fromBuffer(r)); break; - case ChatNotification.UpdateLocalChatAI: - chatStateCallback?.call(LocalAIChatPB.fromBuffer(r)); + case ChatNotification.LocalAIResourceUpdated: + resourceCallback?.call(LackOfAIResourcePB.fromBuffer(r)); break; default: break; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart new file mode 100644 index 0000000000..f5c4209028 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:equatable/equatable.dart'; + +part 'ollama_setting_bloc.freezed.dart'; + +class OllamaSettingBloc extends Bloc { + OllamaSettingBloc() : super(const OllamaSettingState()) { + on(_handleEvent); + } + + Future _handleEvent( + OllamaSettingEvent event, + Emitter emit, + ) async { + event.when( + started: () { + AIEventGetLocalAISetting().send().fold( + (setting) { + if (!isClosed) { + add(OllamaSettingEvent.didLoadSetting(setting)); + } + }, + Log.error, + ); + }, + didLoadSetting: (setting) => _updateSetting(setting, emit), + updateSetting: (setting) => _updateSetting(setting, emit), + onEdit: (content, settingType) { + final updatedSubmittedItems = state.submittedItems + .map( + (item) => item.settingType == settingType + ? SubmittedItem( + content: content, + settingType: item.settingType, + ) + : item, + ) + .toList(); + + // Convert both lists to maps: {settingType: content} + final updatedMap = { + for (final item in updatedSubmittedItems) + item.settingType: item.content, + }; + + final inputMap = { + for (final item in state.inputItems) item.settingType: item.content, + }; + + // Compare maps instead of lists + final isEdited = !const MapEquality() + .equals(updatedMap, inputMap); + + emit( + state.copyWith( + submittedItems: updatedSubmittedItems, + isEdited: isEdited, + ), + ); + }, + submit: () { + final setting = LocalAISettingPB(); + final settingUpdaters = { + SettingType.serverUrl: (value) => setting.serverUrl = value, + SettingType.chatModel: (value) => setting.chatModelName = value, + SettingType.embeddingModel: (value) => + setting.embeddingModelName = value, + }; + + for (final item in state.submittedItems) { + settingUpdaters[item.settingType]?.call(item.content); + } + add(OllamaSettingEvent.updateSetting(setting)); + AIEventUpdateLocalAISetting(setting).send().fold( + (_) => Log.info('AI setting updated successfully'), + (err) => Log.error("update ai setting failed: $err"), + ); + }, + ); + } + + void _updateSetting( + LocalAISettingPB setting, + Emitter emit, + ) { + emit( + state.copyWith( + setting: setting, + inputItems: _createInputItems(setting), + submittedItems: _createSubmittedItems(setting), + isEdited: false, // Reset to false when the setting is loaded/updated. + ), + ); + } + + List _createInputItems(LocalAISettingPB setting) => [ + SettingItem( + content: setting.serverUrl, + hintText: 'http://localhost:11434', + settingType: SettingType.serverUrl, + ), + SettingItem( + content: setting.chatModelName, + hintText: 'llama3.1', + settingType: SettingType.chatModel, + ), + SettingItem( + content: setting.embeddingModelName, + hintText: 'nomic-embed-text', + settingType: SettingType.embeddingModel, + ), + ]; + + List _createSubmittedItems(LocalAISettingPB setting) => [ + SubmittedItem( + content: setting.serverUrl, + settingType: SettingType.serverUrl, + ), + SubmittedItem( + content: setting.chatModelName, + settingType: SettingType.chatModel, + ), + SubmittedItem( + content: setting.embeddingModelName, + settingType: SettingType.embeddingModel, + ), + ]; +} + +// Create an enum for setting type. +enum SettingType { + serverUrl, + chatModel, + embeddingModel; // semicolon needed after the enum values + + String get title { + switch (this) { + case SettingType.serverUrl: + return 'Ollama server url'; + case SettingType.chatModel: + return 'Chat model name'; + case SettingType.embeddingModel: + return 'Embedding model name'; + } + } +} + +class SettingItem extends Equatable { + const SettingItem({ + required this.content, + required this.hintText, + required this.settingType, + }); + final String content; + final String hintText; + final SettingType settingType; + @override + List get props => [content, settingType]; +} + +class SubmittedItem extends Equatable { + const SubmittedItem({ + required this.content, + required this.settingType, + }); + final String content; + final SettingType settingType; + + @override + List get props => [content, settingType]; +} + +@freezed +class OllamaSettingEvent with _$OllamaSettingEvent { + const factory OllamaSettingEvent.started() = _Started; + const factory OllamaSettingEvent.didLoadSetting(LocalAISettingPB setting) = + _DidLoadSetting; + const factory OllamaSettingEvent.updateSetting(LocalAISettingPB setting) = + _UpdateSetting; + const factory OllamaSettingEvent.onEdit( + String content, + SettingType settingType, + ) = _OnEdit; + const factory OllamaSettingEvent.submit() = _OnSubmit; +} + +@freezed +class OllamaSettingState with _$OllamaSettingState { + const factory OllamaSettingState({ + LocalAISettingPB? setting, + @Default([ + SettingItem( + content: 'http://localhost:11434', + hintText: 'http://localhost:11434', + settingType: SettingType.serverUrl, + ), + SettingItem( + content: 'llama3.1', + hintText: 'llama3.1', + settingType: SettingType.chatModel, + ), + SettingItem( + content: 'nomic-embed-text', + hintText: 'nomic-embed-text', + settingType: SettingType.embeddingModel, + ), + ]) + List inputItems, + @Default([]) List submittedItems, + @Default(false) bool isEdited, + }) = _PluginStateState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart deleted file mode 100644 index 4f24309bde..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; - -part 'plugin_state_bloc.freezed.dart'; - -class PluginStateBloc extends Bloc { - PluginStateBloc() - : listener = LocalLLMListener(), - super( - const PluginStateState( - action: PluginStateAction.init(), - ), - ) { - listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateState(pluginState)); - } - }, - ); - - on(_handleEvent); - } - - final LocalLLMListener listener; - - @override - Future close() async { - await listener.stop(); - return super.close(); - } - - Future _handleEvent( - PluginStateEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIPluginState().send(); - result.fold( - (pluginState) { - if (!isClosed) { - add(PluginStateEvent.updateState(pluginState)); - } - }, - (err) => Log.error(err.toString()), - ); - }, - updateState: (LocalAIPluginStatePB pluginState) { - // if the offline ai is not started, ask user to start it - if (pluginState.offlineAiReady) { - // Chech state of the plugin - switch (pluginState.state) { - case RunningStatePB.Connecting: - emit( - const PluginStateState( - action: PluginStateAction.loadingPlugin(), - ), - ); - case RunningStatePB.Running: - emit(const PluginStateState(action: PluginStateAction.ready())); - break; - default: - emit( - state.copyWith(action: const PluginStateAction.restartPlugin()), - ); - break; - } - } else { - emit( - const PluginStateState( - action: PluginStateAction.startAIOfflineApp(), - ), - ); - } - }, - restartLocalAI: () async { - emit( - const PluginStateState(action: PluginStateAction.loadingPlugin()), - ); - unawaited(AIEventRestartLocalAIChat().send()); - }, - openModelDirectory: () async { - final result = await AIEventGetModelStorageDirectory().send(); - result.fold( - (data) { - afLaunchUri(Uri.file(data.filePath)); - }, - (err) => Log.error(err.toString()), - ); - }, - downloadOfflineAIApp: () async { - final result = await AIEventGetOfflineAIAppLink().send(); - await result.fold( - (app) async { - await launchUrl(Uri.parse(app.link)); - }, - (err) {}, - ); - }, - ); - } -} - -@freezed -class PluginStateEvent with _$PluginStateEvent { - const factory PluginStateEvent.started() = _Started; - const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) = - _UpdatePluginState; - const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; - const factory PluginStateEvent.openModelDirectory() = - _OpenModelStorageDirectory; - const factory PluginStateEvent.downloadOfflineAIApp() = _DownloadOfflineAIApp; -} - -@freezed -class PluginStateState with _$PluginStateState { - const factory PluginStateState({ - required PluginStateAction action, - }) = _PluginStateState; -} - -@freezed -class PluginStateAction with _$PluginStateAction { - const factory PluginStateAction.init() = _Init; - const factory PluginStateAction.loadingPlugin() = _LoadingPlugin; - const factory PluginStateAction.ready() = _Ready; - const factory PluginStateAction.restartPlugin() = _RestartPlugin; - const factory PluginStateAction.startAIOfflineApp() = _StartAIOfflineApp; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 91ac63944c..0141283765 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -1,7 +1,8 @@ +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; import 'package:appflowy/user/application/user_listener.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-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -10,60 +11,55 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; +const String aiModelsGlobalActiveModel = "ai_models_global_active_model"; + class SettingsAIBloc extends Bloc { SettingsAIBloc( this.userProfile, this.workspaceId, - AFRolePB? currentWorkspaceMemberRole, ) : _userListener = UserListener(userProfile: userProfile), - _userService = UserBackendService(userId: userProfile.id), + _aiModelSwitchListener = + AIModelSwitchListener(objectId: aiModelsGlobalActiveModel), super( SettingsAIState( userProfile: userProfile, - currentWorkspaceMemberRole: currentWorkspaceMemberRole, ), ) { + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) { + if (!isClosed) { + _loadModelList(); + } + }, + ); _dispatch(); - - if (currentWorkspaceMemberRole == null) { - _userService.getWorkspaceMember().then((result) { - result.fold( - (member) { - if (!isClosed) { - add(SettingsAIEvent.refreshMember(member)); - } - }, - (err) { - Log.error(err); - }, - ); - }); - } } final UserListener _userListener; final UserProfilePB userProfile; - final UserBackendService _userService; final String workspaceId; + final AIModelSwitchListener _aiModelSwitchListener; @override Future close() async { await _userListener.stop(); + await _aiModelSwitchListener.stop(); return super.close(); } void _dispatch() { - on((event, emit) { - event.when( + on((event, emit) async { + await event.when( started: () { _userListener.start( onProfileUpdated: _onProfileUpdated, onUserWorkspaceSettingUpdated: (settings) { if (!isClosed) { - add(SettingsAIEvent.didLoadAISetting(settings)); + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, ); + _loadModelList(); _loadUserWorkspaceSetting(); }, didReceiveUserProfile: (userProfile) { @@ -78,10 +74,18 @@ class SettingsAIBloc extends Bloc { !(state.aiSettings?.disableSearchIndexing ?? false), ); }, - selectModel: (AIModelPB model) { - _updateUserWorkspaceSetting(model: model); + selectModel: (AIModelPB model) async { + if (!model.isLocal) { + await _updateUserWorkspaceSetting(model: model.name); + } + await AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: aiModelsGlobalActiveModel, + selectedModel: model, + ), + ).send(); }, - didLoadAISetting: (UseAISettingPB settings) { + didLoadWorkspaceSetting: (WorkspaceSettingsPB settings) { emit( state.copyWith( aiSettings: settings, @@ -89,17 +93,21 @@ class SettingsAIBloc extends Bloc { ), ); }, - refreshMember: (member) { - emit(state.copyWith(currentWorkspaceMemberRole: member.role)); + didLoadAvailableModels: (AvailableModelsPB models) { + emit( + state.copyWith( + availableModels: models, + ), + ); }, ); }); } - void _updateUserWorkspaceSetting({ + Future> _updateUserWorkspaceSetting({ bool? disableSearchIndexing, - AIModelPB? model, - }) { + String? model, + }) async { final payload = UpdateUserWorkspaceSettingPB( workspaceId: workspaceId, ); @@ -109,7 +117,12 @@ class SettingsAIBloc extends Bloc { if (model != null) { payload.aiModel = model; } - UserEventUpdateWorkspaceSetting(payload).send(); + final result = await UserEventUpdateWorkspaceSetting(payload).send(); + result.fold( + (ok) => Log.info('Update workspace setting success'), + (err) => Log.error('Update workspace setting failed: $err'), + ); + return result; } void _onProfileUpdated( @@ -120,12 +133,24 @@ class SettingsAIBloc extends Bloc { (err) => Log.error(err), ); + void _loadModelList() { + AIEventGetServerAvailableModels().send().then((result) { + result.fold((models) { + if (!isClosed) { + add(SettingsAIEvent.didLoadAvailableModels(models)); + } + }, (err) { + Log.error(err); + }); + }); + } + void _loadUserWorkspaceSetting() { final payload = UserWorkspaceIdPB(workspaceId: workspaceId); UserEventGetWorkspaceSetting(payload).send().then((result) { result.fold((settings) { if (!isClosed) { - add(SettingsAIEvent.didLoadAISetting(settings)); + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, (err) { Log.error(err); @@ -137,27 +162,29 @@ class SettingsAIBloc extends Bloc { @freezed class SettingsAIEvent with _$SettingsAIEvent { const factory SettingsAIEvent.started() = _Started; - const factory SettingsAIEvent.didLoadAISetting( - UseAISettingPB settings, + const factory SettingsAIEvent.didLoadWorkspaceSetting( + WorkspaceSettingsPB settings, ) = _DidLoadWorkspaceSetting; const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; - const factory SettingsAIEvent.refreshMember(WorkspaceMemberPB member) = - _RefreshMember; const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; const factory SettingsAIEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; + + const factory SettingsAIEvent.didLoadAvailableModels( + AvailableModelsPB models, + ) = _DidLoadAvailableModels; } @freezed class SettingsAIState with _$SettingsAIState { const factory SettingsAIState({ required UserProfilePB userProfile, - UseAISettingPB? aiSettings, - AFRolePB? currentWorkspaceMemberRole, + WorkspaceSettingsPB? aiSettings, + AvailableModelsPB? availableModels, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart index 37027bcf38..99b9eaa2c9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; @@ -17,6 +19,7 @@ import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:universal_platform/universal_platform.dart'; part 'appearance_cubit.freezed.dart'; @@ -97,7 +100,19 @@ class AppearanceSettingsCubit extends Cubit { Future setTheme(String themeName) async { _appearanceSettings.theme = themeName; unawaited(_saveAppearanceSettings()); - emit(state.copyWith(appTheme: await AppTheme.fromName(themeName))); + try { + final theme = await AppTheme.fromName(themeName); + emit(state.copyWith(appTheme: theme)); + } catch (e) { + Log.error("Error setting theme: $e"); + if (UniversalPlatform.isMacOS) { + showToastNotification( + message: + LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(), + type: ToastificationType.error, + ); + } + } } /// Reset the current user selected theme back to the default @@ -309,7 +324,6 @@ ThemeModePB _themeModeToPB(ThemeMode themeMode) { case ThemeMode.dark: return ThemeModePB.Dark; case ThemeMode.system: - default: return ThemeModePB.System; } } @@ -358,8 +372,6 @@ enum AppFlowyTextDirection { return TextDirectionPB.RTL; case AppFlowyTextDirection.auto: return TextDirectionPB.AUTO; - default: - return TextDirectionPB.FALLBACK; } } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart index e1ea22a6eb..c1e539cf58 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -14,9 +14,10 @@ class DesktopAppearance extends BaseAppearance { ) { assert(codeFontFamily.isNotEmpty); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + fontFamily = fontFamily.isEmpty ? defaultFontFamily : fontFamily; + + final isLight = brightness == Brightness.light; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; final colorScheme = ColorScheme( brightness: brightness, @@ -150,6 +151,7 @@ class DesktopAppearance extends BaseAppearance { scrollbarColor: theme.scrollbarColor, scrollbarHoverColor: theme.scrollbarHoverColor, lightIconColor: theme.lightIconColor, + toolbarHoverColor: theme.toolbarHoverColor, ), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 63235ba217..46eddd53ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -28,13 +28,12 @@ class MobileAppearance extends BaseAppearance { fontWeight: FontWeight.w400, ); + final isLight = brightness == Brightness.light; final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; - final colorTheme = brightness == Brightness.light + final colorTheme = isLight ? ColorScheme( brightness: brightness, primary: _primaryColor, @@ -49,7 +48,7 @@ class MobileAppearance extends BaseAppearance { error: const Color(0xffFB006D), onError: const Color(0xffFB006D), outline: const Color(0xffe3e3e3), - outlineVariant: const Color(0xffCBD5E0).withOpacity(0.24), + outlineVariant: const Color(0xffCBD5E0).withValues(alpha: 0.24), //Snack bar surface: Colors.white, onSurface: _onSurfaceColor, // text/body color @@ -71,13 +70,9 @@ class MobileAppearance extends BaseAppearance { onSurface: const Color(0xffC5C6C7), // text/body color surfaceContainerHighest: theme.sidebarBg, ); - final hintColor = brightness == Brightness.light - ? const Color(0x991F2329) - : _hintColorInDarkMode; - final onBackground = - brightness == Brightness.light ? _onBackgroundColor : Colors.white; - final background = - brightness == Brightness.light ? Colors.white : const Color(0xff121212); + final hintColor = isLight ? const Color(0x991F2329) : _hintColorInDarkMode; + final onBackground = isLight ? _onBackgroundColor : Colors.white; + final background = isLight ? Colors.white : const Color(0xff121212); return ThemeData( useMaterial3: false, @@ -280,6 +275,7 @@ class MobileAppearance extends BaseAppearance { scrollbarColor: theme.scrollbarColor, scrollbarHoverColor: theme.scrollbarHoverColor, lightIconColor: theme.lightIconColor, + toolbarHoverColor: theme.toolbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), ], diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart index ab324df87f..df880891e9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart @@ -29,7 +29,7 @@ class SettingsBillingBloc required Int64 userId, }) : super(const _Initial()) { _userService = UserBackendService(userId: userId); - _service = WorkspaceService(workspaceId: workspaceId); + _service = WorkspaceService(workspaceId: workspaceId, userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart index f7512a834e..26975b00ff 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -23,7 +23,10 @@ class SettingsPlanBloc extends Bloc { required this.workspaceId, required Int64 userId, }) : super(const _Initial()) { - _service = WorkspaceService(workspaceId: workspaceId); + _service = WorkspaceService( + workspaceId: workspaceId, + userId: userId, + ); _userService = UserBackendService(userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); @@ -43,7 +46,7 @@ class SettingsPlanBloc extends Bloc { FlowyError? error; final usageResult = snapshots.first.fold( - (s) => s as WorkspaceUsagePB, + (s) => s as WorkspaceUsagePB?, (f) { error = f; return null; @@ -148,7 +151,7 @@ class SettingsPlanBloc extends Bloc { usage.freeze(); final newUsage = usage.rebuild((value) { - if (!newInfo.hasAIMax && !newInfo.hasAIOnDevice) { + if (!newInfo.hasAIMax) { value.aiResponsesUnlimited = false; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 0578d9808b..726e95bb9e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -2,7 +2,6 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -91,8 +90,8 @@ class SettingsDialogBloc AFRolePB? currentWorkspaceMemberRole, ]) async { if ([ - AuthenticatorPB.Local, - ].contains(userProfile.authenticator)) { + AuthTypePB.Local, + ].contains(userProfile.authType)) { return false; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart index 25b51c9a81..af95d5af5a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart @@ -53,8 +53,8 @@ class SettingsShortcutService { } } - /// Extracts shortcuts from the saved json file. The shortcuts in the saved file consist of [List]. - /// This list needs to be converted to List. This function is intended to facilitate the same. + // Extracts shortcuts from the saved json file. The shortcuts in the saved file consist of [List]. + // This list needs to be converted to List. This function is intended to facilitate the same. List getShortcutsFromJson(String savedJson) { final shortcuts = EditorShortcuts.fromJson(jsonDecode(savedJson)); return shortcuts.commandShortcuts; 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 1737494530..56d6ae8cc8 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 @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; @@ -182,17 +183,24 @@ class SidebarPlanBloc extends Bloc { ); } - void _checkWorkspaceUsage() { - if (state.workspaceId != null) { - final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!); - UserEventGetWorkspaceUsage(payload).send().then((result) { - result.onSuccess( - (usage) { - add(SidebarPlanEvent.updateWorkspaceUsage(usage)); - }, - ); - }); + Future _checkWorkspaceUsage() async { + if (state.workspaceId == null || state.userProfile == null) { + return; } + + await WorkspaceService( + workspaceId: state.workspaceId!, + userId: state.userProfile!.id, + ).getWorkspaceUsage().then((result) { + result.fold( + (usage) { + if (!isClosed && usage != null) { + add(SidebarPlanEvent.updateWorkspaceUsage(usage)); + } + }, + (error) => Log.error("Failed to get workspace usage: $error"), + ); + }); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index 0f1dc4e987..6d6ce05051 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -331,16 +331,6 @@ class SpaceBloc extends Bloc { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); - Log.info( - 'receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', - ); - - for (var i = 0; i < spaces.length; i++) { - Log.info( - 'receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', - ); - } - emit( state.copyWith( spaces: spaces, @@ -496,8 +486,10 @@ class SpaceBloc extends Bloc { } void _initial(UserProfilePB userProfile, String workspaceId) { - Log.info('initial(or reset) space bloc: $workspaceId, ${userProfile.id}'); - _workspaceService = WorkspaceService(workspaceId: workspaceId); + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); this.userProfile = userProfile; this.workspaceId = workspaceId; @@ -507,7 +499,6 @@ class SpaceBloc extends Bloc { workspaceId: workspaceId, )..start( sectionChanged: (result) async { - Log.info('did receive section views changed'); if (isClosed) { return; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart index 56faa9f8d8..2f62177661 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -54,17 +54,27 @@ class SettingsUserViewBloc extends Bloc { ); }); }, - removeUserIcon: () { - // Empty Icon URL = No icon - _userService.updateUserProfile(iconUrl: "").then((result) { + updateUserEmail: (String email) { + _userService.updateUserProfile(email: email).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, - updateUserEmail: (String email) { - _userService.updateUserProfile(email: email).then((result) { + updateUserPassword: (String oldPassword, String newPassword) { + _userService + .updateUserProfile(password: newPassword) + .then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + removeUserIcon: () { + // Empty Icon URL = No icon + _userService.updateUserProfile(iconUrl: "").then((result) { result.fold( (l) => null, (err) => Log.error(err), @@ -104,10 +114,19 @@ class SettingsUserViewBloc extends Bloc { @freezed class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.initial() = _Initial; - const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; - const factory SettingsUserEvent.updateUserEmail(String email) = _UpdateEmail; - const factory SettingsUserEvent.updateUserIcon({required String iconUrl}) = - _UpdateUserIcon; + const factory SettingsUserEvent.updateUserName({ + required String name, + }) = _UpdateUserName; + const factory SettingsUserEvent.updateUserEmail({ + required String email, + }) = _UpdateEmail; + const factory SettingsUserEvent.updateUserIcon({ + required String iconUrl, + }) = _UpdateUserIcon; + const factory SettingsUserEvent.updateUserPassword({ + required String oldPassword, + required String newPassword, + }) = _UpdateUserPassword; const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 7f32a86d1c..0e0b912a08 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -44,7 +44,7 @@ class UserWorkspaceBloc extends Bloc { final currentWorkspace = result.$1; final workspaces = result.$2; final isCollabWorkspaceOn = - userProfile.authenticator == AuthenticatorPB.AppFlowyCloud && + userProfile.authType == AuthTypePB.Server && FeatureFlag.collaborativeWorkspace.isOn; Log.info( 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' @@ -52,7 +52,10 @@ class UserWorkspaceBloc extends Bloc { ); if (currentWorkspace != null && result.$3 == true) { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); - await _userService.openWorkspace(currentWorkspace.workspaceId); + await _userService.openWorkspace( + currentWorkspace.workspaceId, + currentWorkspace.workspaceAuthType, + ); } emit( @@ -86,10 +89,15 @@ class UserWorkspaceBloc extends Bloc { Log.info( 'fetch workspaces: try to open workspace: ${currentWorkspace.workspaceId}', ); - add(OpenWorkspace(currentWorkspace.workspaceId)); + add( + OpenWorkspace( + currentWorkspace.workspaceId, + currentWorkspace.workspaceAuthType, + ), + ); } }, - createWorkspace: (name) async { + createWorkspace: (name, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -99,7 +107,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.createUserWorkspace(name); + final result = await _userService.createUserWorkspace( + name, + authType, + ); final workspaces = result.fold( (s) => [...state.workspaces, s], (e) => state.workspaces, @@ -118,7 +129,12 @@ class UserWorkspaceBloc extends Bloc { result ..onSuccess((s) { Log.info('create workspace success: $s'); - add(OpenWorkspace(s.workspaceId)); + add( + OpenWorkspace( + s.workspaceId, + s.workspaceAuthType, + ), + ); }) ..onFailure((f) { Log.error('create workspace error: $f'); @@ -171,7 +187,12 @@ class UserWorkspaceBloc extends Bloc { Log.info('delete workspace success: $workspaceId'); // if the current workspace is deleted, open the first workspace if (state.currentWorkspace?.workspaceId == workspaceId) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.workspaceAuthType, + ), + ); } }) ..onFailure((f) { @@ -179,7 +200,12 @@ class UserWorkspaceBloc extends Bloc { // if the workspace is deleted but return an error, we need to // open the first workspace if (!containsDeletedWorkspace) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.workspaceAuthType, + ), + ); } }); emit( @@ -193,7 +219,7 @@ class UserWorkspaceBloc extends Bloc { ), ); }, - openWorkspace: (workspaceId) async { + openWorkspace: (workspaceId, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -203,7 +229,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.openWorkspace(workspaceId); + final result = await _userService.openWorkspace( + workspaceId, + authType, + ); final currentWorkspace = result.fold( (s) => state.workspaces.firstWhereOrNull( (e) => e.workspaceId == workspaceId, @@ -337,7 +366,12 @@ class UserWorkspaceBloc extends Bloc { Log.info('leave workspace success: $workspaceId'); // if leaving the current workspace, open the first workspace if (state.currentWorkspace?.workspaceId == workspaceId) { - add(OpenWorkspace(workspaces.first.workspaceId)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.workspaceAuthType, + ), + ); } }) ..onFailure((f) { @@ -441,12 +475,16 @@ class UserWorkspaceBloc extends Bloc { class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.initial() = Initial; const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; - const factory UserWorkspaceEvent.createWorkspace(String name) = - CreateWorkspace; + const factory UserWorkspaceEvent.createWorkspace( + String name, + AuthTypePB authType, + ) = CreateWorkspace; const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = DeleteWorkspace; - const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = - OpenWorkspace; + const factory UserWorkspaceEvent.openWorkspace( + String workspaceId, + AuthTypePB authType, + ) = OpenWorkspace; const factory UserWorkspaceEvent.renameWorkspace( String workspaceId, String name, diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index 2a4c472397..7c2a4d9b64 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -262,7 +262,7 @@ class ViewBloc extends Bloc { }, updateIcon: (value) async { await ViewBackendService.updateViewIcon( - viewId: view.id, + view: view, viewIcon: view.icon.toEmojiIconData(), ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 0f114d7ff4..fcd991fcf9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -71,6 +71,11 @@ extension ViewExtension on ViewPB { name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name; bool get isDocument => pluginType == PluginType.document; + bool get isDatabase => [ + PluginType.grid, + PluginType.board, + PluginType.calendar, + ].contains(pluginType); Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { @@ -329,6 +334,7 @@ extension ViewLayoutExtension on ViewLayoutPB { bool get shrinkWrappable => switch (this) { ViewLayoutPB.Grid => true, + ViewLayoutPB.Board => true, _ => false, }; diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart new file mode 100644 index 0000000000..251131d849 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'view_lock_status_bloc.freezed.dart'; + +class ViewLockStatusBloc + extends Bloc { + ViewLockStatusBloc({ + required this.view, + }) : viewBackendSvc = ViewBackendService(), + listener = ViewListener(viewId: view.id), + super(ViewLockStatusState.init(view)) { + on( + (event, emit) async { + await event.when( + initial: () async { + listener.start( + onViewUpdated: (view) async { + add(ViewLockStatusEvent.updateLockStatus(view.isLocked)); + }, + ); + + final result = await ViewBackendService.getView(view.id); + final latestView = result.fold( + (view) => view, + (_) => view, + ); + emit( + state.copyWith( + view: latestView, + isLocked: latestView.isLocked, + isLoadingLockStatus: false, + ), + ); + }, + lock: () async { + final result = await ViewBackendService.lockView(view.id); + final isLocked = result.fold( + (_) => true, + (_) => false, + ); + add( + ViewLockStatusEvent.updateLockStatus( + isLocked, + ), + ); + }, + unlock: () async { + final result = await ViewBackendService.unlockView(view.id); + final isLocked = result.fold( + (_) => false, + (_) => true, + ); + add( + ViewLockStatusEvent.updateLockStatus( + isLocked, + lockCounter: state.lockCounter + 1, + ), + ); + }, + updateLockStatus: (isLocked, lockCounter) { + state.view.freeze(); + final updatedView = state.view.rebuild( + (update) => update.isLocked = isLocked, + ); + emit( + state.copyWith( + view: updatedView, + isLocked: isLocked, + lockCounter: lockCounter ?? state.lockCounter, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewBackendService viewBackendSvc; + final ViewListener listener; + + @override + Future close() async { + await listener.stop(); + + return super.close(); + } +} + +@freezed +class ViewLockStatusEvent with _$ViewLockStatusEvent { + const factory ViewLockStatusEvent.initial() = Initial; + const factory ViewLockStatusEvent.lock() = Lock; + const factory ViewLockStatusEvent.unlock() = Unlock; + const factory ViewLockStatusEvent.updateLockStatus( + bool isLocked, { + int? lockCounter, + }) = UpdateLockStatus; +} + +@freezed +class ViewLockStatusState with _$ViewLockStatusState { + const factory ViewLockStatusState({ + required ViewPB view, + required bool isLocked, + required int lockCounter, + @Default(true) bool isLoadingLockStatus, + }) = _ViewLockStatusState; + + factory ViewLockStatusState.init(ViewPB view) => ViewLockStatusState( + view: view, + isLocked: false, + lockCounter: 0, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart index d20aed8c1b..709515f1b3 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/me import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -190,14 +192,25 @@ class ViewBackendService { } static Future> updateViewIcon({ - required String viewId, + required ViewPB view, required EmojiIconData viewIcon, }) { + final viewId = view.id; + final oldIcon = view.icon.toEmojiIconData(); final icon = viewIcon.toViewIcon(); final payload = UpdateViewIconPayloadPB.create() ..viewId = viewId ..icon = icon; - + if (oldIcon.type == FlowyIconType.custom && + viewIcon.emoji != oldIcon.emoji) { + DocumentEventDeleteFile( + DeleteFilePB(url: oldIcon.emoji), + ).send().onFailure((e) { + Log.error( + 'updateViewIcon error while deleting :${oldIcon.emoji}, error: ${e.msg}, ${e.code}', + ); + }); + } return FolderEventUpdateViewIcon(payload).send(); } @@ -392,4 +405,14 @@ class ViewBackendService { return (publishedPages.isNotEmpty, publishedPages); } + + static Future> lockView(String viewId) async { + final payload = ViewIdPB()..value = viewId; + return FolderEventLockView(payload).send(); + } + + static Future> unlockView(String viewId) async { + final payload = ViewIdPB()..value = viewId; + return FolderEventUnlockView(payload).send(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart index d8d5db45b4..ed06f16c8f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -2,6 +2,7 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -64,7 +65,8 @@ class WorkspaceBloc extends Bloc { String desc, Emitter emit, ) async { - final result = await userService.createWorkspace(name, desc); + final result = + await userService.createUserWorkspace(name, AuthTypePB.Server); emit( result.fold( (workspace) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index b958e5cd30..ae6220994e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -1,15 +1,18 @@ import 'dart:async'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart' as fixnum; class WorkspaceService { - WorkspaceService({required this.workspaceId}); + WorkspaceService({required this.workspaceId, required this.userId}); final String workspaceId; + final fixnum.Int64 userId; Future> createView({ required String name, @@ -82,7 +85,18 @@ class WorkspaceService { return FolderEventMoveView(payload).send(); } - Future> getWorkspaceUsage() { + Future> getWorkspaceUsage() async { + final request = WorkspaceMemberIdPB()..uid = userId; + final result = await UserEventGetMemberInfo(request).send(); + final isOwner = result.fold( + (member) => member.role.isOwner, + (_) => false, + ); + + if (!isOwner) { + return FlowyResult.success(null); + } + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); return UserEventGetWorkspaceUsage(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart index 8eb7765c3a..648712bd15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -4,7 +4,6 @@ import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_v import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -135,13 +134,17 @@ class CommandPaletteModal extends StatelessWidget { builder: (context, state) => FlowyDialog( alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 100), - constraints: const BoxConstraints(maxHeight: 420, maxWidth: 510), + constraints: const BoxConstraints( + maxHeight: 600, + maxWidth: 800, + minHeight: 600, + ), expandHeight: false, child: shortcutBuilder( + // Change mainAxisSize to max so Expanded works correctly. Column( - mainAxisSize: MainAxisSize.min, children: [ - SearchField(query: state.query, isLoading: state.isLoading), + SearchField(query: state.query, isLoading: state.searching), if (state.query?.isEmpty ?? true) ...[ const Divider(height: 0), Flexible( @@ -150,23 +153,26 @@ class CommandPaletteModal extends StatelessWidget { ), ), ], - if (state.results.isNotEmpty && + if (state.combinedResponseItems.isNotEmpty && (state.query?.isNotEmpty ?? false)) ...[ const Divider(height: 0), Flexible( - child: SearchResultsList( + child: SearchResultList( trash: state.trash, - results: state.results, + resultItems: state.combinedResponseItems.values.toList(), + resultSummaries: state.resultSummaries, ), ), - ] else if ((state.query?.isNotEmpty ?? false) && - !state.isLoading) ...[ - const _NoResultsHint(), + ] + // When there are no results and the query is not empty and not loading, + // show the no results message, centered in the available space. + else if ((state.query?.isNotEmpty ?? false) && + !state.searching) ...[ + const Divider(height: 0), + Expanded( + child: const _NoResultsHint(), + ), ], - _CommandPaletteFooter( - shouldShow: state.results.isNotEmpty && - (state.query?.isNotEmpty ?? false), - ), ], ), ), @@ -175,57 +181,16 @@ class CommandPaletteModal extends StatelessWidget { } } +/// Updated _NoResultsHint now centers its content. class _NoResultsHint extends StatelessWidget { const _NoResultsHint(); @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: FlowyText.regular( - LocaleKeys.commandPalette_noResultsHint.tr(), - textAlign: TextAlign.left, - ), - ), - ], - ); - } -} - -class _CommandPaletteFooter extends StatelessWidget { - const _CommandPaletteFooter({required this.shouldShow}); - - final bool shouldShow; - - @override - Widget build(BuildContext context) { - if (!shouldShow) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4), - ), - child: const FlowyText.semibold('TAB', fontSize: 10), - ), - const HSpace(4), - FlowyText(LocaleKeys.commandPalette_navigateHint.tr(), fontSize: 11), - ], + return Center( + child: FlowyText.regular( + LocaleKeys.commandPalette_noResultsHint.tr(), + textAlign: TextAlign.center, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart index b0f87005d2..3bc160ee81 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart @@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -52,7 +52,7 @@ class RecentViewsList extends StatelessWidget { ) : FlowySvg(view.iconData, size: const Size.square(20)); - return RecentViewTile( + return SearchRecentViewCell( icon: SizedBox(width: 24, child: icon), view: view, onSelected: onSelected, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart index c18024a909..1586ab0a7e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart @@ -7,7 +7,6 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -25,28 +24,31 @@ class SearchField extends StatefulWidget { class _SearchFieldState extends State { late final FocusNode focusNode; - late final controller = TextEditingController(text: widget.query); + late final TextEditingController controller; @override void initState() { super.initState(); - focusNode = FocusNode( - onKeyEvent: (node, event) { - if (node.hasFocus && - event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.arrowDown) { - node.nextFocus(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); + controller = TextEditingController(text: widget.query); + focusNode = FocusNode(onKeyEvent: _handleKeyEvent); focusNode.requestFocus(); - controller.selection = TextSelection( - baseOffset: 0, - extentOffset: controller.text.length, - ); + // Update the text selection after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (node.hasFocus && + event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.nextFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } @override @@ -56,21 +58,83 @@ class _SearchFieldState extends State { super.dispose(); } + Widget _buildSuffixIcon(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + final clearIcon = Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AFThemeExtension.of(context).lightGreyHover, + ), + child: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(16), + ), + ); + return AnimatedOpacity( + opacity: hasText ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: hasText + ? FlowyTooltip( + message: LocaleKeys.commandPalette_clearSearchTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _clearSearch, + child: clearIcon, + ), + ), + ) + : clearIcon, + ); + }, + ); + } + @override Widget build(BuildContext context) { + // Cache theme and text styles + final theme = Theme.of(context); + final textStyle = theme.textTheme.bodySmall?.copyWith(fontSize: 14); + final hintStyle = theme.textTheme.bodySmall?.copyWith( + fontSize: 14, + color: theme.hintColor, + ); + + // Choose the leading icon based on loading state + final Widget leadingIcon = widget.isLoading + ? FlowyTooltip( + message: LocaleKeys.commandPalette_loadingTooltip.tr(), + child: const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(3.0), + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + ) + : SizedBox( + width: 20, + height: 20, + child: FlowySvg( + FlowySvgs.search_m, + color: theme.hintColor, + ), + ); + return Row( children: [ const HSpace(12), - FlowySvg( - FlowySvgs.search_m, - color: Theme.of(context).hintColor, - ), + leadingIcon, Expanded( child: FlowyTextField( focusNode: focusNode, controller: controller, - textStyle: - Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14), + textStyle: textStyle, decoration: InputDecoration( constraints: const BoxConstraints(maxHeight: 48), contentPadding: const EdgeInsets.symmetric(horizontal: 12), @@ -80,72 +144,14 @@ class _SearchFieldState extends State { ), isDense: false, hintText: LocaleKeys.commandPalette_placeholder.tr(), - hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 14, - color: Theme.of(context).hintColor, - ), - errorStyle: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(color: Theme.of(context).colorScheme.error), + hintStyle: hintStyle, + errorStyle: theme.textTheme.bodySmall! + .copyWith(color: theme.colorScheme.error), suffix: Row( mainAxisSize: MainAxisSize.min, children: [ - AnimatedOpacity( - opacity: controller.text.trim().isNotEmpty ? 1 : 0, - duration: const Duration(milliseconds: 200), - child: Builder( - builder: (context) { - final icon = Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AFThemeExtension.of(context).lightGreyHover, - ), - child: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(16), - ), - ); - if (controller.text.isEmpty) { - return icon; - } - - return FlowyTooltip( - message: - LocaleKeys.commandPalette_clearSearchTooltip.tr(), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: controller.text.trim().isNotEmpty - ? _clearSearch - : null, - child: icon, - ), - ), - ); - }, - ), - ), + _buildSuffixIcon(context), const HSpace(8), - FlowyTooltip( - message: LocaleKeys.commandPalette_betaTooltip.tr(), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 2, - ), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText.semibold( - LocaleKeys.commandPalette_betaLabel.tr(), - fontSize: 11, - lineHeight: 1.2, - ), - ), - ), ], ), counterText: "", @@ -155,9 +161,7 @@ class _SearchFieldState extends State { ), errorBorder: OutlineInputBorder( borderRadius: Corners.s8Border, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.error, - ), + borderSide: BorderSide(color: theme.colorScheme.error), ), ), onChanged: (value) => context @@ -165,17 +169,6 @@ class _SearchFieldState extends State { .add(CommandPaletteEvent.searchChanged(search: value)), ), ), - if (widget.isLoading) ...[ - FlowyTooltip( - message: LocaleKeys.commandPalette_loadingTooltip.tr(), - child: const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2.5), - ), - ), - const HSpace(12), - ], ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart similarity index 74% rename from frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart index deb401ea5c..a803f9b44c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart @@ -7,8 +7,8 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; -class RecentViewTile extends StatelessWidget { - const RecentViewTile({ +class SearchRecentViewCell extends StatelessWidget { + const SearchRecentViewCell({ super.key, required this.icon, required this.view, @@ -27,14 +27,16 @@ class RecentViewTile extends StatelessWidget { children: [ icon, const HSpace(6), - FlowyText( - view.nameOrDefault, - overflow: TextOverflow.ellipsis, + Expanded( + child: FlowyText( + view.nameOrDefault, + overflow: TextOverflow.ellipsis, + ), ), ], ), - focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), onTap: () { onSelected(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart new file mode 100644 index 0000000000..2485da4a69 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -0,0 +1,235 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchResultCell extends StatefulWidget { + const SearchResultCell({ + super.key, + required this.item, + this.isTrashed = false, + this.isHovered = false, + }); + + final SearchResultItem item; + final bool isTrashed; + final bool isHovered; + + @override + State createState() => _SearchResultCellState(); +} + +class _SearchResultCellState extends State { + bool _hasFocus = false; + final focusNode = FocusNode(); + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + /// Helper to handle the selection action. + void _handleSelection() { + context.read().add( + SearchResultListEvent.openPage(pageId: widget.item.id), + ); + } + + /// Helper to clean up preview text. + String _cleanPreview(String preview) { + return preview.replaceAll('\n', ' ').trim(); + } + + @override + Widget build(BuildContext context) { + final title = widget.item.displayName.orDefault( + LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + ); + final icon = widget.item.icon.getIcon(); + final cleanedPreview = _cleanPreview(widget.item.content); + final hasPreview = cleanedPreview.isNotEmpty; + final trashHintText = + widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; + + // Build the tile content based on preview availability. + Widget tileContent; + if (hasPreview) { + tileContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isTrashed) + FlowyText( + trashHintText!, + color: AFThemeExtension.of(context) + .textColor + .withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const VSpace(4), + _DocumentPreview(preview: cleanedPreview), + ], + ); + } else { + tileContent = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isTrashed) + FlowyText( + trashHintText!, + color: + AFThemeExtension.of(context).textColor.withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleSelection, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _handleSelection(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + onFocusChange: (hasFocus) { + setState(() { + context.read().add( + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), + ); + _hasFocus = hasFocus; + }); + }, + child: FlowyHover( + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), + ); + }, + isSelected: () => _hasFocus || widget.isHovered, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: tileContent, + ), + ), + ), + ), + ); + } +} + +class _DocumentPreview extends StatelessWidget { + const _DocumentPreview({required this.preview}); + + final String preview; + + @override + Widget build(BuildContext context) { + // Combine the horizontal padding for clarity: + return Padding( + padding: const EdgeInsets.fromLTRB(30, 0, 16, 0), + child: FlowyText.regular( + preview, + color: Theme.of(context).hintColor, + fontSize: 12, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class SearchResultPreview extends StatelessWidget { + const SearchResultPreview({ + super.key, + required this.data, + }); + + final SearchResultItem data; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_pagePreview.tr(), + fontSize: 12, + ), + ), + const VSpace(6), + Expanded( + child: FlowyText( + data.content, + maxLines: 30, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart deleted file mode 100644 index 1db18b77fb..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; -import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; -import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -class SearchResultTile extends StatefulWidget { - const SearchResultTile({ - super.key, - required this.result, - required this.onSelected, - this.isTrashed = false, - }); - - final SearchResultPB result; - final VoidCallback onSelected; - final bool isTrashed; - - @override - State createState() => _SearchResultTileState(); -} - -class _SearchResultTileState extends State { - bool _hasFocus = false; - - final focusNode = FocusNode(); - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final title = widget.result.data.orDefault( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - ); - final icon = widget.result.getIcon(); - final cleanedPreview = _cleanPreview(widget.result.preview); - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - widget.onSelected(); - - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.result.viewId), - ), - ); - }, - child: Focus( - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) { - return KeyEventResult.ignored; - } - - if (event.logicalKey == LogicalKeyboardKey.enter) { - widget.onSelected(); - - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: widget.result.viewId), - ), - ); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), - child: FlowyHover( - isSelected: () => _hasFocus, - style: HoverStyle( - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), - foregroundColorOnHover: AFThemeExtension.of(context).textColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - // page icon - if (icon != null) ...[ - SizedBox(width: 24, child: icon), - const HSpace(6), - ], - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // if the result is trashed, show a hint - if (widget.isTrashed) ...[ - FlowyText( - LocaleKeys.commandPalette_fromTrashHint.tr(), - color: AFThemeExtension.of(context) - .textColor - .withAlpha(175), - fontSize: 10, - ), - ], - // page title - FlowyText( - title, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - // content preview - if (cleanedPreview.isNotEmpty) ...[ - const VSpace(4), - _DocumentPreview(preview: cleanedPreview), - ], - ], - ), - ), - ), - ), - ); - } - - String _cleanPreview(String preview) { - return preview.replaceAll('\n', ' ').trim(); - } -} - -class _DocumentPreview extends StatelessWidget { - const _DocumentPreview({required this.preview}); - - final String preview; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16) + - const EdgeInsets.only(left: 14), - child: FlowyText.regular( - preview, - color: Theme.of(context).hintColor, - fontSize: 12, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart index ed9becf29e..d90888e3e9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -1,47 +1,278 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_animate/flutter_animate.dart'; -class SearchResultsList extends StatelessWidget { - const SearchResultsList({ - super.key, +import 'search_result_cell.dart'; +import 'search_summary_cell.dart'; + +class SearchResultList extends StatefulWidget { + const SearchResultList({ required this.trash, - required this.results, + required this.resultItems, + required this.resultSummaries, + super.key, }); final List trash; - final List results; + final List resultItems; + final List resultSummaries; + + @override + State createState() => _SearchResultListState(); +} + +class _SearchResultListState extends State { + late final SearchResultListBloc bloc; + + @override + void initState() { + super.initState(); + bloc = SearchResultListBloc(); + } + + @override + void dispose() { + bloc.close(); + super.dispose(); + } + + Widget _buildSectionHeader(String title) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 8), + child: Opacity( + opacity: 0.6, + child: FlowyText(title, fontSize: 12), + ), + ); + + Widget _buildAIOverviewSection(BuildContext context) { + final state = context.read().state; + + if (state.generatingAIOverview) { + return Row( + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + const HSpace(10), + const AIOverviewIndicator(), + ], + ); + } + + if (widget.resultSummaries.isNotEmpty) { + if (!bloc.state.userHovered) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + bloc.add( + SearchResultListEvent.onHoverSummary( + summary: widget.resultSummaries[0], + userHovered: false, + ), + ); + }, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: widget.resultSummaries.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) => SearchSummaryCell( + summary: widget.resultSummaries[index], + isHovered: bloc.state.hoveredSummary != null, + ), + ), + ], + ); + } + + return const SizedBox.shrink(); + } + + Widget _buildResultsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _buildSectionHeader(LocaleKeys.commandPalette_bestMatches.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: widget.resultItems.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) { + final item = widget.resultItems[index]; + return SearchResultCell( + item: item, + isTrashed: widget.trash.any((t) => t.id == item.id), + isHovered: bloc.state.hoveredResult?.id == item.id, + ); + }, + ), + ], + ); + } @override Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - separatorBuilder: (_, __) => const Divider(height: 0), - itemCount: results.length + 1, - itemBuilder: (_, index) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8) + - const EdgeInsets.only(left: 16), - child: FlowyText( - LocaleKeys.commandPalette_bestMatches.tr(), - ), - ); - } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: BlocProvider.value( + value: bloc, + child: BlocListener( + listener: (context, state) { + if (state.openPageId != null) { + FlowyOverlay.pop(context); + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: state.openPageId!), + ), + ); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 7, + child: BlocBuilder( + buildWhen: (previous, current) => + previous.hoveredResult != current.hoveredResult || + previous.hoveredSummary != current.hoveredSummary, + builder: (context, state) { + return ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + _buildAIOverviewSection(context), + const VSpace(10), + if (widget.resultItems.isNotEmpty) + _buildResultsSection(context), + ], + ); + }, + ), + ), + const HSpace(10), + if (widget.resultItems + .any((item) => item.content.isNotEmpty)) ...[ + const VerticalDivider( + thickness: 1.0, + ), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 16, + ), + child: const SearchCellPreview(), + ), + ), + ], + ], + ), + ), + ), + ); + } +} - final result = results[index - 1]; - return SearchResultTile( - result: result, - onSelected: () => FlowyOverlay.pop(context), - isTrashed: trash.any((t) => t.id == result.viewId), - ); +class SearchCellPreview extends StatelessWidget { + const SearchCellPreview({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.hoveredSummary != null) { + return SearchSummaryPreview(summary: state.hoveredSummary!); + } else if (state.hoveredResult != null) { + return SearchResultPreview(data: state.hoveredResult!); + } + return const SizedBox.shrink(); }, ); } } + +class AIOverviewIndicator extends StatelessWidget { + const AIOverviewIndicator({ + super.key, + this.duration = const Duration(seconds: 1), + }); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); + return SelectionContainer.disabled( + child: SizedBox( + height: 20, + width: 100, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), + children: [ + buildDot(const Color(0xFF9327FF)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(duration: slice * 2, begin: 0, end: 0), + buildDot(const Color(0xFFFB006D)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: 0) + .then() + .slideY(begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(begin: 0, end: 0), + buildDot(const Color(0xFFFFCE00)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice * 2, begin: 0, end: 0) + .then() + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0), + ], + ), + ), + ); + } + + Widget buildDot(Color color) { + return SizedBox.square( + dimension: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart new file mode 100644 index 0000000000..84b8f6646b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchSummaryCell extends StatelessWidget { + const SearchSummaryCell({ + required this.summary, + required this.isHovered, + super.key, + }); + + final SearchSummaryPB summary; + final bool isHovered; + + @override + Widget build(BuildContext context) { + return FlowyHover( + isSelected: () => isHovered, + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverSummary( + summary: summary, + userHovered: true, + ), + ); + }, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: FlowyText( + summary.content, + maxLines: 20, + ), + ), + ); + } +} + +class SearchSummaryPreview extends StatelessWidget { + const SearchSummaryPreview({ + required this.summary, + super.key, + }); + + final SearchSummaryPB summary; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.highlights.isNotEmpty) ...[ + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewMoreDetails.tr(), + fontSize: 12, + ), + ), + const VSpace(6), + SearchSummaryHighlight(text: summary.highlights), + const VSpace(36), + ], + + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewSource.tr(), + fontSize: 12, + ), + ), + // Sources + const VSpace(6), + ...summary.sources.map((e) => SearchSummarySource(source: e)), + ], + ); + } +} + +class SearchSummaryHighlight extends StatelessWidget { + const SearchSummaryHighlight({ + required this.text, + super.key, + }); + + final String text; + + @override + Widget build(BuildContext context) { + return AIMarkdownText(markdown: text); + } +} + +class SearchSummarySource extends StatelessWidget { + const SearchSummarySource({ + required this.source, + super.key, + }); + + final SearchSourcePB source; + + @override + Widget build(BuildContext context) { + final icon = source.icon.getIcon(); + return FlowyTooltip( + message: LocaleKeys.commandPalette_clickToOpenPage.tr(), + child: SizedBox( + height: 30, + child: FlowyButton( + leftIcon: icon, + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + text: FlowyText(source.displayName), + onTap: () { + context.read().add( + SearchResultListEvent.openPage(pageId: source.id), + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index a8d768aa79..619ee4e229 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -52,8 +52,8 @@ class DesktopHomeScreen extends StatelessWidget { return _buildLoading(); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) => workspaceSettingPB as WorkspaceSettingPB, + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) => workspaceLatestPB as WorkspaceLatestPB, (error) => null, ); @@ -64,7 +64,7 @@ class DesktopHomeScreen extends StatelessWidget { // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. - if (workspaceSetting == null || userProfile == null) { + if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } @@ -86,11 +86,11 @@ class DesktopHomeScreen extends StatelessWidget { BlocProvider.value(value: getIt()), BlocProvider( create: (_) => - HomeBloc(workspaceSetting)..add(const HomeEvent.initial()), + HomeBloc(workspaceLatest)..add(const HomeEvent.initial()), ), BlocProvider( create: (_) => HomeSettingBloc( - workspaceSetting, + workspaceLatest, context.read(), context.widthPx, )..add(const HomeSettingEvent.initial()), @@ -137,7 +137,7 @@ class DesktopHomeScreen extends StatelessWidget { child: _buildBody( context, userProfile, - workspaceSetting, + workspaceLatest, ), ), ), @@ -157,7 +157,7 @@ class DesktopHomeScreen extends StatelessWidget { Widget _buildBody( BuildContext context, UserProfilePB userProfile, - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, ) { final layout = HomeLayout(context); final homeStack = HomeStack( @@ -190,7 +190,7 @@ class DesktopHomeScreen extends StatelessWidget { BuildContext context, { required HomeLayout layout, required UserProfilePB userProfile, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, }) { final homeMenu = HomeSideBar( userProfile: userProfile, 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 f7a95f3103..ae3b92a702 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -326,7 +326,7 @@ class _SecondaryViewState extends State ? const Color(0x1F1F2329) : Theme.of(context) .shadowColor - .withOpacity(0.08), + .withValues(alpha: 0.08), ), ], ), 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 57168b40a5..f8c3a30488 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 @@ -60,7 +60,7 @@ class SidebarTemplateButton extends StatelessWidget { FlowySvgs.icon_template_s, ), text: LocaleKeys.template_label.tr(), - onTap: () => afLaunchUrlString('https://appflowy.io/templates'), + onTap: () => afLaunchUrlString('https://appflowy.com/templates'), ); } } 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 6d2b93be45..05e6d46957 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 @@ -105,9 +105,9 @@ class SidebarToast extends StatelessWidget { if (role.isOwner) { showSettingsDialog( context, - userProfile, - userWorkspaceBloc, - SettingsPage.plan, + userProfile: userProfile, + userWorkspaceBloc: userWorkspaceBloc, + initPage: SettingsPage.plan, ); } else { final String message; @@ -174,8 +174,8 @@ class _PlanIndicatorState extends State { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - const Color(0xFF8032FF).withOpacity(.1), - const Color(0xFFEF35FF).withOpacity(.1), + const Color(0xFF8032FF).withValues(alpha: .1), + const Color(0xFFEF35FF).withValues(alpha: .1), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart new file mode 100644 index 0000000000..abe3ffd354 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart @@ -0,0 +1,110 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SidebarUpgradeApplicationButton extends StatelessWidget { + const SidebarUpgradeApplicationButton({ + super.key, + required this.onUpdateButtonTap, + required this.onCloseButtonTap, + }); + + final VoidCallback onUpdateButtonTap; + final VoidCallback onCloseButtonTap; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.sidebarUpgradeButtonBackground, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // title + _buildTitle(), + const VSpace(2), + // description + _buildDescription(), + const VSpace(10), + // update button + _buildUpdateButton(), + ], + ), + ); + } + + Widget _buildTitle() { + return Row( + children: [ + const FlowySvg( + FlowySvgs.sidebar_upgrade_version_s, + blendMode: null, + ), + const HSpace(6), + FlowyText.medium( + LocaleKeys.autoUpdate_bannerUpdateTitle.tr(), + fontSize: 14, + figmaLineHeight: 18, + ), + const Spacer(), + FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.upgrade_close_s), + onTap: onCloseButtonTap, + ), + ], + ); + } + + Widget _buildDescription() { + return Opacity( + opacity: 0.7, + child: FlowyText( + LocaleKeys.autoUpdate_bannerUpdateDescription.tr(), + fontSize: 13, + figmaLineHeight: 16, + maxLines: null, + ), + ); + } + + Widget _buildUpdateButton() { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onUpdateButtonTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: ShapeDecoration( + color: const Color(0xFFA44AFD), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + ), + child: FlowyText.medium( + LocaleKeys.autoUpdate_settingsUpdateButton.tr(), + color: Colors.white, + fontSize: 12.0, + figmaLineHeight: 15.0, + ), + ), + ), + ); + } +} + +extension on BuildContext { + Color get sidebarUpgradeButtonBackground => Theme.of(this).isLightMode + ? const Color(0xB2EBE4FF) + : const Color(0xB239275B); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart index 559c189925..67930c336a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart @@ -50,8 +50,8 @@ class SidebarTopMenu extends StatelessWidget { } final svgData = Theme.of(context).brightness == Brightness.dark - ? FlowySvgs.flowy_logo_dark_mode_xl - : FlowySvgs.flowy_logo_text_xl; + ? FlowySvgs.app_logo_with_text_dark_xl + : FlowySvgs.app_logo_with_text_light_xl; return Padding( padding: const EdgeInsets.only(top: 12.0, left: 8), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart index 6a23c7def9..716002e917 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart @@ -204,8 +204,6 @@ class _ImportPanelState extends State { ..importType = ImportTypePB.AFDatabase, ); break; - default: - break; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart index 84a76cfe83..0bd5dafe91 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; @@ -33,7 +34,7 @@ HotKeyItem openSettingsHotKey( ), keyDownHandler: (_) { if (_settingsDialogKey.currentContext == null) { - showSettingsDialog(context, userProfile); + showSettingsDialog(context, userProfile: userProfile); } else { Navigator.of(context, rootNavigator: true) .popUntil((route) => route.isFirst); @@ -57,37 +58,55 @@ class UserSettingButton extends StatefulWidget { class _UserSettingButtonState extends State { late UserWorkspaceBloc _userWorkspaceBloc; + late PasswordBloc _passwordBloc; @override void initState() { super.initState(); + _userWorkspaceBloc = context.read(); + _passwordBloc = PasswordBloc(widget.userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()); } @override void didChangeDependencies() { _userWorkspaceBloc = context.read(); + super.didChangeDependencies(); } + @override + void dispose() { + _passwordBloc.close(); + + super.dispose(); + } + @override Widget build(BuildContext context) { return SizedBox.square( dimension: 24.0, child: FlowyTooltip( message: LocaleKeys.settings_menu_open.tr(), - child: FlowyButton( - onTap: () => showSettingsDialog( - context, - widget.userProfile, - _userWorkspaceBloc, - ), - margin: EdgeInsets.zero, - text: FlowySvg( - FlowySvgs.settings_s, - color: - widget.isHover ? Theme.of(context).colorScheme.onSurface : null, - opacity: 0.7, + child: BlocProvider.value( + value: _passwordBloc, + child: FlowyButton( + onTap: () => showSettingsDialog( + context, + userProfile: widget.userProfile, + userWorkspaceBloc: _userWorkspaceBloc, + passwordBloc: _passwordBloc, + ), + margin: EdgeInsets.zero, + text: FlowySvg( + FlowySvgs.settings_s, + color: widget.isHover + ? Theme.of(context).colorScheme.onSurface + : null, + opacity: 0.7, + ), ), ), ), @@ -96,21 +115,33 @@ class _UserSettingButtonState extends State { } void showSettingsDialog( - BuildContext context, - UserProfilePB userProfile, [ - UserWorkspaceBloc? bloc, + BuildContext context, { + required UserProfilePB userProfile, + UserWorkspaceBloc? userWorkspaceBloc, + PasswordBloc? passwordBloc, SettingsPage? initPage, -]) { +}) { AFFocusManager.maybeOf(context)?.notifyLoseFocus(); showDialog( context: context, builder: (dialogContext) => MultiBlocProvider( key: _settingsDialogKey, providers: [ + passwordBloc != null + ? BlocProvider.value( + value: passwordBloc, + ) + : BlocProvider( + create: (context) => PasswordBloc(userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()), + ), BlocProvider.value( value: BlocProvider.of(dialogContext), ), - BlocProvider.value(value: bloc ?? context.read()), + BlocProvider.value( + value: userWorkspaceBloc ?? context.read(), + ), ], child: SettingsDialog( userProfile, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 6906dc8b73..9c19184217 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -7,7 +7,9 @@ import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/loading.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; @@ -23,6 +25,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; @@ -57,7 +60,7 @@ class HomeSideBar extends StatelessWidget { final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceSetting; @override Widget build(BuildContext context) { @@ -261,6 +264,9 @@ class _SidebarState extends State<_Sidebar> { final _isHovered = ValueNotifier(false); final _scrollOffset = ValueNotifier(0); + // mute the update button during the current application lifecycle. + final _muteUpdateButton = ValueNotifier(false); + @override void initState() { super.initState(); @@ -347,6 +353,7 @@ class _SidebarState extends State<_Sidebar> { const VSpace(8), _renderUpgradeSpaceButton(menuHorizontalInset), + _buildUpgradeApplicationButton(menuHorizontalInset), const VSpace(8), Padding( @@ -432,6 +439,42 @@ class _SidebarState extends State<_Sidebar> { ); } + Widget _buildUpgradeApplicationButton(EdgeInsets menuHorizontalInset) { + return ValueListenableBuilder( + valueListenable: _muteUpdateButton, + builder: (_, mute, child) { + if (mute) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: ApplicationInfo.latestVersionNotifier, + builder: (_, latestVersion, child) { + if (!ApplicationInfo.isUpdateAvailable) { + return const SizedBox.shrink(); + } + + return Padding( + padding: menuHorizontalInset + + const EdgeInsets.only( + left: 4.0, + right: 4.0, + ), + child: SidebarUpgradeApplicationButton( + onUpdateButtonTap: () { + versionChecker.checkForUpdate(); + }, + onCloseButtonTap: () { + _muteUpdateButton.value = true; + }, + ), + ); + }, + ); + }, + ); + } + void _onScrollChanged() { setState(() => _isScrolling = true); 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 7afb4a6298..d06016dfb8 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 @@ -250,16 +250,16 @@ enum ConfirmPopupStyle { class ConfirmPopupColor { static Color titleColor(BuildContext context) { if (Theme.of(context).isLightMode) { - return const Color(0xFF171717).withOpacity(0.8); + return const Color(0xFF171717).withValues(alpha: 0.8); } - return const Color(0xFFffffff).withOpacity(0.8); + return const Color(0xFFffffff).withValues(alpha: 0.8); } static Color descriptionColor(BuildContext context) { if (Theme.of(context).isLightMode) { - return const Color(0xFF171717).withOpacity(0.7); + return const Color(0xFF171717).withValues(alpha: 0.7); } - return const Color(0xFFffffff).withOpacity(0.7); + return const Color(0xFFffffff).withValues(alpha: 0.7); } } @@ -275,6 +275,8 @@ class ConfirmPopup extends StatefulWidget { this.confirmButtonColor, this.child, this.closeOnAction = true, + this.showCloseButton = true, + this.enableKeyboardListener = true, }); final String title; @@ -303,6 +305,16 @@ class ConfirmPopup extends StatefulWidget { /// final bool closeOnAction; + /// Show close button. + /// Defaults to true. + /// + final bool showCloseButton; + + /// Enable keyboard listener. + /// Defaults to true. + /// + final bool enableKeyboardListener; + @override State createState() => _ConfirmPopupState(); } @@ -316,14 +328,16 @@ class _ConfirmPopupState extends State { focusNode: focusNode, autofocus: true, onKeyEvent: (event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - Navigator.of(context).pop(); - } else if (event is KeyUpEvent && - event.logicalKey == LogicalKeyboardKey.enter) { - widget.onConfirm(); - if (widget.closeOnAction) { + if (widget.enableKeyboardListener) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); + } else if (event is KeyUpEvent && + event.logicalKey == LogicalKeyboardKey.enter) { + widget.onConfirm(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } } } }, @@ -367,15 +381,17 @@ class _ConfirmPopupState extends State { ), ), const HSpace(6.0), - FlowyButton( - margin: const EdgeInsets.all(3), - useIntrinsicWidth: true, - text: const FlowySvg( - FlowySvgs.upgrade_close_s, - size: Size.square(18.0), + if (widget.showCloseButton) ...[ + FlowyButton( + margin: const EdgeInsets.all(3), + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.upgrade_close_s, + size: Size.square(18.0), + ), + onTap: () => Navigator.of(context).pop(), ), - onTap: () => Navigator.of(context).pop(), - ), + ], ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index f3038c0bec..4ff5ccbf67 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -306,9 +306,12 @@ class _WorkspaceInfo extends StatelessWidget { // Persist and close other tabs when switching workspace, restore tabs for new workspace getIt().add(TabsEvent.switchWorkspace(workspace.workspaceId)); - context - .read() - .add(UserWorkspaceEvent.openWorkspace(workspace.workspaceId)); + context.read().add( + UserWorkspaceEvent.openWorkspace( + workspace.workspaceId, + workspace.workspaceAuthType, + ), + ); PopoverContainer.of(context).closeAll(); } @@ -370,7 +373,7 @@ class _CreateWorkspaceButton extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( - color: const Color(0x01717171).withOpacity(0.12), + color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), @@ -383,7 +386,12 @@ class _CreateWorkspaceButton extends StatelessWidget { final workspaceBloc = context.read(); await CreateWorkspaceDialog( onConfirm: (name) { - workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name)); + workspaceBloc.add( + UserWorkspaceEvent.createWorkspace( + name, + AuthTypePB.Server, + ), + ); }, ).show(context); } @@ -438,7 +446,7 @@ class _ImportNotionButton extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( - color: const Color(0x01717171).withOpacity(0.12), + color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 038ec9f5a6..50ea9d83c7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -169,7 +169,6 @@ class _SidebarWorkspaceState extends State { if (message != null) { showToastNotification( - context, message: message, type: result.fold( (_) => ToastificationType.success, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart index 2b27024cff..c604fae432 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart @@ -111,7 +111,8 @@ class _DraggableViewItemState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(6.0), color: position == DraggableHoverPosition.center - ? widget.centerHighlightColor ?? hoverColor.withOpacity(0.5) + ? widget.centerHighlightColor ?? + hoverColor.withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, @@ -150,7 +151,10 @@ class _DraggableViewItemState extends State { borderRadius: BorderRadius.circular(4.0), color: position == DraggableHoverPosition.center ? widget.centerHighlightColor ?? - Theme.of(context).colorScheme.secondary.withOpacity(0.5) + Theme.of(context) + .colorScheme + .secondary + .withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart index fe2e5def48..d4f91b67d9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart @@ -17,6 +17,14 @@ enum ViewMoreActionType { divider, lastModified, created, + lockPage; + + static const disableInLockedView = [ + delete, + rename, + moveTo, + changeIcon, + ]; } extension ViewMoreActionTypeExtension on ViewMoreActionType { @@ -42,6 +50,8 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { return LocaleKeys.disclosureAction_changeIcon.tr(); case ViewMoreActionType.collapseAllPages: return LocaleKeys.disclosureAction_collapseAllPages.tr(); + case ViewMoreActionType.lockPage: + return LocaleKeys.disclosureAction_lockPage.tr(); case ViewMoreActionType.divider: case ViewMoreActionType.lastModified: case ViewMoreActionType.created: @@ -69,6 +79,8 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { return FlowySvgs.change_icon_s; case ViewMoreActionType.collapseAllPages: return FlowySvgs.collapse_all_page_s; + case ViewMoreActionType.lockPage: + return FlowySvgs.lock_page_s; case ViewMoreActionType.divider: case ViewMoreActionType.lastModified: case ViewMoreActionType.copyLink: @@ -92,6 +104,7 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { case ViewMoreActionType.delete: case ViewMoreActionType.lastModified: case ViewMoreActionType.created: + case ViewMoreActionType.lockPage: return const SizedBox.shrink(); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 34509dbb96..22182f7429 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -21,6 +21,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type. import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; @@ -615,7 +616,7 @@ class _SingleInnerViewItemState extends State { offset: const Offset(0, 5), direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (_) => RenameViewPopover( - viewId: widget.view.id, + view: widget.view, name: widget.view.name, emoji: widget.view.icon.toEmojiIconData(), popoverController: popoverController, @@ -631,10 +632,14 @@ class _SingleInnerViewItemState extends State { Widget _buildViewIconButton() { final iconData = widget.view.icon.toEmojiIconData(); final icon = iconData.isNotEmpty - ? RawEmojiIconWidget(emoji: iconData, emojiSize: 16.0) + ? RawEmojiIconWidget( + emoji: iconData, + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, + ) : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); - return AppFlowyPopover( + final Widget child = AppFlowyPopover( offset: const Offset(20, 0), controller: controller, direction: PopoverDirection.rightWithCenterAligned, @@ -661,7 +666,7 @@ class _SingleInnerViewItemState extends State { documentId: widget.view.id, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( - viewId: widget.view.id, + view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) controller.close(); @@ -669,6 +674,14 @@ class _SingleInnerViewItemState extends State { ); }, ); + + if (widget.view.isLocked) { + return LockPageButtonWrapper( + child: child, + ); + } + + return child; } // > button or · button @@ -792,7 +805,7 @@ class _SingleInnerViewItemState extends State { return; } await ViewBackendService.updateViewIcon( - viewId: widget.view.id, + view: widget.view, viewIcon: data.data, ); break; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index a58aab2b2e..7ccd03b4f4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; @@ -54,19 +55,22 @@ class ViewMoreActionPopover extends StatelessWidget { List _buildActionTypeWrappers() { final actionTypes = _buildActionTypes(); - return actionTypes - .map( - (e) => ViewMoreActionTypeWrapper(e, view, (controller, data) { - onEditing(false); - onAction(e, data); - bool enableClose = true; - if (data is SelectedEmojiIconResult) { - if (data.keepOpen) enableClose = false; - } - if (enableClose) controller.close(); - }), - ) - .toList(); + return actionTypes.map( + (e) { + final actionWrapper = + ViewMoreActionTypeWrapper(e, view, (controller, data) { + onEditing(false); + onAction(e, data); + bool enableClose = true; + if (data is SelectedEmojiIconResult) { + if (data.keepOpen) enableClose = false; + } + if (enableClose) controller.close(); + }); + + return actionWrapper; + }, + ).toList(); } List _buildActionTypes() { @@ -144,19 +148,30 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { PopoverController controller, PopoverMutex? mutex, ) { + Widget child; + if (inner == ViewMoreActionType.divider) { - return _buildDivider(); + child = _buildDivider(); } else if (inner == ViewMoreActionType.lastModified) { - return _buildLastModified(context); + child = _buildLastModified(context); } else if (inner == ViewMoreActionType.created) { - return _buildCreated(context); + child = _buildCreated(context); } else if (inner == ViewMoreActionType.changeIcon) { - return _buildEmojiActionButton(context, controller); + child = _buildEmojiActionButton(context, controller); } else if (inner == ViewMoreActionType.moveTo) { - return _buildMoveToActionButton(context, controller); + child = _buildMoveToActionButton(context, controller); + } else { + child = _buildNormalActionButton(context, controller); } - return _buildNormalActionButton(context, controller); + if (ViewMoreActionType.disableInLockedView.contains(inner) && + sourceView.isLocked) { + child = LockPageButtonWrapper( + child: child, + ); + } + + return child; } Widget _buildNormalActionButton( @@ -196,7 +211,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { ) { final userProfile = context.read().userProfile; // move to feature doesn't support in local mode - if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + if (userProfile.authType != AuthTypePB.Server) { return const SizedBox.shrink(); } return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart new file mode 100644 index 0000000000..2125ea4b66 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SettingsAppVersion extends StatelessWidget { + const SettingsAppVersion({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ApplicationInfo.isUpdateAvailable + ? const _UpdateAppSection() + : _buildIsUpToDate(context); + } + + Widget _buildIsUpToDate(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.settings_accountPage_isUpToDate.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const VSpace(4), + Text( + LocaleKeys.settings_accountPage_officialVersion.tr( + namedArgs: { + 'version': ApplicationInfo.applicationVersion, + }, + ), + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ), + ), + ], + ); + } +} + +class _UpdateAppSection extends StatelessWidget { + const _UpdateAppSection(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: _buildDescription(context)), + _buildUpdateButton(), + ], + ); + } + + Widget _buildUpdateButton() { + return PrimaryRoundedButton( + text: LocaleKeys.autoUpdate_settingsUpdateButton.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + fontWeight: FontWeight.w500, + radius: 8.0, + onTap: () { + Log.info('[AutoUpdater] Checking for updates'); + versionChecker.checkForUpdate(); + }, + ); + } + + Widget _buildDescription(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildRedDot(), + const HSpace(6), + Flexible( + child: FlowyText.medium( + LocaleKeys.autoUpdate_settingsUpdateTitle.tr( + namedArgs: { + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + figmaLineHeight: 17, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const VSpace(4), + _buildCurrentVersionAndLatestVersion(context), + ], + ); + } + + Widget _buildCurrentVersionAndLatestVersion(BuildContext context) { + return Row( + children: [ + Flexible( + child: Opacity( + opacity: 0.7, + child: FlowyText.regular( + LocaleKeys.autoUpdate_settingsUpdateDescription.tr( + namedArgs: { + 'currentVersion': ApplicationInfo.applicationVersion, + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + fontSize: 12, + figmaLineHeight: 13, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const HSpace(6), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + afLaunchUrlString('https://www.appflowy.io/what-is-new'); + }, + child: FlowyText.regular( + LocaleKeys.autoUpdate_settingsUpdateWhatsNew.tr(), + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + figmaLineHeight: 13, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _buildRedDot() { + return Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFFB006D), + shape: BoxShape.circle, + ), + ); + } +} 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 index 6f1c4920cb..04d078ec0d 100644 --- 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 @@ -8,13 +8,12 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_w import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; -const _confirmText = 'DELETE MY ACCOUNT'; const _acceptableConfirmTexts = [ 'delete my account', 'deletemyaccount', @@ -44,43 +43,36 @@ class _AccountDeletionButtonState extends State { @override Widget build(BuildContext context) { - final textColor = Theme.of(context).brightness == Brightness.light - ? const Color(0xFF4F4F4F) - : const Color(0xFFB0B0B0); + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText( + Text( LocaleKeys.button_deleteAccount.tr(), - fontSize: 14.0, - fontWeight: FontWeight.w500, - figmaLineHeight: 21.0, - color: textColor, + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), ), const VSpace(8), Row( children: [ - Flexible( - child: FlowyText.regular( + Expanded( + child: Text( LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), - fontSize: 12.0, - figmaLineHeight: 13.0, maxLines: 2, - color: textColor, + overflow: TextOverflow.ellipsis, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ), ), ), - 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, - fontSize: 12, - isDangerous: true, - onPressed: () { + AFOutlinedTextButton.destructive( + text: LocaleKeys.button_deleteAccount.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.error, + weight: FontWeight.w400, + ), + onTap: () { isCheckedNotifier.value = false; textEditingController.clear(); @@ -135,7 +127,8 @@ class _AccountDeletionDialog extends StatelessWidget { ), const VSpace(12.0), FlowyTextField( - hintText: _confirmText, + hintText: + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(), controller: controller, ), const VSpace(16), @@ -176,7 +169,8 @@ class _AccountDeletionDialog extends StatelessWidget { 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); + return _acceptableConfirmTexts.contains(text) || + text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(); } Future deleteMyAccount( @@ -192,7 +186,6 @@ Future deleteMyAccount( if (!isChecked) { showToastNotification( - context, type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -207,7 +200,6 @@ Future deleteMyAccount( if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { showToastNotification( - context, type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -225,7 +217,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - context, message: LocaleKeys .newSettings_myAccount_deleteAccount_deleteAccountSuccess .tr(), @@ -244,7 +235,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - context, type: ToastificationType.error, bottomPadding: bottomPadding, message: f.msg, 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 index 8d15c80181..78f1aaf16e 100644 --- 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 @@ -3,17 +3,55 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/user/application/prelude.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +class AccountSignInOutSection extends StatelessWidget { + const AccountSignInOutSection({ + super.key, + required this.userProfile, + required this.onAction, + this.signIn = true, + }); + + final UserProfilePB userProfile; + final VoidCallback onAction; + final bool signIn; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + children: [ + Text( + LocaleKeys.settings_accountPage_login_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AccountSignInOutButton( + userProfile: userProfile, + onAction: onAction, + signIn: signIn, + ), + ], + ); + } +} + class AccountSignInOutButton extends StatelessWidget { const AccountSignInOutButton({ super.key, @@ -28,13 +66,10 @@ class AccountSignInOutButton extends StatelessWidget { @override Widget build(BuildContext context) { - return PrimaryRoundedButton( + return AFFilledTextButton.primary( 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), ); @@ -44,9 +79,7 @@ class AccountSignInOutButton extends StatelessWidget { 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(), + description: LocaleKeys.settings_menu_logoutPrompt.tr(), onConfirm: () async { await getIt().signOut(); onAction(); @@ -68,6 +101,94 @@ class AccountSignInOutButton extends StatelessWidget { } } +class ChangePasswordSection extends StatelessWidget { + const ChangePasswordSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + state.hasPassword + ? AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_changePassword + .tr(), + onTap: () => _showChangePasswordDialog(context), + ) + : AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_setupPassword + .tr(), + onTap: () => _showSetPasswordDialog(context), + ), + ], + ); + }, + ); + } + + Future _showChangePasswordDialog(BuildContext context) async { + final theme = AppFlowyTheme.of(context); + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: ChangePasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } + + Future _showSetPasswordDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + child: SetupPasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } +} + class _SignInDialogContent extends StatelessWidget { const _SignInDialogContent(); @@ -83,7 +204,7 @@ class _SignInDialogContent extends StatelessWidget { const _DialogHeader(), const _DialogTitle(), const VSpace(16), - const SignInWithMagicLinkButtons(), + const ContinueWithEmailAndPassword(), if (isAuthEnabled) ...[ const VSpace(20), const _OrDivider(), 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 index bd08501ae4..62a6232c4a 100644 --- 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 @@ -4,6 +4,7 @@ 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:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; @@ -96,27 +97,29 @@ class _AccountUserProfileState extends State { } Widget _buildNameDisplay() { + final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.only(top: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: FlowyText.medium( + child: Text( widget.name, overflow: TextOverflow.ellipsis, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), ), ), const HSpace(4), - GestureDetector( - behavior: HitTestBehavior.opaque, + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), onTap: () => setState(() => isEditing = true), - child: const FlowyHover( - resetHoverOnRebuild: false, - child: Padding( - padding: EdgeInsets.all(4), - child: FlowySvg(FlowySvgs.edit_s), - ), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.toolbar_link_edit_m, + size: const Size.square(20), ), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart new file mode 100644 index 0000000000..d606f870ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +class SettingsEmailSection extends StatelessWidget { + const SettingsEmailSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.settings_accountPage_email_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + VSpace(theme.spacing.s), + Text( + userProfile.email, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart new file mode 100644 index 0000000000..194254c869 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart @@ -0,0 +1,330 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ChangePasswordDialogContent extends StatefulWidget { + const ChangePasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _ChangePasswordDialogContentState(); +} + +class _ChangePasswordDialogContentState + extends State { + final currentPasswordTextFieldKey = GlobalKey(); + final newPasswordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final currentPasswordController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + currentPasswordController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildCurrentPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildNewPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Change password', + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildCurrentPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_currentPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: currentPasswordTextFieldKey, + controller: currentPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourCurrentPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + currentPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildNewPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_newPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: newPasswordTextFieldKey, + controller: newPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + newPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_confirmNewPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_confirmYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: LocaleKeys.button_cancel.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: LocaleKeys.button_save.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final currentPassword = currentPasswordController.text; + final newPassword = newPasswordController.text; + final confirmPassword = confirmPasswordController.text; + + if (newPassword.isEmpty) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (newPassword != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + if (newPassword == currentPassword) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsSameAsCurrent + .tr(), + ); + return; + } + + // all the verification passed, save the new password + context.read().add( + PasswordEvent.changePassword( + oldPassword: currentPassword, + newPassword: newPassword, + ), + ); + } + + void _resetError() { + currentPasswordTextFieldKey.currentState?.clearError(); + newPasswordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final changePasswordResult = state.changePasswordResult; + final setPasswordResult = state.setupPasswordResult; + + if (changePasswordResult != null) { + changePasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedFailed + .tr(); + description = error.msg; + }, + ); + } else if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupFailed + .tr(); + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart new file mode 100644 index 0000000000..5417b1a0eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart @@ -0,0 +1,30 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class PasswordSuffixIcon extends StatelessWidget { + const PasswordSuffixIcon({ + super.key, + required this.isObscured, + required this.onTap, + }); + + final bool isObscured; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Padding( + padding: EdgeInsets.only(right: theme.spacing.m), + child: GestureDetector( + onTap: onTap, + child: FlowySvg( + isObscured ? FlowySvgs.show_s : FlowySvgs.hide_s, + color: theme.textColorScheme.secondary, + size: const Size.square(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart new file mode 100644 index 0000000000..2fdfd8b934 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart @@ -0,0 +1,254 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SetupPasswordDialogContent extends StatefulWidget { + const SetupPasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _SetupPasswordDialogContentState(); +} + +class _SetupPasswordDialogContentState + extends State { + final passwordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final passwordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_setupPassword.tr(), + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: passwordTextFieldKey, + controller: passwordController, + hintText: 'Enter your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + passwordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Confirm password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: 'Confirm your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: 'Cancel', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: 'Save', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final password = passwordController.text; + final confirmPassword = confirmPasswordController.text; + + if (password.isEmpty) { + passwordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (password != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + // all the verification passed, save the password + context.read().add( + PasswordEvent.setupPassword( + newPassword: password, + ), + ); + } + + void _resetError() { + passwordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final setPasswordResult = state.setupPasswordResult; + + if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = 'Password set'; + description = 'Your password has been set'; + }, + (error) { + hasError = true; + message = 'Failed to set password'; + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart deleted file mode 100644 index a7ed782aea..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/download_model_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:percent_indicator/linear_percent_indicator.dart'; - -class DownloadingIndicator extends StatelessWidget { - const DownloadingIndicator({ - required this.llmModel, - required this.onCancel, - required this.onFinish, - super.key, - }); - final LLMModelPB llmModel; - final VoidCallback onCancel; - final VoidCallback onFinish; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - DownloadModelBloc(llmModel)..add(const DownloadModelEvent.started()), - child: BlocListener( - listener: (context, state) { - if (state.isFinish) { - onFinish(); - } - }, - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DownloadingProgressBar(onCancel: onCancel), - if (state.bigFileDownloadPrompt != null) ...[ - const VSpace(2), - Opacity( - opacity: 0.6, - child: - FlowyText(state.bigFileDownloadPrompt!, fontSize: 11), - ), - ], - ], - ); - }, - ), - ), - ), - ); - } -} - -class DownloadingProgressBar extends StatelessWidget { - const DownloadingProgressBar({required this.onCancel, super.key}); - - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Opacity( - opacity: 0.6, - child: FlowyText( - "${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}", - fontSize: 11, - ), - ), - IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: LinearPercentIndicator( - lineHeight: 9.0, - percent: state.percent, - padding: EdgeInsets.zero, - progressColor: AFThemeExtension.of(context).success, - backgroundColor: - AFThemeExtension.of(context).progressBarBGColor, - barRadius: const Radius.circular(8), - trailing: FlowyText( - "${(state.percent * 100).toStringAsFixed(0)}%", - fontSize: 11, - color: AFThemeExtension.of(context).success, - ), - ), - ), - const HSpace(12), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.button_cancel.tr(), - fontSize: 11, - ), - onTap: onCancel, - ), - ], - ), - ), - ], - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart deleted file mode 100644 index d924b46825..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class InitLocalAIIndicator extends StatelessWidget { - const InitLocalAIIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: BlocBuilder( - builder: (context, state) { - switch (state.runningState) { - case RunningStatePB.Connecting: - case RunningStatePB.Connected: - return Row( - children: [ - const HSpace(8), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoading.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ], - ); - case RunningStatePB.Running: - return SizedBox( - height: 30, - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ], - ), - ); - case RunningStatePB.Stopped: - return Row( - children: [ - const HSpace(8), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStopped.tr(), - fontSize: 11, - color: const Color(0xFFC62828), - ), - ], - ); - default: - return const SizedBox.shrink(); - } - }, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart deleted file mode 100644 index 9cb3a17d88..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:expandable/expandable.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class LocalAIChatSetting extends StatelessWidget { - const LocalAIChatSetting({super.key}); - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => LocalAIChatSettingBloc()), - BlocProvider( - create: (context) => LocalAIChatToggleBloc() - ..add(const LocalAIChatToggleEvent.started()), - ), - ], - child: ExpandableNotifier( - child: BlocListener( - listener: (context, state) { - // Listen to the toggle state and expand the panel if the state is ready. - final controller = ExpandableController.of( - context, - required: true, - )!; - - // Neet to wrap with WidgetsBinding.instance.addPostFrameCallback otherwise the - // ExpandablePanel not expanded sometimes. Maybe because the ExpandablePanel is not - // built yet when the listener is called. - WidgetsBinding.instance.addPostFrameCallback( - (_) { - state.pageIndicator.when( - error: (_) => controller.expanded = false, - ready: (enabled) { - controller.expanded = enabled; - context.read().add( - const LocalAIChatSettingEvent.refreshAISetting(), - ); - }, - loading: () => controller.expanded = false, - ); - }, - debugLabel: 'LocalAI.showLocalAIChatSetting', - ); - }, - child: ExpandablePanel( - theme: const ExpandableThemeData( - headerAlignment: ExpandablePanelHeaderAlignment.center, - tapBodyToCollapse: false, - hasIcon: false, - tapBodyToExpand: false, - tapHeaderToExpand: false, - ), - header: const SizedBox.shrink(), - collapsed: const SizedBox.shrink(), - expanded: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - // child: _LocalLLMInfoWidget(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, state) { - // If the progress indicator is startOfflineAIApp, then don't show the LLM model. - if (state.progressIndicator == - const LocalAIProgress.startOfflineAIApp()) { - return const SizedBox.shrink(); - } else { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: FlowyText.medium( - LocaleKeys.settings_aiPage_keys_llmModel.tr(), - fontSize: 14, - ), - ), - const Spacer(), - state.aiModelProgress.when( - init: () => const SizedBox.shrink(), - loading: () { - return const Expanded( - child: Row( - children: [ - Spacer(), - CircularProgressIndicator.adaptive(), - ], - ), - ); - }, - finish: (err) => (err == null) - ? const _SelectLocalModelDropdownMenu() - : const SizedBox.shrink(), - ), - ], - ); - } - }, - ), - const IntrinsicHeight(child: _LocalAIStateWidget()), - ], - ), - ), - ), - ), - ), - ); - } -} - -class LocalAIChatSettingHeader extends StatelessWidget { - const LocalAIChatSettingHeader({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return state.pageIndicator.when( - error: (error) { - return const SizedBox.shrink(); - }, - loading: () { - return Row( - children: [ - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIStart.tr(), - ), - const Spacer(), - const CircularProgressIndicator.adaptive(), - const HSpace(8), - ], - ); - }, - ready: (isEnabled) { - return Row( - children: [ - const FlowyText('Enable Local AI Chat'), - const Spacer(), - Toggle( - value: isEnabled, - onChanged: (_) { - context - .read() - .add(const LocalAIChatToggleEvent.toggle()); - }, - ), - ], - ); - }, - ); - }, - ); - } -} - -class _SelectLocalModelDropdownMenu extends StatelessWidget { - const _SelectLocalModelDropdownMenu(); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Flexible( - child: SettingsDropdown( - key: const Key('_SelectLocalModelDropdownMenu'), - onChanged: (model) => context.read().add( - LocalAIChatSettingEvent.selectLLMConfig(model), - ), - selectedOption: state.selectedLLMModel!, - options: state.models - .map( - (llm) => buildDropdownMenuEntry( - context, - value: llm, - label: llm.chatModel, - ), - ) - .toList(), - ), - ); - }, - ); - } -} - -class _LocalAIStateWidget extends StatelessWidget { - const _LocalAIStateWidget(); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final error = errorFromState(state); - if (error == null) { - // If the error is null, handle selected llm model. - if (state.progressIndicator != null) { - final child = state.progressIndicator!.when( - showDownload: ( - LocalModelResourcePB llmResource, - LLMModelPB llmModel, - ) => - _ShowDownloadIndicator( - llmResource: llmResource, - llmModel: llmModel, - ), - startDownloading: (llmModel) { - return DownloadingIndicator( - key: UniqueKey(), - llmModel: llmModel, - onFinish: () => context - .read() - .add(const LocalAIChatSettingEvent.finishDownload()), - onCancel: () => context - .read() - .add(const LocalAIChatSettingEvent.cancelDownload()), - ); - }, - finishDownload: () => const InitLocalAIIndicator(), - checkPluginState: () => const PluginStateIndicator(), - startOfflineAIApp: () => OpenOrDownloadOfflineAIApp( - onRetry: () { - context - .read() - .add(const LocalAIChatSettingEvent.refreshAISetting()); - }, - ), - ); - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: child, - ); - } else { - return const SizedBox.shrink(); - } - } else { - return Opacity( - opacity: 0.5, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: FlowyText( - error.msg, - maxLines: 10, - ), - ), - ); - } - }, - ); - } - - FlowyError? errorFromState(LocalAIChatSettingState state) { - final err = state.aiModelProgress.when( - loading: () => null, - finish: (err) => err, - init: () {}, - ); - - if (err == null) { - state.selectLLMState.when( - loading: () => null, - finish: (err) => err, - ); - } - - return err; - } -} - -void _showDownloadDialog( - BuildContext context, - LocalModelResourcePB llmResource, - LLMModelPB llmModel, -) { - if (llmResource.pendingResources.isEmpty) { - return; - } - - final res = llmResource.pendingResources.first; - String desc = ""; - switch (res.resType) { - case PendingResourceTypePB.AIModel: - desc = LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr( - args: [ - llmResource.pendingResources[0].name, - llmResource.pendingResources[0].fileSize, - ], - ); - break; - case PendingResourceTypePB.OfflineApp: - desc = LocaleKeys.settings_aiPage_keys_downloadAppFlowyOfflineAI.tr(); - break; - } - - showConfirmDialog( - context: context, - style: ConfirmPopupStyle.cancelAndOk, - title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr( - args: [res.name], - ), - description: desc, - confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () => context.read().add( - LocalAIChatSettingEvent.startDownloadModel( - llmModel, - ), - ), - onCancel: () => context.read().add( - const LocalAIChatSettingEvent.cancelDownload(), - ), - ); -} - -class _ShowDownloadIndicator extends StatelessWidget { - const _ShowDownloadIndicator({ - required this.llmResource, - required this.llmModel, - }); - final LocalModelResourcePB llmResource; - final LLMModelPB llmModel; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Row( - children: [ - const Spacer(), - IntrinsicWidth( - child: SizedBox( - height: 30, - child: FlowyButton( - text: FlowyText( - LocaleKeys.settings_aiPage_keys_downloadAIModelButton.tr(), - fontSize: 14, - color: const Color(0xFF005483), - ), - leftIcon: const FlowySvg( - FlowySvgs.local_model_download_s, - color: Color(0xFF005483), - ), - onTap: () { - _showDownloadDialog(context, llmResource, llmModel); - }, - ), - ), - ), - ], - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index f9dc6fa4d8..b836f15b03 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -1,17 +1,17 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; -import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'ollama_setting.dart'; +import 'plugin_status_indicator.dart'; + class LocalAISetting extends StatefulWidget { const LocalAISetting({super.key}); @@ -20,117 +20,129 @@ class LocalAISetting extends StatefulWidget { } class _LocalAISettingState extends State { + final expandableController = ExpandableController(initialExpanded: false); + + @override + void dispose() { + expandableController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocProvider( + create: (context) => LocalAiPluginBloc(), + child: BlocConsumer( + listener: (context, state) { + expandableController.value = state.isEnabled; + }, + builder: (context, state) { + return ExpandablePanel( + controller: expandableController, + theme: ExpandableThemeData( + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, + ), + header: LocalAiSettingHeader( + isEnabled: state.isEnabled, + isToggleable: state is ReadyLocalAiPluginState, + ), + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: EdgeInsets.only(top: 12), + child: LocalAISettingPanel(), + ), + ); + }, + ), + ); + } +} + +class LocalAiSettingHeader extends StatelessWidget { + const LocalAiSettingHeader({ + super.key, + required this.isEnabled, + required this.isToggleable, + }); + + final bool isEnabled; + final bool isToggleable; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const VSpace(4), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), + maxLines: 3, + fontSize: 12, + ), + ], + ), + ), + IgnorePointer( + ignoring: !isToggleable, + child: Opacity( + opacity: isToggleable ? 1 : 0.5, + child: Toggle( + value: isEnabled, + onChanged: (_) => _onToggleChanged(context), + ), + ), + ), + ], + ); + } + + void _onToggleChanged(BuildContext context) { + if (isEnabled) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(), + description: + LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + context + .read() + .add(const LocalAiPluginEvent.toggle()); + }, + ); + } else { + context.read().add(const LocalAiPluginEvent.toggle()); + } + } +} + +class LocalAISettingPanel extends StatelessWidget { + const LocalAISettingPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( builder: (context, state) { - if (state.aiSettings == null) { + if (state is! ReadyLocalAiPluginState) { return const SizedBox.shrink(); } - return BlocProvider( - create: (context) => - LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: ExpandableNotifier( - child: BlocListener( - listener: (context, state) { - final controller = - ExpandableController.of(context, required: true)!; - state.pageIndicator.when( - error: (_) => controller.expanded = false, - ready: (enabled) => controller.expanded = enabled, - loading: () => controller.expanded = false, - ); - }, - child: ExpandablePanel( - theme: const ExpandableThemeData( - headerAlignment: ExpandablePanelHeaderAlignment.center, - tapBodyToCollapse: false, - hasIcon: false, - tapBodyToExpand: false, - tapHeaderToExpand: false, - ), - header: const LocalAISettingHeader(), - collapsed: const SizedBox.shrink(), - expanded: Column( - children: [ - const VSpace(6), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - borderRadius: - const BorderRadius.all(Radius.circular(4)), - ), - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: LocalAIChatSetting(), - ), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -class LocalAISettingHeader extends StatelessWidget { - const LocalAISettingHeader({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return state.pageIndicator.when( - error: (error) { - return const SizedBox.shrink(); - }, - loading: () { - return const CircularProgressIndicator.adaptive(); - }, - ready: (isEnabled) { - return Row( - children: [ - FlowyText( - LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), - ), - const Spacer(), - Toggle( - value: isEnabled, - onChanged: (_) { - if (isEnabled) { - showConfirmDialog( - context: context, - title: LocaleKeys - .settings_aiPage_keys_disableLocalAITitle - .tr(), - description: LocaleKeys - .settings_aiPage_keys_disableLocalAIDescription - .tr(), - confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () => context - .read() - .add(const LocalAIToggleEvent.toggle()), - ); - } else { - context - .read() - .add(const LocalAIToggleEvent.toggle()); - } - }, - ), - ], - ); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LocalAIStatusIndicator(), + const VSpace(10), + OllamaSettingPage(), + ], ); }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart new file mode 100644 index 0000000000..e90c42444f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalSettingsAIView extends StatelessWidget { + const LocalSettingsAIView({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SettingsAIBloc(userProfile, workspaceId) + ..add(const SettingsAIEvent.started()), + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: "", + children: [ + const LocalAISetting(), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart index 22aaf0bcca..7357c2951c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -1,47 +1,65 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; -import 'package: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/style_widget/text.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AIModelSelection extends StatelessWidget { const AIModelSelection({super.key}); + static const double height = 49; @override Widget build(BuildContext context) { return BlocBuilder( + buildWhen: (previous, current) => + previous.availableModels != current.availableModels, builder: (context, state) { + final models = state.availableModels?.models; + if (models == null) { + return const SizedBox( + // Using same height as SettingsDropdown to avoid layout shift + height: height, + ); + } + + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + final selectedModel = state.availableModels!.selectedModel; + return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( + Expanded( child: FlowyText.medium( LocaleKeys.settings_aiPage_keys_llmModelType.tr(), - fontSize: 14, + overflow: TextOverflow.ellipsis, ), ), - const Spacer(), Flexible( child: SettingsDropdown( key: const Key('_AIModelSelection'), onChanged: (model) => context .read() .add(SettingsAIEvent.selectModel(model)), - selectedOption: state.userProfile.aiModel, - options: _availableModels + selectedOption: selectedModel, + selectOptionCompare: (left, right) => + left?.name == right?.name, + options: [...localModels, ...cloudModels] .map( - (format) => buildDropdownMenuEntry( + (model) => buildDropdownMenuEntry( context, - value: format, - label: _titleForAIModel(format), + value: model, + label: + model.isLocal ? "${model.i18n} 🔐" : model.i18n, + subLabel: model.desc, + maximumHeight: height, ), ) .toList(), @@ -54,29 +72,3 @@ class AIModelSelection extends StatelessWidget { ); } } - -List _availableModels = [ - AIModelPB.DefaultModel, - AIModelPB.Claude3Opus, - AIModelPB.Claude3Sonnet, - AIModelPB.GPT4oMini, - AIModelPB.GPT4o, -]; - -String _titleForAIModel(AIModelPB model) { - switch (model) { - case AIModelPB.DefaultModel: - return "Default"; - case AIModelPB.Claude3Opus: - return "Claude 3 Opus"; - case AIModelPB.Claude3Sonnet: - return "Claude 3 Sonnet"; - case AIModelPB.GPT4oMini: - return "GPT-4o-mini"; - case AIModelPB.GPT4o: - return "GPT-4o"; - default: - Log.error("Unknown AI model: $model, fallback to default"); - return "Default"; - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart new file mode 100644 index 0000000000..6f38043927 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OllamaSettingPage extends StatelessWidget { + const OllamaSettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + OllamaSettingBloc()..add(const OllamaSettingEvent.started()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.inputItems != current.inputItems || + previous.isEdited != current.isEdited, + builder: (context, state) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + padding: EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + for (final item in state.inputItems) + _SettingItemWidget(item: item), + _SaveButton(isEdited: state.isEdited), + ], + ), + ); + }, + ), + ); + } +} + +class _SettingItemWidget extends StatelessWidget { + const _SettingItemWidget({required this.item}); + + final SettingItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(item.content + item.settingType.title), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + item.settingType.title, + fontSize: 12, + figmaLineHeight: 16, + ), + const VSpace(4), + SizedBox( + height: 32, + child: FlowyTextField( + autoFocus: false, + hintText: item.hintText, + text: item.content, + onChanged: (content) { + context.read().add( + OllamaSettingEvent.onEdit(content, item.settingType), + ); + }, + ), + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({required this.isEdited}); + + final bool isEdited; + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerEnd, + child: FlowyTooltip( + message: isEdited ? null : 'No changes', + child: SizedBox( + child: FlowyButton( + text: FlowyText( + 'Apply', + figmaLineHeight: 20, + color: Theme.of(context).colorScheme.onPrimary, + ), + disable: !isEdited, + expandText: false, + margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), + onTap: () { + if (isEdited) { + context + .read() + .add(const OllamaSettingEvent.submit()); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart deleted file mode 100644 index bf601b6184..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; -import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class PluginStateIndicator extends StatelessWidget { - const PluginStateIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - PluginStateBloc()..add(const PluginStateEvent.started()), - child: BlocBuilder( - builder: (context, state) { - return state.action.when( - init: () => const _InitPlugin(), - ready: () => const _LocalAIReadyToUse(), - restartPlugin: () => const _ReloadButton(), - loadingPlugin: () => const _InitPlugin(), - startAIOfflineApp: () => OpenOrDownloadOfflineAIApp( - onRetry: () { - context - .read() - .add(const PluginStateEvent.started()); - }, - ), - ); - }, - ), - ); - } -} - -class _InitPlugin extends StatelessWidget { - const _InitPlugin(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - FlowyText(LocaleKeys.settings_aiPage_keys_localAIStart.tr()), - const Spacer(), - const SizedBox( - height: 20, - child: CircularProgressIndicator.adaptive(), - ), - ], - ); - } -} - -class _ReloadButton extends StatelessWidget { - const _ReloadButton(); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const FlowySvg( - FlowySvgs.download_warn_s, - color: Color(0xFFC62828), - ), - const HSpace(6), - FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()), - const Spacer(), - SizedBox( - height: 30, - child: FlowyButton( - useIntrinsicWidth: true, - text: - FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()), - onTap: () { - context.read().add( - const PluginStateEvent.restartLocalAI(), - ); - }, - ), - ), - ], - ); - } -} - -class _LocalAIReadyToUse extends StatelessWidget { - const _LocalAIReadyToUse(); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFEDF7ED), - borderRadius: BorderRadius.all( - Radius.circular(4), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - Flexible( - child: FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - maxLines: 3, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.settings_aiPage_keys_openModelDirectory.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - onTap: () { - context.read().add( - const PluginStateEvent.openModelDirectory(), - ); - }, - ), - ), - ], - ), - ), - ); - } -} - -class OpenOrDownloadOfflineAIApp extends StatelessWidget { - const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); - - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DownloadOfflineAIBloc(), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = - () => context.read().add( - const DownloadOfflineAIEvent.started(), - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - // const SizedBox( - // height: 6, - // ), // Replaced VSpace with SizedBox for simplicity - // SizedBox( - // height: 30, - // child: FlowyButton( - // useIntrinsicWidth: true, - // margin: const EdgeInsets.symmetric(horizontal: 12), - // text: FlowyText( - // LocaleKeys.settings_aiPage_keys_activeOfflineAI.tr(), - // ), - // onTap: onRetry, - // ), - // ), - ], - ); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart new file mode 100644 index 0000000000..a280cf0644 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart @@ -0,0 +1,363 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalAIStatusIndicator extends StatelessWidget { + const LocalAIStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + ready: (_, version, runningState, lackOfResource) { + if (lackOfResource != null) { + return _LackOfResource(resource: lackOfResource); + } + + return switch (runningState) { + RunningStatePB.ReadyToRun => const _ReadyToRun(), + RunningStatePB.Connecting || + RunningStatePB.Connected => + _Initializing(), + RunningStatePB.Running => _LocalAIRunning(version: version), + RunningStatePB.Stopped => const _RestartPluginButton(), + _ => const SizedBox.shrink(), + }; + }, + orElse: () => const SizedBox.shrink(), + ); + }, + ); + } +} + +class _ReadyToRun extends StatelessWidget { + const _ReadyToRun(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _Initializing extends StatelessWidget { + const _Initializing(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + HSpace(8), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _RestartPluginButton extends StatelessWidget { + const _RestartPluginButton(); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_error_filled_s, + size: Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + context + .read() + .add(const LocalAiPluginEvent.restart()); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _LocalAIRunning extends StatelessWidget { + const _LocalAIRunning({ + required this.version, + }); + + final String version; + + @override + Widget build(BuildContext context) { + final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr(); + final text = version.isEmpty ? runningText : "$runningText ($version)"; + + return Container( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Expanded( + child: FlowyText( + text, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _LackOfResource extends StatelessWidget { + const _LackOfResource({required this.resource}); + + final LackOfAIResourcePB resource; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + FlowySvg( + FlowySvgs.toast_error_filled_s, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: switch (resource.resourceType) { + LackOfAIResourceTypePB.PluginExecutableNotReady => + _buildNoLAI(context), + LackOfAIResourceTypePB.OllamaServerNotReady => + _buildNoOllama(context), + LackOfAIResourceTypePB.MissingModel => + _buildNoModel(context, resource.missingModelNames), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + ); + } + + TextStyle? _textStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + } + + Widget _buildNoLAI(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: _textStyle(context)), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoOllama(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: textStyle), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoModel(BuildContext context, List modelNames) { + final textStyle = _textStyle(context); + + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), + style: textStyle, + ), + TextSpan( + text: modelNames.join(', '), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(), + style: textStyle, + ), + ], + ), + ); + } + + List _downloadInstructions(TextStyle? textStyle) { + return [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan(text: ' ', style: textStyle), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(), + style: textStyle, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart index a6181ba016..c2e75ff2f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -1,39 +1,15 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/shared/af_role_pb_extension.dart'; -import 'package:appflowy/shared/feature_flags.dart'; -import 'package:appflowy/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class AIFeatureOnlySupportedWhenUsingAppFlowyCloud extends StatelessWidget { - const AIFeatureOnlySupportedWhenUsingAppFlowyCloud({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 60, vertical: 30), - child: FlowyText( - LocaleKeys.settings_aiPage_keys_loginToEnableAIFeature.tr(), - maxLines: null, - fontSize: 16, - lineHeight: 1.6, - ), - ); - } -} - class SettingsAIView extends StatelessWidget { const SettingsAIView({ super.key, @@ -49,34 +25,16 @@ class SettingsAIView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - SettingsAIBloc(userProfile, workspaceId, currentWorkspaceMemberRole) - ..add(const SettingsAIEvent.started()), - child: BlocBuilder( - builder: (context, state) { - final children = [ - const AIModelSelection(), - ]; - - children.add(const _AISearchToggle(value: false)); - - if (state.currentWorkspaceMemberRole != null) { - children.add( - _LocalAIOnBoarding( - userProfile: userProfile, - currentWorkspaceMemberRole: state.currentWorkspaceMemberRole!, - workspaceId: workspaceId, - ), - ); - } - - return SettingsBody( - title: LocaleKeys.settings_aiPage_title.tr(), - description: - LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), - children: children, - ); - }, + create: (_) => SettingsAIBloc(userProfile, workspaceId) + ..add(const SettingsAIEvent.started()), + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), + children: [ + const AIModelSelection(), + const _AISearchToggle(value: false), + const LocalAISetting(), + ], ), ); } @@ -124,122 +82,3 @@ class _AISearchToggle extends StatelessWidget { ); } } - -// ignore: unused_element -class _LocalAIOnBoarding extends StatelessWidget { - const _LocalAIOnBoarding({ - required this.userProfile, - required this.currentWorkspaceMemberRole, - required this.workspaceId, - }); - final UserProfilePB userProfile; - final AFRolePB? currentWorkspaceMemberRole; - final String workspaceId; - - @override - Widget build(BuildContext context) { - if (FeatureFlag.planBilling.isOn) { - return BillingGateGuard( - builder: (context) { - return BlocProvider( - create: (context) => LocalAIOnBoardingBloc( - userProfile, - currentWorkspaceMemberRole, - workspaceId, - )..add(const LocalAIOnBoardingEvent.started()), - child: BlocBuilder( - builder: (context, state) { - // Show the local AI settings if the user has purchased the AI Local plan - if (kDebugMode || state.isPurchaseAILocal) { - return const LocalAISetting(); - } else { - if (currentWorkspaceMemberRole?.isOwner ?? false) { - // Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan - return _UpgradeToAILocalPlan( - onTap: () { - context.read().add( - const LocalAIOnBoardingEvent.addSubscription( - SubscriptionPlanPB.AiLocal, - ), - ); - }, - ); - } else { - return const _AskOwnerUpgradeToLocalAI(); - } - } - }, - ), - ); - }, - ); - } else { - return const SizedBox.shrink(); - } - } -} - -class _AskOwnerUpgradeToLocalAI extends StatelessWidget { - const _AskOwnerUpgradeToLocalAI(); - - @override - Widget build(BuildContext context) { - return FlowyText( - LocaleKeys.sideBar_askOwnerToUpgradeToLocalAI.tr(), - color: AFThemeExtension.of(context).strongText, - ); - } -} - -class _UpgradeToAILocalPlan extends StatefulWidget { - const _UpgradeToAILocalPlan({required this.onTap}); - - final VoidCallback onTap; - - @override - State<_UpgradeToAILocalPlan> createState() => _UpgradeToAILocalPlanState(); -} - -class _UpgradeToAILocalPlanState extends State<_UpgradeToAILocalPlan> { - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.medium( - LocaleKeys.sideBar_upgradeToAILocal.tr(), - maxLines: 10, - lineHeight: 1.5, - ), - const VSpace(4), - Opacity( - opacity: 0.6, - child: FlowyText( - LocaleKeys.sideBar_upgradeToAILocalDesc.tr(), - fontSize: 12, - maxLines: 10, - lineHeight: 1.5, - ), - ), - ], - ), - ), - BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const CircularProgressIndicator.adaptive(); - } else { - return Toggle( - value: false, - onChanged: (_) => widget.onTap(), - ); - } - }, - ), - ], - ); - } -} 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 bb0e4aac9f..e242da473b 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 @@ -2,13 +2,14 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/about/app_version.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/email/email_section.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -44,11 +45,11 @@ class _SettingsAccountViewState extends State { child: BlocBuilder( builder: (context, state) { return SettingsBody( - title: LocaleKeys.settings_accountPage_title.tr(), + title: LocaleKeys.newSettings_myAccount_title.tr(), children: [ // user profile SettingsCategory( - title: LocaleKeys.settings_accountPage_general_title.tr(), + title: LocaleKeys.newSettings_myAccount_myProfile.tr(), children: [ AccountUserProfile( name: userName, @@ -60,7 +61,7 @@ class _SettingsAccountViewState extends State { setState(() => userName = newName); context .read() - .add(SettingsUserEvent.updateUserName(newName)); + .add(SettingsUserEvent.updateUserName(name: newName)); }, ), ], @@ -69,34 +70,53 @@ 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) ...[ + state.userProfile.authType != AuthTypePB.Local) ...[ SettingsCategory( - title: LocaleKeys.settings_accountPage_email_title.tr(), + title: LocaleKeys.newSettings_myAccount_myAccount.tr(), children: [ - FlowyText.regular(state.userProfile.email), + SettingsEmailSection( + userProfile: state.userProfile, + ), + ChangePasswordSection( + userProfile: state.userProfile, + ), + AccountSignInOutSection( + userProfile: state.userProfile, + onAction: state.userProfile.authType == AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.authType == AuthTypePB.Local, + ), ], ), ], - // user sign in/out + if (isAuthEnabled && + state.userProfile.authType == AuthTypePB.Local) ...[ + SettingsCategory( + title: LocaleKeys.settings_accountPage_login_title.tr(), + children: [ + AccountSignInOutSection( + userProfile: state.userProfile, + onAction: state.userProfile.authType == AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.authType == AuthTypePB.Local, + ), + ], + ), + ], + + // App version SettingsCategory( - title: LocaleKeys.settings_accountPage_login_title.tr(), - children: [ - AccountSignInOutButton( - userProfile: state.userProfile, - onAction: - state.userProfile.authenticator == AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: state.userProfile.authenticator == - AuthenticatorPB.Local, - ), + title: LocaleKeys.newSettings_myAccount_aboutAppFlowy.tr(), + children: const [ + SettingsAppVersion(), ], ), // user deletion - if (widget.userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (widget.userProfile.authType == AuthTypePB.Server) const AccountDeletionButton(), ], ); 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 450f2167de..77c1116319 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,5 +1,3 @@ -import 'dart:io'; - import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/util/int64_extension.dart'; @@ -211,26 +209,6 @@ class _SettingsBillingViewState extends State { ), ), const SettingsDashedDivider(), - - // Currently, the AI Local tile is only available on macOS - // TODO(nathan): enable windows and linux - if (Platform.isMacOS) - _AITile( - plan: SubscriptionPlanPB.AiLocal, - label: LocaleKeys - .settings_billingPage_addons_aiOnDevice_label - .tr(), - description: LocaleKeys - .settings_billingPage_addons_aiOnDevice_description, - activeDescription: LocaleKeys - .settings_billingPage_addons_aiOnDevice_activeDescription, - canceledDescription: LocaleKeys - .settings_billingPage_addons_aiOnDevice_canceledDescription, - subscriptionInfo: - state.subscriptionInfo.addOns.firstWhereOrNull( - (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, - ), - ), ], ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 7c096b4b2f..a2d911ea40 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -157,7 +157,6 @@ class SettingsManageDataView extends StatelessWidget { if (context.mounted) { showToastNotification( - context, message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), 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 05e7d87b6b..420daa8698 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 @@ -667,6 +667,10 @@ final _planLabels = [ label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(), tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), + ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFileUpload.tr(), ), @@ -712,6 +716,9 @@ final List<_CellItem> _freeLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(), + ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFileUpload.tr(), ), @@ -746,6 +753,9 @@ final List<_CellItem> _proLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.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 2230650164..21896ead0e 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,5 +1,3 @@ -import 'dart:io'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/colors.dart'; @@ -134,46 +132,6 @@ class _SettingsPlanViewState extends State { ), ), const HSpace(8), - - // Currently, the AI Local tile is only available on macOS - // TODO(nathan): enable windows and linux - if (Platform.isMacOS) - Flexible( - child: _AddOnBox( - title: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_title - .tr(), - description: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_description - .tr(), - price: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_price - .tr( - args: [ - SubscriptionPlanPB.AiLocal.priceAnnualBilling, - ], - ), - priceInfo: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_priceInfo - .tr(), - recommend: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_recommend - .tr( - args: [ - SubscriptionPlanPB.AiLocal.priceMonthBilling, - ], - ), - buttonText: state.subscriptionInfo.hasAIOnDevice - ? LocaleKeys - .settings_planPage_planUsage_addons_activeLabel - .tr() - : LocaleKeys - .settings_planPage_planUsage_addons_addLabel - .tr(), - isActive: state.subscriptionInfo.hasAIOnDevice, - plan: SubscriptionPlanPB.AiLocal, - ), - ), ], ), ], @@ -438,23 +396,6 @@ class _PlanUsageSummary extends StatelessWidget { }, ), ], - if (!subscriptionInfo.hasAIOnDevice) ...[ - _ToggleMore( - value: false, - label: LocaleKeys.settings_planPage_planUsage_aiOnDeviceToggle - .tr(), - badgeLabel: - LocaleKeys.settings_planPage_planUsage_aiOnDeviceBadge.tr(), - onTap: () async { - context.read().add( - const SettingsPlanEvent.addSubscription( - SubscriptionPlanPB.AiLocal, - ), - ); - await Future.delayed(const Duration(seconds: 2), () {}); - }, - ), - ], ], ), ], @@ -602,8 +543,8 @@ class _PlanProgressIndicator extends StatelessWidget { borderRadius: BorderRadius.circular(8), color: AFThemeExtension.of(context).progressBarBGColor, border: Border.all( - color: const Color(0xFFDDF1F7).withOpacity( - theme.brightness == Brightness.light ? 1 : 0.1, + color: const Color(0xFFDDF1F7).withValues( + alpha: theme.brightness == Brightness.light ? 1 : 0.1, ), ), ), @@ -673,7 +614,7 @@ class _AddOnBox extends StatelessWidget { border: Border.all( color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB), ), - color: const Color(0xFFF7F8FC).withOpacity(0.05), + color: const Color(0xFFF7F8FC).withValues(alpha: 0.05), borderRadius: BorderRadius.circular(16), ), child: Column( 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 f98ff00a3a..0d3716c7dc 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 @@ -5,7 +5,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/strin import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; @@ -20,7 +22,6 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -485,7 +486,7 @@ class KeyBadge extends StatelessWidget { borderRadius: Corners.s4Border, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.25), + color: Colors.black.withValues(alpha: 0.25), blurRadius: 1, offset: const Offset(0, 1), ), @@ -597,6 +598,10 @@ extension CommandLabel on CommandShortcutEvent { label = LocaleKeys.settings_shortcutsPage_keybindings_alignCenter.tr(); } else if (key == customTextRightAlignCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_alignRight.tr(); + } else if (key == insertInlineMathEquationCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_insertInlineMathEquation + .tr(); } else if (key == undoCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_undo.tr(); } else if (key == redoCommand.key) { 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 ab8d5ee078..9a17016e5f 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 @@ -88,7 +88,7 @@ class SettingsWorkspaceView extends StatelessWidget { autoSeparate: false, children: [ // We don't allow changing workspace name/icon for local/offline - if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceName_title .tr(), @@ -180,7 +180,7 @@ class SettingsWorkspaceView extends StatelessWidget { ), const SettingsCategorySpacer(), - if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + if (userProfile.authType != AuthTypePB.Local) ...[ SingleSettingAction( label: LocaleKeys.settings_workspacePage_manageWorkspace_title .tr(), @@ -1090,9 +1090,11 @@ class _FontListPopupState extends State<_FontListPopup> { hoverColor: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.12), - selectedTileColor: - Theme.of(context).colorScheme.primary.withOpacity(0.12), + .withValues(alpha: 0.12), + selectedTileColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12), contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), minTileHeight: 0, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart index f764bec9e7..2f03fc052c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart @@ -53,8 +53,7 @@ class SettingsPageSitesEvent { ); getIt().setData(ClipboardServiceData(plainText: url)); showToastNotification( - context, - message: LocaleKeys.grid_url_copy.tr(), + message: LocaleKeys.message_copy_success.tr(), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart index 2ac7814de4..b1d9b9cdae 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart @@ -249,11 +249,10 @@ class _FreePlanUpgradeButton extends StatelessWidget { horizontal: 8.0, vertical: 6.0, ), - hoverColor: context.proSecondaryColor.withOpacity(0.9), + hoverColor: context.proSecondaryColor.withValues(alpha: 0.9), onTap: () { if (isOwner) { showToastNotification( - context, message: LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), type: ToastificationType.info, @@ -264,7 +263,6 @@ class _FreePlanUpgradeButton extends StatelessWidget { ); } else { showToastNotification( - context, message: LocaleKeys .settings_sites_namespace_pleaseAskOwnerToSetHomePage .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart index 6555494144..9617f2c8d6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart @@ -216,7 +216,6 @@ class _DomainSettingsDialogState extends State { result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(), ); @@ -234,7 +233,6 @@ class _DomainSettingsDialogState extends State { Log.error('Failed to update namespace: $f'); showToastNotification( - context, message: basicErrorMessage, type: ToastificationType.error, description: errorMessage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart index b7f3cecebf..ad37bae866 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart @@ -203,7 +203,6 @@ class _PublishedViewSettingsDialogState result.fold( (s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); Navigator.of(context).pop(); @@ -212,7 +211,6 @@ class _PublishedViewSettingsDialogState Log.error('update path name failed: $f'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: f.code.publishErrorMessage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart index 7b00e652ed..f3845b0896 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart @@ -178,7 +178,6 @@ class _SettingsSitesPageView extends StatelessWidget { Log.error('Failed to generate payment link for Pro Plan: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), type: ToastificationType.error, @@ -188,14 +187,12 @@ class _SettingsSitesPageView extends StatelessWidget { result != null) { result.fold((_) { showToastNotification( - context, message: LocaleKeys.publish_unpublishSuccessfully.tr(), ); }, (f) { Log.error('Failed to unpublish view: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.publish_unpublishFailed.tr(), type: ToastificationType.error, description: f.msg, @@ -204,14 +201,12 @@ class _SettingsSitesPageView extends StatelessWidget { } else if (type == SettingsSitesActionType.setHomePage && result != null) { result.fold((s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), ); }, (f) { Log.error('Failed to set homepage: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), type: ToastificationType.error, ); @@ -220,14 +215,12 @@ class _SettingsSitesPageView extends StatelessWidget { result != null) { result.fold((s) { showToastNotification( - context, message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), ); }, (f) { Log.error('Failed to remove homepage: ${f.msg}'); showToastNotification( - context, message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 84ba87144b..e262a27cb6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -32,6 +32,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'pages/setting_ai_view/local_settings_ai_view.dart'; import 'widgets/setting_cloud.dart'; @visibleForTesting @@ -139,14 +140,19 @@ class SettingsDialog extends StatelessWidget { case SettingsPage.shortcuts: return const SettingsShortcutsView(); case SettingsPage.ai: - if (user.authenticator == AuthenticatorPB.AppFlowyCloud) { + if (user.authType == AuthTypePB.Server) { return SettingsAIView( + key: ValueKey(workspaceId), userProfile: user, currentWorkspaceMemberRole: currentWorkspaceMemberRole, workspaceId: workspaceId, ); } else { - return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud(); + return LocalSettingsAIView( + key: ValueKey(workspaceId), + userProfile: user, + workspaceId: workspaceId, + ); } case SettingsPage.member: return WorkspaceMembersPage( @@ -170,8 +176,6 @@ class SettingsDialog extends StatelessWidget { ); case SettingsPage.featureFlags: return const FeatureFlagsPage(); - default: - return const SizedBox.shrink(); } } } @@ -364,7 +368,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { }) async { if (cloudUrl.isEmpty || webUrl.isEmpty) { showToastNotification( - context, message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); @@ -376,7 +379,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { if (mounted) { if (isValid) { showToastNotification( - context, message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]), ); @@ -388,7 +390,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> { await runAppFlowy(); } else { showToastNotification( - context, message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); @@ -523,7 +524,6 @@ class _SupportSettings extends StatelessWidget { await getIt().clearAllCache(); if (context.mounted) { showToastNotification( - context, message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart index d005901cff..720f7793f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -9,14 +9,40 @@ DropdownMenuEntry buildDropdownMenuEntry( BuildContext context, { required T value, required String label, + String subLabel = '', T? selectedValue, Widget? leadingWidget, Widget? trailingWidget, String? fontFamily, + double maximumHeight = 29, }) { final fontFamilyUsed = fontFamily != null ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily : defaultFontFamily; + Widget? labelWidget; + if (subLabel.isNotEmpty) { + labelWidget = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + label, + fontSize: 14, + ), + const VSpace(4), + FlowyText.regular( + subLabel, + fontSize: 10, + ), + ], + ); + } else { + labelWidget = FlowyText.regular( + label, + fontSize: 14, + textAlign: TextAlign.start, + fontFamily: fontFamilyUsed, + ); + } return DropdownMenuEntry( style: ButtonStyle( @@ -26,17 +52,12 @@ DropdownMenuEntry buildDropdownMenuEntry( const EdgeInsets.symmetric(horizontal: 6, vertical: 4), ), minimumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), - maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), + maximumSize: WidgetStatePropertyAll(Size(double.infinity, maximumHeight)), ), value: value, label: label, leadingIcon: leadingWidget, - labelWidget: FlowyText.regular( - label, - fontSize: 14, - textAlign: TextAlign.start, - fontFamily: fontFamilyUsed, - ), + labelWidget: labelWidget, trailingIcon: Row( children: [ if (trailingWidget != null) ...[ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart index eb39df8b32..68556f8294 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; class FlowyGradientButton extends StatefulWidget { const FlowyGradientButton({ @@ -49,7 +48,7 @@ class _FlowyGradientButtonState extends State { boxShadow: [ BoxShadow( blurRadius: 4, - color: Colors.black.withOpacity(0.25), + color: Colors.black.withValues(alpha: 0.25), offset: const Offset(0, 2), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart index 5d1c858d29..6c8eeb9ae4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart @@ -12,6 +12,8 @@ class SettingValueDropDown extends StatefulWidget { this.child, this.popoverController, this.offset, + this.boxConstraints, + this.margin = const EdgeInsets.all(6), }); final String currentValue; @@ -21,6 +23,8 @@ class SettingValueDropDown extends StatefulWidget { final Widget? child; final PopoverController? popoverController; final Offset? offset; + final BoxConstraints? boxConstraints; + final EdgeInsets margin; @override State createState() => _SettingValueDropDownState(); @@ -33,12 +37,14 @@ class _SettingValueDropDownState extends State { key: widget.popoverKey, controller: widget.popoverController, direction: PopoverDirection.bottomWithCenterAligned, + margin: widget.margin, popupBuilder: widget.popupBuilder, - constraints: const BoxConstraints( - minWidth: 80, - maxWidth: 160, - maxHeight: 400, - ), + constraints: widget.boxConstraints ?? + const BoxConstraints( + minWidth: 80, + maxWidth: 160, + maxHeight: 400, + ), offset: widget.offset, onClose: widget.onClose, child: widget.child ?? diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart index a111fa2626..33c81b99e8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -25,15 +26,18 @@ class SettingsCategory extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - FlowyText.semibold( + Text( title, + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), maxLines: 2, - fontSize: 16, overflow: TextOverflow.ellipsis, ), if (tooltip != null) ...[ @@ -47,7 +51,7 @@ class SettingsCategory extends StatelessWidget { if (actions != null) ...actions!, ], ), - const VSpace(8), + const VSpace(16), if (description?.isNotEmpty ?? false) ...[ FlowyText.regular( description!, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart index 5637fdd20c..deec09c1d8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// This is used to create a uniform space and divider @@ -7,6 +8,11 @@ class SettingsCategorySpacer extends StatelessWidget { const SettingsCategorySpacer({super.key}); @override - Widget build(BuildContext context) => - const Divider(height: 32, color: Color(0xFFF2F2F2)); + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Divider( + height: 32, + color: theme.borderColorScheme.greyPrimary, + ); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart index 56d2c8d2cc..e392ed91f0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart @@ -16,9 +16,11 @@ class SettingsDropdown extends StatefulWidget { this.onChanged, this.actions, this.expandWidth = true, + this.selectOptionCompare, }); final T selectedOption; + final CompareFunction? selectOptionCompare; final List> options; final void Function(T)? onChanged; final List? actions; @@ -52,6 +54,7 @@ class _SettingsDropdownState extends State> { expandedInsets: widget.expandWidth ? EdgeInsets.zero : null, initialSelection: widget.selectedOption, dropdownMenuEntries: widget.options, + selectOptionCompare: widget.selectOptionCompare, textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( fontFamily: fontFamilyUsed, fontWeight: FontWeight.w400, @@ -61,7 +64,7 @@ class _SettingsDropdownState extends State> { const WidgetStatePropertyAll(Size(double.infinity, 250)), elevation: const WidgetStatePropertyAll(10), shadowColor: - WidgetStatePropertyAll(Colors.black.withOpacity(0.4)), + WidgetStatePropertyAll(Colors.black.withValues(alpha: 0.4)), backgroundColor: WidgetStatePropertyAll( Theme.of(context).cardColor, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart index c028e6886d..7409070ba9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; /// Renders a simple header for the settings view /// @@ -13,10 +13,16 @@ class SettingsHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.semibold(title, fontSize: 24), + Text( + title, + style: theme.textStyle.heading2.enhanced( + color: theme.textColorScheme.primary, + ), + ), if (description?.isNotEmpty == true) ...[ const VSpace(8), FlowyText( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart index a356e3fd50..6b0c920a04 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart @@ -128,11 +128,11 @@ class SingleSettingAction extends StatelessWidget { Color? hoverColor(BuildContext context) { if (buttonType.isDangerous) { - return Theme.of(context).colorScheme.error.withOpacity(0.1); + return Theme.of(context).colorScheme.error.withValues(alpha: 0.1); } if (buttonType.isPrimary) { - return Theme.of(context).colorScheme.primary.withOpacity(0.9); + return Theme.of(context).colorScheme.primary.withValues(alpha: 0.9); } if (buttonType.isHighlight) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart index ab952386bd..72aed27ad4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart @@ -33,15 +33,39 @@ void showEmojiPickerMenu( Alignment alignment, Offset offset, ) { - final top = alignment == Alignment.topLeft ? offset.dy : null; - final bottom = alignment == Alignment.bottomLeft ? offset.dy : null; + (double? left, double? top, double? right, double? bottom) getPosition() { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } + + final (left, top, right, bottom) = getPosition(); keepEditorFocusNotifier.increase(); late OverlayEntry emojiPickerMenuEntry; emojiPickerMenuEntry = FullScreenOverlayEntry( + left: left, top: top, bottom: bottom, - left: offset.dx, + right: right, dismissCallback: () => keepEditorFocusNotifier.decrease(), builder: (context) => Material( type: MaterialType.transparency, @@ -56,6 +80,7 @@ void showEmojiPickerMenu( child: EmojiSelectionMenu( onSubmitted: (emoji) { editorState.insertTextAtCurrentSelection(emoji); + emojiPickerMenuEntry.remove(); }, onExit: () { // close emoji panel diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart index 078cf64963..5bb4766353 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart @@ -35,7 +35,7 @@ CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { // Calculate the offset and alignment // Don't like these values being hardcoded but unsure how to grab the // values dynamically to match the /emoji command. - const menuHeight = 200.0; + const menuHeight = 380.0; const menuOffset = Offset(10, 10); // Tried (0, 10) but that looked off final editorOffset = @@ -47,7 +47,7 @@ CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { alignment = Alignment.topLeft; final bottomRight = rect.bottomRight; final topRight = rect.topRight; - final newOffset = bottomRight + menuOffset; + var newOffset = bottomRight + menuOffset; offset = Offset( newOffset.dx, newOffset.dy, @@ -55,12 +55,12 @@ CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { // show above if (newOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { - offset = topRight - menuOffset; + newOffset = topRight - menuOffset; alignment = Alignment.bottomLeft; offset = Offset( newOffset.dx, - MediaQuery.of(context).size.height - newOffset.dy, + editorHeight + editorOffset.dy - newOffset.dy, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart index 22d2bbe034..f329e9dd1c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart @@ -3,8 +3,8 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'models/emoji_category_models.dart'; import 'emoji_picker.dart'; +import 'models/emoji_category_models.dart'; part 'emji_picker_config.freezed.dart'; @@ -87,8 +87,6 @@ class EmojiPickerConfig with _$EmojiPickerConfig { return emojiCategoryIcons.flagIcon; case EmojiCategory.SEARCH: return emojiCategoryIcons.searchIcon; - default: - throw Exception('Unsupported EmojiCategory'); } } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index 645c3daa65..5f158f4ae1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -3,6 +3,7 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; @@ -19,7 +20,6 @@ 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/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget { const VSpace(8), const AppFlowyCloudEnableSync(), const VSpace(6), - const AppFlowyCloudSyncLogEnabled(), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(12), RestartButton( onClick: () { @@ -130,7 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget { final List children = []; children.addAll([ const AppFlowyCloudEnableSync(), - const AppFlowyCloudSyncLogEnabled(), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(40), ]); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart index cf51d7a3e9..8a85377efe 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart @@ -2,7 +2,6 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -64,12 +63,8 @@ class SettingThirdPartyLogin extends StatelessWidget { ) async { result.fold( (user) async { - if (user.encryptionType == EncryptionTypePB.Symmetric) { - getIt().pushEncryptionScreen(context, user); - } else { - didLogin(); - await runAppFlowy(); - } + didLogin(); + await runAppFlowy(); }, (error) => showSnapBar(context, error.msg), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index c9069b8be3..04a93656ca 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -63,8 +63,7 @@ class SettingsMenu extends StatelessWidget { changeSelectedPage: changeSelectedPage, ), if (FeatureFlag.membersSettings.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + userProfile.authType == AuthTypePB.Server) SettingsMenuElement( page: SettingsPage.member, selectedPage: currentPage, @@ -110,8 +109,7 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - if (userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + if (userProfile.authType == AuthTypePB.Server) SettingsMenuElement( page: SettingsPage.sites, selectedPage: currentPage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart index f3cd25afde..ea8ebfe36b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart @@ -16,8 +16,8 @@ class ThemeUploadDecoration extends StatelessWidget { borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), color: Theme.of(context).colorScheme.surface, border: Border.all( - color: AFThemeExtension.of(context).onBackground.withOpacity( - ThemeUploadWidget.fadeOpacity, + color: AFThemeExtension.of(context).onBackground.withValues( + alpha: ThemeUploadWidget.fadeOpacity, ), ), ), @@ -28,7 +28,7 @@ class ThemeUploadDecoration extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(ThemeUploadWidget.fadeOpacity), + .withValues(alpha: ThemeUploadWidget.fadeOpacity), radius: const Radius.circular(ThemeUploadWidget.borderRadius), child: ClipRRect( borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart index edb382d6ee..a7286bee48 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart @@ -15,7 +15,7 @@ class ThemeUploadFailureWidget extends StatelessWidget { color: Theme.of(context) .colorScheme .error - .withOpacity(ThemeUploadWidget.fadeOpacity), + .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), padding: ThemeUploadWidget.padding, child: Column( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart index 9e25dece0e..bdc5ef0546 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -1,12 +1,12 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart index 5e0ad15f38..1d3e7ab0f8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart @@ -14,7 +14,7 @@ class ThemeUploadLoadingWidget extends StatelessWidget { color: Theme.of(context) .colorScheme .surface - .withOpacity(ThemeUploadWidget.fadeOpacity), + .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart index 0113d26a37..02a7c8e7ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart @@ -15,7 +15,7 @@ class UploadNewThemeWidget extends StatelessWidget { color: Theme.of(context) .colorScheme .surface - .withOpacity(ThemeUploadWidget.fadeOpacity), + .withValues(alpha: ThemeUploadWidget.fadeOpacity), padding: ThemeUploadWidget.padding, child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart index 8bfc187422..d965670f77 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart @@ -23,6 +23,7 @@ abstract class AppFlowyDatePicker extends StatefulWidget { this.onIncludeTimeChanged, this.onIsRangeChanged, this.onReminderSelected, + this.enableDidUpdate = true, }); final DateTime? dateTime; @@ -55,6 +56,7 @@ abstract class AppFlowyDatePicker extends StatefulWidget { final ReminderOption reminderOption; final OnReminderSelected? onReminderSelected; + final bool enableDidUpdate; } abstract class AppFlowyDatePickerState @@ -75,34 +77,31 @@ abstract class AppFlowyDatePickerState @override void initState() { super.initState(); - - dateTime = widget.dateTime; - startDateTime = widget.isRange ? widget.dateTime : null; - endDateTime = widget.isRange ? widget.endDateTime : null; - includeTime = widget.includeTime; - isRange = widget.isRange; - reminderOption = widget.reminderOption; + initData(); focusedDateTime = widget.dateTime ?? DateTime.now(); } @override void didUpdateWidget(covariant oldWidget) { - dateTime = widget.dateTime; - if (widget.isRange) { - startDateTime = widget.dateTime; - endDateTime = widget.endDateTime; - } else { - startDateTime = endDateTime = null; + if (widget.enableDidUpdate) { + initData(); } - includeTime = widget.includeTime; - isRange = widget.isRange; if (oldWidget.reminderOption != widget.reminderOption) { reminderOption = widget.reminderOption; } super.didUpdateWidget(oldWidget); } + void initData() { + dateTime = widget.dateTime; + startDateTime = widget.isRange ? widget.dateTime : null; + endDateTime = widget.isRange ? widget.endDateTime : null; + includeTime = widget.includeTime; + isRange = widget.isRange; + reminderOption = widget.reminderOption; + } + void onDateSelectedFromDatePicker( DateTime? newStartDateTime, DateTime? newEndDateTime, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart index c404f576b1..fada23e994 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart @@ -32,6 +32,7 @@ class DesktopAppFlowyDatePicker extends AppFlowyDatePicker { super.onIncludeTimeChanged, super.onIsRangeChanged, super.onReminderSelected, + super.enableDidUpdate, this.popoverMutex, this.options = const [], }); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart index 301fd038ee..54fc2fac2a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart @@ -16,7 +16,6 @@ import 'package:flutter/services.dart'; class DatePickerOptions { DatePickerOptions({ DateTime? focusedDay, - this.popoverMutex, this.selectedDay, this.includeTime = false, this.isRange = false, @@ -31,7 +30,6 @@ class DatePickerOptions { }) : focusedDay = focusedDay ?? DateTime.now(); final DateTime focusedDay; - final PopoverMutex? popoverMutex; final DateTime? selectedDay; final bool includeTime; final bool isRange; @@ -48,6 +46,7 @@ class DatePickerOptions { abstract class DatePickerService { void show(Offset offset, {required DatePickerOptions options}); + void dismiss(); } @@ -60,6 +59,7 @@ class DatePickerMenu extends DatePickerService { final BuildContext context; final EditorState editorState; + PopoverMutex? popoverMutex; OverlayEntry? _menuEntry; @@ -67,6 +67,9 @@ class DatePickerMenu extends DatePickerService { void dismiss() { _menuEntry?.remove(); _menuEntry = null; + popoverMutex?.close(); + popoverMutex?.dispose(); + popoverMutex = null; } @override @@ -97,6 +100,7 @@ class DatePickerMenu extends DatePickerService { } } + popoverMutex = PopoverMutex(); _menuEntry = OverlayEntry( builder: (_) => Material( type: MaterialType.transparency, @@ -119,6 +123,7 @@ class DatePickerMenu extends DatePickerService { offset: Offset(offsetX, offsetY), showBelow: showBelow, options: options, + popoverMutex: popoverMutex, ), ], ), @@ -137,11 +142,13 @@ class _AnimatedDatePicker extends StatelessWidget { required this.offset, required this.showBelow, required this.options, + this.popoverMutex, }); final Offset offset; final bool showBelow; final DatePickerOptions options; + final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { @@ -165,11 +172,12 @@ class _AnimatedDatePicker extends StatelessWidget { dateFormat: options.dateFormat.simplified, timeFormat: options.timeFormat.simplified, dateTime: options.selectedDay, - popoverMutex: options.popoverMutex, + popoverMutex: popoverMutex, reminderOption: options.selectedReminderOption ?? ReminderOption.none, onDaySelected: options.onDaySelected, onRangeSelected: options.onRangeSelected, onReminderSelected: options.onReminderSelected, + enableDidUpdate: false, ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart new file mode 100644 index 0000000000..43ab8897e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +typedef SimpleAFDialogAction = (String, void Function(BuildContext)?); + +/// A simple dialog with a title, content, and actions. +/// +/// The primary button is a filled button and colored using theme or destructive +/// color depending on the [isDestructive] parameter. The secondary button is an +/// outlined button. +/// +Future showSimpleAFDialog({ + required BuildContext context, + required String title, + required String content, + bool isDestructive = false, + required SimpleAFDialogAction primaryAction, + SimpleAFDialogAction? secondaryAction, + bool barrierDismissible = true, +}) { + final theme = AppFlowyTheme.of(context); + + return showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + barrierDismissible: barrierDismissible, + builder: (_) { + return AFModal( + constraints: BoxConstraints( + maxWidth: AFModalDimension.S, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + title, + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.close_s, + size: Size.square(20), + ); + }, + ), + ], + ), + Flexible( + child: ConstrainedBox( + // AFModalDimension.dialogHeight - header - footer + constraints: BoxConstraints(minHeight: 108.0), + child: AFModalBody( + child: Text(content), + ), + ), + ), + AFModalFooter( + trailing: [ + if (secondaryAction != null) + AFOutlinedButton.normal( + onTap: () { + secondaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(secondaryAction.$1); + }, + ), + isDestructive + ? AFFilledButton.destructive( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text( + primaryAction.$1, + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ) + : AFFilledButton.primary( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(primaryAction.$1); + }, + ), + ], + ), + ], + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 1a36ea2a66..7e30c4fa55 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -157,7 +157,6 @@ class _NavigatorTextFieldDialogState extends State { onOkPressed: () { if (newValue.isEmpty) { showToastNotification( - context, message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), ); return; @@ -363,8 +362,7 @@ class OkCancelButton extends StatelessWidget { } } -void showToastNotification( - BuildContext context, { +ToastificationItem showToastNotification({ String? message, TextSpan? richMessage, String? description, @@ -376,7 +374,7 @@ void showToastNotification( (message == null) != (richMessage == null), "Exactly one of message or richMessage must be non-null.", ); - toastification.showCustom( + return toastification.showCustom( alignment: Alignment.bottomCenter, autoCloseDuration: const Duration(milliseconds: 3000), callbacks: callbacks ?? const ToastificationCallbacks(), @@ -673,9 +671,13 @@ Future showCustomConfirmDialog({ String? confirmLabel, ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, bool closeOnConfirm = true, + bool showCloseButton = true, + bool enableKeyboardListener = true, + bool barrierDismissible = true, }) { return showDialog( context: context, + barrierDismissible: barrierDismissible, builder: (context) { return Dialog( shape: RoundedRectangleBorder( @@ -692,6 +694,8 @@ Future showCustomConfirmDialog({ confirmButtonColor: Theme.of(context).colorScheme.primary, style: style, closeOnAction: closeOnConfirm, + showCloseButton: showCloseButton, + enableKeyboardListener: enableKeyboardListener, child: builder(context), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart index d666e606f6..e3117c7f86 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart @@ -86,7 +86,7 @@ class _BubbleActionListState extends State { ), buildChild: (controller) { return FlowyTooltip( - message: LocaleKeys.questionBubble_help.tr(), + message: LocaleKeys.questionBubble_getSupport.tr(), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -121,22 +121,22 @@ class _BubbleActionListState extends State { if (action is BubbleActionWrapper) { switch (action.inner) { case BubbleAction.whatsNews: - afLaunchUrlString("https://www.appflowy.io/what-is-new"); + afLaunchUrlString('https://www.appflowy.io/what-is-new'); break; - case BubbleAction.help: - afLaunchUrlString("https://discord.gg/9Q2xaN37tV"); + case BubbleAction.getSupport: + afLaunchUrlString('https://discord.gg/9Q2xaN37tV'); break; case BubbleAction.debug: _DebugToast().show(); break; case BubbleAction.shortcuts: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/shortcuts", + 'https://docs.appflowy.io/docs/appflowy/product/shortcuts', ); break; case BubbleAction.markdown: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/markdown", + 'https://docs.appflowy.io/docs/appflowy/product/markdown', ); break; case BubbleAction.github: @@ -144,6 +144,11 @@ class _BubbleActionListState extends State { 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', ); break; + case BubbleAction.helpAndDocumentation: + afLaunchUrlString( + 'https://appflowy.com/guide', + ); + break; } } @@ -155,7 +160,7 @@ class _BubbleActionListState extends State { class _DebugToast { void show() async { - String debugInfo = ""; + String debugInfo = ''; debugInfo += await _getDeviceInfo(); debugInfo += await _getDocumentPath(); await Clipboard.setData(ClipboardData(text: debugInfo)); @@ -168,20 +173,21 @@ class _DebugToast { final deviceInfo = await deviceInfoPlugin.deviceInfo; return deviceInfo.data.entries - .fold('', (prev, el) => "$prev${el.key}: ${el.value}\n"); + .fold('', (prev, el) => '$prev${el.key}: ${el.value}\n'); } Future _getDocumentPath() async { return appFlowyApplicationDataDirectory().then((directory) { final path = directory.path.toString(); - return "Document: $path\n"; + return 'Document: $path\n'; }); } } enum BubbleAction { whatsNews, - help, + helpAndDocumentation, + getSupport, debug, shortcuts, markdown, @@ -204,8 +210,10 @@ extension QuestionBubbleExtension on BubbleAction { switch (this) { case BubbleAction.whatsNews: return LocaleKeys.questionBubble_whatsNew.tr(); - case BubbleAction.help: - return LocaleKeys.questionBubble_help.tr(); + case BubbleAction.helpAndDocumentation: + return LocaleKeys.questionBubble_helpAndDocumentation.tr(); + case BubbleAction.getSupport: + return LocaleKeys.questionBubble_getSupport.tr(); case BubbleAction.debug: return LocaleKeys.questionBubble_debug_name.tr(); case BubbleAction.shortcuts: @@ -221,7 +229,12 @@ extension QuestionBubbleExtension on BubbleAction { switch (this) { case BubbleAction.whatsNews: return const FlowySvg(FlowySvgs.star_s); - case BubbleAction.help: + case BubbleAction.helpAndDocumentation: + return const FlowySvg( + FlowySvgs.help_and_documentation_s, + size: Size.square(16.0), + ); + case BubbleAction.getSupport: return const FlowySvg(FlowySvgs.message_support_s); case BubbleAction.debug: return const FlowySvg(FlowySvgs.debug_s); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart index f5c8ffa146..8b58557455 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -25,18 +25,13 @@ class SocialMediaSection extends CustomActionCell { action: SocialMediaWrapper(social), itemHeight: ActionListSizes.itemHeight, onSelected: (action) { - switch (action.inner) { - case SocialMedia.reddit: - afLaunchUrlString( - 'https://www.reddit.com/r/AppFlowy/', - ); - case SocialMedia.twitter: - afLaunchUrlString( - 'https://x.com/appflowy', - ); - case SocialMedia.forum: - afLaunchUrlString('https://forum.appflowy.io/'); - } + final url = switch (action.inner) { + SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/', + SocialMedia.twitter => 'https://x.com/appflowy', + SocialMedia.forum => 'https://forum.appflowy.com/', + }; + + afLaunchUrlString(url); }, ); }, @@ -79,20 +74,17 @@ extension QuestionBubbleExtension on SocialMedia { case SocialMedia.forum: return Theme.of(context).hintColor; - - default: - return null; } } String get name { switch (this) { case SocialMedia.forum: - return "Community Forum"; + return 'Community Forum'; case SocialMedia.twitter: - return "Twitter – @appflowy"; + return 'Twitter – @appflowy'; case SocialMedia.reddit: - return "Reddit – r/appflowy"; + return 'Reddit – r/appflowy'; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart index 923f695188..f6a2caa5a2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -51,7 +51,6 @@ class FlowyVersionSection extends CustomActionCell { } enableDocumentInternalLog = !enableDocumentInternalLog; showToastNotification( - context, message: enableDocumentInternalLog ? 'Enabled Internal Log' : 'Disabled Internal Log', 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 a602aaed58..765a385b0b 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 @@ -119,7 +119,7 @@ class InteractiveImageToolbar extends StatelessWidget { child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( - hoverColor: Colors.white.withOpacity(0.1), + hoverColor: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Padding( @@ -204,7 +204,7 @@ class InteractiveImageToolbar extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), - color: Colors.black.withOpacity(0.6), + color: Colors.black.withValues(alpha: 0.6), ), child: Padding( padding: const EdgeInsets.all(4), @@ -284,8 +284,9 @@ class _ToolbarItem extends StatelessWidget { child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( - hoverColor: - isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1), + hoverColor: isDisabled + ? Colors.transparent + : Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Container( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart index 86ca1a3018..fe202e7590 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -5,10 +5,12 @@ import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; @@ -62,18 +64,23 @@ class _MoreViewActionsState extends State { ); } - Widget _buildPopup(ViewInfoState state) { + Widget _buildPopup(ViewInfoState viewInfoState) { final userWorkspaceBloc = context.read(); final userProfile = userWorkspaceBloc.userProfile; final workspaceId = userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; - final actions = _buildActions(state); return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => - ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + create: (_) => ViewBloc(view: widget.view) + ..add( + const ViewEvent.initial(), + ), + ), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), ), BlocProvider( create: (context) => SpaceBloc( @@ -84,27 +91,36 @@ class _MoreViewActionsState extends State { ), ), ], - child: BlocBuilder( - builder: (context, state) { - if (state.spaces.isEmpty && - userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { - return const SizedBox.shrink(); - } + child: BlocBuilder( + builder: (context, viewState) { + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty && + userProfile.authType == AuthTypePB.Server) { + return const SizedBox.shrink(); + } - return ListView.builder( - key: ValueKey(state.spaces.hashCode), - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: actions.length, - physics: StyledScrollPhysics(), - itemBuilder: (_, index) => actions[index], + final actions = _buildActions( + context, + viewInfoState, + ); + return ListView.builder( + key: ValueKey(state.spaces.hashCode), + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: actions.length, + physics: StyledScrollPhysics(), + itemBuilder: (_, index) => actions[index], + ); + }, ); }, ), ); } - List _buildActions(ViewInfoState state) { + List _buildActions(BuildContext context, ViewInfoState state) { + final view = context.watch().state.view; final appearanceSettings = context.watch().state; final dateFormat = appearanceSettings.dateFormat; final timeFormat = appearanceSettings.timeFormat; @@ -122,14 +138,24 @@ class _MoreViewActionsState extends State { const FontSizeAction(), ViewAction( type: ViewMoreActionType.divider, - view: widget.view, + view: view, + mutex: popoverMutex, + ), + ], + if (widget.view.isDocument || widget.view.isDatabase) ...[ + LockPageAction( + view: view, + ), + ViewAction( + type: ViewMoreActionType.divider, + view: view, mutex: popoverMutex, ), ], ...viewMoreActionTypes.map( (type) => ViewAction( type: type, - view: widget.view, + view: view, mutex: popoverMutex, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart index 10bc3dde34..2ecec3244c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart @@ -10,10 +10,8 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_ import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -107,6 +105,8 @@ class CustomViewAction extends StatelessWidget { required this.view, required this.leftIcon, required this.label, + this.tooltipMessage, + this.disabled = false, this.onTap, this.mutex, }); @@ -114,6 +114,8 @@ class CustomViewAction extends StatelessWidget { final ViewPB view; final FlowySvgData leftIcon; final String label; + final bool disabled; + final String? tooltipMessage; final VoidCallback? onTap; final PopoverMutex? mutex; @@ -122,17 +124,23 @@ class CustomViewAction extends StatelessWidget { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), - child: FlowyIconTextButton( - margin: const EdgeInsets.symmetric(horizontal: 6), - onTap: onTap, - leftIconBuilder: (onHover) => FlowySvg( - leftIcon, - size: const Size.square(16.0), - ), - iconPadding: 10.0, - textBuilder: (onHover) => FlowyText( - label, - figmaLineHeight: 18.0, + child: FlowyTooltip( + message: tooltipMessage, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + disable: disabled, + onTap: onTap, + leftIcon: FlowySvg( + leftIcon, + size: const Size.square(16.0), + color: disabled ? Theme.of(context).disabledColor : null, + ), + iconPadding: 10.0, + text: FlowyText( + label, + figmaLineHeight: 18.0, + color: disabled ? Theme.of(context).disabledColor : null, + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart new file mode 100644 index 0000000000..202919b639 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_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/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LockPageAction extends StatefulWidget { + const LockPageAction({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _LockPageActionState(); +} + +class _LockPageActionState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add( + ViewLockStatusEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return _buildTextButton(context); + }, + ), + ); + } + + Widget _buildTextButton( + BuildContext context, + ) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + onTap: () => _toggle(context), + leftIconBuilder: (onHover) => FlowySvg( + FlowySvgs.lock_page_s, + size: const Size.square(16.0), + ), + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText( + LocaleKeys.disclosureAction_lockPage.tr(), + figmaLineHeight: 18.0, + ), + rightIconBuilder: (_) => _buildSwitch( + context, + ), + ), + ); + } + + Widget _buildSwitch(BuildContext context) { + final lockState = context.read().state; + if (lockState.isLoadingLockStatus) { + return SizedBox.shrink(); + } + + return Container( + width: 30, + height: 20, + margin: const EdgeInsets.only(right: 6), + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: lockState.isLocked, + activeTrackColor: Theme.of(context).colorScheme.primary, + onChanged: (_) => _toggle(context), + ), + ), + ); + } + + Future _toggle(BuildContext context) async { + final isLocked = context.read().state.isLocked; + + context.read().add( + isLocked ? ViewLockStatusEvent.unlock() : ViewLockStatusEvent.lock(), + ); + + Log.info('update page(${widget.view.id}) lock status: $isLocked'); + } +} + +class LockPageButtonWrapper extends StatelessWidget { + const LockPageButtonWrapper({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.lockPage_lockedOperationTooltip.tr(), + child: IgnorePointer( + child: Opacity( + opacity: 0.5, + child: child, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart index 8e339ca17c..954fc77603 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart @@ -1,6 +1,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -12,7 +13,7 @@ import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; class RenameViewPopover extends StatefulWidget { const RenameViewPopover({ super.key, - required this.viewId, + required this.view, required this.name, required this.popoverController, required this.emoji, @@ -21,7 +22,7 @@ class RenameViewPopover extends StatefulWidget { this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); - final String viewId; + final ViewPB view; final String name; final PopoverController popoverController; final EmojiIconData emoji; @@ -64,7 +65,7 @@ class _RenameViewPopoverState extends State { direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 18), onSubmitted: _updateViewIcon, - documentId: widget.viewId, + documentId: widget.view.id, tabs: widget.tabs, ), ), @@ -88,7 +89,7 @@ class _RenameViewPopoverState extends State { Future _updateViewName(String name) async { if (name.isNotEmpty && name != widget.name) { await ViewBackendService.updateView( - viewId: widget.viewId, + viewId: widget.view.id, name: _controller.text, ); widget.popoverController.close(); @@ -100,7 +101,7 @@ class _RenameViewPopoverState extends State { PopoverController? _, ) async { await ViewBackendService.updateViewIcon( - viewId: widget.viewId, + view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart index 673f08c668..5cb834cbf3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; class ToggleStyle { const ToggleStyle({ @@ -38,6 +37,7 @@ class Toggle extends StatelessWidget { this.thumbColor, this.activeBackgroundColor, this.inactiveBackgroundColor, + this.duration = const Duration(milliseconds: 150), this.padding = const EdgeInsets.all(8.0), }); @@ -48,6 +48,7 @@ class Toggle extends StatelessWidget { final Color? activeBackgroundColor; final Color? inactiveBackgroundColor; final EdgeInsets padding; + final Duration duration; @override Widget build(BuildContext context) { @@ -70,7 +71,7 @@ class Toggle extends StatelessWidget { ), ), AnimatedPositioned( - duration: const Duration(milliseconds: 150), + duration: duration, top: (style.height - style.thumbRadius) / 2, left: value ? style.width - style.thumbRadius - 1 : 1, child: Container( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart index adb3b1e454..347d95d01d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -90,15 +90,14 @@ class UserAvatar extends StatelessWidget { : null, ), child: ClipRRect( - borderRadius: Corners.s5Border, - child: CircleAvatar( - backgroundColor: Colors.transparent, - child: Image.network( - iconUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - _buildEmptyAvatar(context), - ), + borderRadius: BorderRadius.circular(size / 2), + child: Image.network( + iconUrl, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildEmptyAvatar(context), ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart index 05657307f9..3be0973123 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -5,10 +5,12 @@ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart'; import 'package:appflowy/workspace/application/view_title/view_title_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -29,8 +31,14 @@ class ViewTitleBar extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ViewTitleBarBloc(view: view), + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => ViewTitleBarBloc(view: view)), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: view) + ..add(const ViewLockStatusEvent.initial()), + ), + ], child: BlocBuilder( builder: (context, state) { final ancestors = state.ancestors; @@ -42,11 +50,14 @@ class ViewTitleBar extends StatelessWidget { child: SizedBox( height: 24, child: Row( - children: _buildViewTitles( - context, - ancestors, - state.isDeleted, - ), + children: [ + ..._buildViewTitles( + context, + ancestors, + state.isDeleted, + ), + _buildLockPageStatus(context), + ], ), ), ); @@ -55,6 +66,29 @@ class ViewTitleBar extends StatelessWidget { ); } + Widget _buildLockPageStatus(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + } + }, + builder: (context, state) { + if (state.isLocked) { + return LockedPageStatus(); + } else if (!state.isLocked && state.lockCounter > 0) { + return ReLockedPageStatus(); + } + return const SizedBox.shrink(); + }, + ); + } + List _buildViewTitles( BuildContext context, List views, @@ -98,7 +132,7 @@ class ViewTitleBar extends StatelessWidget { message: view.name, child: ViewTitle( view: view, - behavior: i == views.length - 1 + behavior: i == views.length - 1 && !view.isLocked ? ViewTitleBehavior.editable // only the last one is editable : ViewTitleBehavior.uneditable, // others are not editable onUpdated: () { @@ -280,7 +314,7 @@ class _ViewTitleState extends State { // icon + textfield _resetTextEditingController(state); return RenameViewPopover( - viewId: widget.view.id, + view: widget.view, name: widget.view.name, popoverController: popoverController, icon: widget.view.defaultIcon(), @@ -350,3 +384,92 @@ class _ViewTitleState extends State { ); } } + +class LockedPageStatus extends StatelessWidget { + const LockedPageStatus({super.key}); + + @override + Widget build(BuildContext context) { + final color = const Color(0xFFD95A0B); + return FlowyTooltip( + message: LocaleKeys.lockPage_lockTooltip.tr(), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: color), + borderRadius: BorderRadius.circular(6), + ), + color: context.lockedPageButtonBackground, + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 4.0, + ), + iconPadding: 4.0, + text: FlowyText.regular( + LocaleKeys.lockPage_lockPage.tr(), + color: color, + fontSize: 12.0, + ), + hoverColor: color.withValues(alpha: 0.1), + leftIcon: FlowySvg( + FlowySvgs.lock_page_fill_s, + blendMode: null, + ), + onTap: () => context.read().add( + const ViewLockStatusEvent.unlock(), + ), + ), + ), + ); + } +} + +class ReLockedPageStatus extends StatelessWidget { + const ReLockedPageStatus({super.key}); + + @override + Widget build(BuildContext context) { + final iconColor = const Color(0xFF8F959E); + return DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: iconColor), + borderRadius: BorderRadius.circular(6), + ), + color: context.lockedPageButtonBackground, + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 4.0, + ), + iconPadding: 4.0, + text: FlowyText.regular( + LocaleKeys.lockPage_reLockPage.tr(), + fontSize: 12.0, + ), + leftIcon: FlowySvg( + FlowySvgs.unlock_page_s, + color: iconColor, + blendMode: null, + ), + onTap: () => context.read().add( + const ViewLockStatusEvent.lock(), + ), + ), + ); + } +} + +extension on BuildContext { + Color get lockedPageButtonBackground { + if (Theme.of(this).brightness == Brightness.light) { + return Colors.white.withValues(alpha: 0.75); + } + return Color(0xB21B1A22); + } +} diff --git a/frontend/appflowy_flutter/linux/packaging/assets/logo.png b/frontend/appflowy_flutter/linux/packaging/assets/logo.png new file mode 100644 index 0000000000..f34332a395 Binary files /dev/null and b/frontend/appflowy_flutter/linux/packaging/assets/logo.png differ diff --git a/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml b/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml new file mode 100644 index 0000000000..801a5dbc02 --- /dev/null +++ b/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml @@ -0,0 +1,36 @@ +display_name: AppFlowy +package_name: appflowy + +maintainer: + name: AppFlowy + email: support@appflowy.io + +keywords: + - AppFlowy + - Office + - Document + - Database + - Note + - Kanban + - Note + +installed_size: 100000 +icon: linux/packaging/assets/logo.png + +generic_name: AppFlowy + +categories: + - Office + - Productivity + +startup_notify: true +essential: false + +section: x11 +priority: optional + +supportedMimeType: x-scheme-handler/appflowy-flutter + +dependencies: + - libnotify-bin + - libkeybinder-3.0-0 diff --git a/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml b/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml new file mode 100644 index 0000000000..3fcdea03bc --- /dev/null +++ b/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml @@ -0,0 +1,33 @@ +display_name: AppFlowy +icon: linux/packaging/assets/logo.png +group: Applications/Office +vendor: AppFlowy +packager: AppFlowy +packagerEmail: support@appflowy.io +license: APGL-3.0 +url: https://github.com/AppFlowy-IO/appflowy + +build_arch: x86_64 + +keywords: + - AppFlowy + - Office + - Document + - Database + - Note + - Kanban + - Note + +generic_name: AppFlowy + +categories: + - Office + - Productivity + +startup_notify: true + +supportedMimeType: x-scheme-handler/appflowy-flutter + +requires: + - libnotify + - keybinder diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 347343dad8..b4a1a3d20d 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -3,6 +3,9 @@ PODS: - FlutterMacOS - appflowy_backend (0.0.1): - FlutterMacOS + - auto_updater_macos (0.0.1): + - FlutterMacOS + - Sparkle - bitsdojo_window_macos (0.0.1): - FlutterMacOS - connectivity_plus (0.0.1): @@ -17,7 +20,7 @@ PODS: - flowy_infra_ui (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - HotKey (0.2.0) + - HotKey (0.2.1) - hotkey_manager (0.0.1): - FlutterMacOS - HotKey @@ -30,8 +33,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - ReachabilitySwift (5.2.3) - - screen_retriever (0.0.1): + - ReachabilitySwift (5.2.4) + - screen_retriever_macos (0.0.1): - FlutterMacOS - Sentry/HybridSDK (8.35.1) - sentry_flutter (8.8.0): @@ -43,19 +46,24 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - Sparkle (2.6.4) + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - super_native_extensions (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - 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`) + - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/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`) @@ -68,13 +76,14 @@ DEPENDENCIES: - 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`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/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`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/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`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) SPEC REPOS: @@ -82,12 +91,15 @@ SPEC REPOS: - HotKey - ReachabilitySwift - Sentry + - Sparkle EXTERNAL SOURCES: app_links: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos appflowy_backend: :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + auto_updater_macos: + :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos bitsdojo_window_macos: :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos connectivity_plus: @@ -112,26 +124,29 @@ EXTERNAL SOURCES: :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 + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/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 + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/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 + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 @@ -139,21 +154,23 @@ SPEC CHECKSUMS: file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - HotKey: e96d8a2ddbf4591131e2bb3f54e69554d90cdca6 + HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 - screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 share_plus: 1fa619de8392a4398bfaf176d441853922614e89 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 diff --git a/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift index cad0330b85..c7872aaec9 100644 --- a/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift +++ b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false @@ -16,4 +16,8 @@ class AppDelegate: FlutterAppDelegate { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig index da469610eb..d2b3d7e9b3 100644 --- a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = AppFlowy PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 AppFlowy.IO. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 AppFlowy.IO. All rights reserved. diff --git a/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements index 71949adefe..4c829c7ab0 100644 --- a/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements +++ b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements @@ -1,20 +1,20 @@ - - com.apple.security.app-sandbox - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.temporary-exception.files.absolute-path.read-write - - / - - - + + com.apple.security.app-sandbox + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.temporary-exception.files.absolute-path.read-write + + / + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Runner/Info.plist b/frontend/appflowy_flutter/macos/Runner/Info.plist index cd07887134..cb3d1127a0 100644 --- a/frontend/appflowy_flutter/macos/Runner/Info.plist +++ b/frontend/appflowy_flutter/macos/Runner/Info.plist @@ -1,57 +1,61 @@ - - LSApplicationCategoryType - public.app-category.productivity - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - fr - it - zh - - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleURLTypes - - - CFBundleURLName - - CFBundleURLSchemes - - appflowy-flutter - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSAppTransportSecurity - NSAllowsArbitraryLoads - + LSApplicationCategoryType + public.app-category.productivity + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + fr + it + zh + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleURLTypes + + + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + SUPublicEDKey + Bs++IOmOwYmNTjMMC2jMqLNldP+mndDp/LwujCg2/kw= + SUAllowsAutomaticUpdates + - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/macos/Runner/Release.entitlements index 71949adefe..4c829c7ab0 100644 --- a/frontend/appflowy_flutter/macos/Runner/Release.entitlements +++ b/frontend/appflowy_flutter/macos/Runner/Release.entitlements @@ -1,20 +1,20 @@ - - com.apple.security.app-sandbox - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.temporary-exception.files.absolute-path.read-write - - / - - - + + com.apple.security.app-sandbox + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.temporary-exception.files.absolute-path.read-write + + / + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json new file mode 100644 index 0000000000..87f89b3bbc --- /dev/null +++ b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json @@ -0,0 +1 @@ +{"appPreferencesBuildSettings":{},"buildConfigurations":[{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","CLANG_ANALYZER_NONNULL":"YES","CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION":"YES_AGGRESSIVE","CLANG_CXX_LANGUAGE_STANDARD":"gnu++14","CLANG_CXX_LIBRARY":"libc++","CLANG_ENABLE_MODULES":"YES","CLANG_ENABLE_OBJC_ARC":"YES","CLANG_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"YES","CLANG_WARN_CONSTANT_CONVERSION":"YES","CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS":"YES","CLANG_WARN_DIRECT_OBJC_ISA_USAGE":"YES_ERROR","CLANG_WARN_DOCUMENTATION_COMMENTS":"YES","CLANG_WARN_EMPTY_BODY":"YES","CLANG_WARN_ENUM_CONVERSION":"YES","CLANG_WARN_INFINITE_RECURSION":"YES","CLANG_WARN_INT_CONVERSION":"YES","CLANG_WARN_NON_LITERAL_NULL_CONVERSION":"YES","CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf","ENABLE_STRICT_OBJC_MSGSEND":"YES","ENABLE_TESTABILITY":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_DYNAMIC_NO_PIC":"NO","GCC_NO_COMMON_BLOCKS":"YES","GCC_OPTIMIZATION_LEVEL":"0","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_DEBUG=1 DEBUG=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"INCLUDE_SOURCE","MTL_FAST_MATH":"YES","ONLY_ACTIVE_ARCH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_ACTIVE_COMPILATION_CONDITIONS":"DEBUG","SWIFT_OPTIMIZATION_LEVEL":"-Onone","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98814b7e2c3bac55ee99d78eaa8d1ec61e","name":"Debug"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","CLANG_ANALYZER_NONNULL":"YES","CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION":"YES_AGGRESSIVE","CLANG_CXX_LANGUAGE_STANDARD":"gnu++14","CLANG_CXX_LIBRARY":"libc++","CLANG_ENABLE_MODULES":"YES","CLANG_ENABLE_OBJC_ARC":"YES","CLANG_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"YES","CLANG_WARN_CONSTANT_CONVERSION":"YES","CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS":"YES","CLANG_WARN_DIRECT_OBJC_ISA_USAGE":"YES_ERROR","CLANG_WARN_DOCUMENTATION_COMMENTS":"YES","CLANG_WARN_EMPTY_BODY":"YES","CLANG_WARN_ENUM_CONVERSION":"YES","CLANG_WARN_INFINITE_RECURSION":"YES","CLANG_WARN_INT_CONVERSION":"YES","CLANG_WARN_NON_LITERAL_NULL_CONVERSION":"YES","CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf-with-dsym","ENABLE_NS_ASSERTIONS":"NO","ENABLE_STRICT_OBJC_MSGSEND":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_NO_COMMON_BLOCKS":"YES","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_PROFILE=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98c22f26ca3341c3062f2313dc737070d4","name":"Profile"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","CLANG_ANALYZER_NONNULL":"YES","CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION":"YES_AGGRESSIVE","CLANG_CXX_LANGUAGE_STANDARD":"gnu++14","CLANG_CXX_LIBRARY":"libc++","CLANG_ENABLE_MODULES":"YES","CLANG_ENABLE_OBJC_ARC":"YES","CLANG_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"YES","CLANG_WARN_CONSTANT_CONVERSION":"YES","CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS":"YES","CLANG_WARN_DIRECT_OBJC_ISA_USAGE":"YES_ERROR","CLANG_WARN_DOCUMENTATION_COMMENTS":"YES","CLANG_WARN_EMPTY_BODY":"YES","CLANG_WARN_ENUM_CONVERSION":"YES","CLANG_WARN_INFINITE_RECURSION":"YES","CLANG_WARN_INT_CONVERSION":"YES","CLANG_WARN_NON_LITERAL_NULL_CONVERSION":"YES","CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf-with-dsym","ENABLE_NS_ASSERTIONS":"NO","ENABLE_STRICT_OBJC_MSGSEND":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_NO_COMMON_BLOCKS":"YES","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_RELEASE=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e9828903703a9fe9e3707306e58aab67b51","name":"Release"}],"classPrefix":"","defaultConfigurationName":"Release","developmentRegion":"en","groupTree":{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d0b25d39b515a574839e998df229c3cb","path":"../Podfile","sourceTree":"SOURCE_ROOT","type":"file"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f986fc29daf4cb723e5ecd0e77c9cc3a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983499ae993d0615bc4a25c6d23d299cd2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878bdb70529628b051bfb14170b6ce281","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/SwiftAppLinksPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98803a16064d6b26357b9fa2d9c81eb2c0","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e982bcf9ac9f04ccd93893e9ae54d152c11","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980af336cd97bd48a5f76247c45af3ca5a","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4c688735e4b7c4c9af25f0254256c5a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980f47fa79356e5b78e85637df4eab1e8f","name":"app_links","path":"app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852951db13a823ccb98a790f496fab4e3","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988786e23fb077ded3affc0f7a37fc275c","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9874ef26f64fe74d7c02d1a94bcde1184c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989187ad07f57154eca7d638acb8377adb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc7eee9f7cdf2e623ebc316ac5388a1e","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c3c6bda68e418bfa0a7d818fbfb0037","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a15f02f386a528bc8eb605ae37642455","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbbda7998cd576c276e2258fb3bad5a5","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b881f7fdcc02480bc57ddb51bd8d98f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aa491e8e0f7d7e2c84ecbe06c2f4df77","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ae796e67846c10ae19147ab24013260d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ee7362a7cb62b913a6f351fa670031fb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c70d2ff23f2582de73eade3b63ca6732","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cfdf5bb4bb8df40c98fd16d64963a3be","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9827cc495d5b0d43a98c370d69de3ff8ae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/app_links.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98b1016bc412809827cc7f8ffd7be68d60","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988f23ea9e443884f6d6a834c279bb42c4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d5bbbb2531f246d5e384ddd39c9ef94","path":"app_links.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985bbf5bd8a81b79ac8b94165aebec6742","path":"app_links-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a0510b51afa43c66fac3cfa1b303b58d","path":"app_links-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988921fa59229e94f886b598374e0a0a6c","path":"app_links-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988a8ca854307b43eb2b99c9daa9a1cc96","path":"app_links-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a31ee17a0bf46ce08a50fbab9d7e0d7","path":"app_links.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981b9d938e8177ba8be7381344ebbf3f72","path":"app_links.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98faf0d598eef28587701cbceba0a82824","path":"ResourceBundle-app_links_ios_privacy-app_links-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9834af3cd6a85915e92c7086a5cef194bd","name":"Support Files","path":"../../../../Pods/Target Support Files/app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985fe1a1ae80c7c29a049e0b0f5a968eb6","name":"app_links","path":"../.symlinks/plugins/app_links/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9b1d2b694569060c7a89ce432ae59aa","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d2eb541b9cb3760f4d8a0de1aceac0cd","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ff9d3e6fbf7f3ebdf3b2d98a4d52480e","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98082f3bd8728bb955d3b231cc205df22a","path":"../../../../../../packages/appflowy_backend/ios/Classes/binding.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981cf16cdf61f891cb9befc3392b13ff5f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ba42de9b682ddbb7d9ecb3a91add63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ce8a30ee373383f69298444d24489306","name":"appflowy_backend","path":"appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c86a47e2309e0b005ed79419dd093f0d","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503452eddbee5272ed788d0cc749960e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98055cce552f828e105a78ac03f0bdd805","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca2c764817f57c4676d84d72558b3899","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f5501b2ed32cd958d3563e60f001d703","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9c0603d95a0886bda3e008ea3e3501a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877a3c2776a0dd48b2c60d087afb651a0","name":"..","path":"../../../../../packages/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"archive.ar","guid":"bfdfe7dc352907fc980b868725387e9836c8d2e47d81f8a6899c11848d60e876","path":"../../../../../packages/appflowy_backend/ios/libdart_ffi.a","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5592f7a99092768eeefc4cfc096cae8","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98c2aceac4d9f7a88a7f2aef9ddb559c68","path":"../../../../../packages/appflowy_backend/ios/appflowy_backend.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985e17269d373b81baa61bd43ba6a542e5","path":"../../../../../packages/appflowy_backend/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9850866a0df8df8e8e5c4a2fae74fa1e1f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e87c56b9467409c44163e34bc882ac5d","path":"appflowy_backend.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e158e9b03f83f13bbdf8668ff066134b","path":"appflowy_backend-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98eaf420deb60cd2d5a76ad867f2138dae","path":"appflowy_backend-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9805ed6ca977fdc6df3bcf5eed84819eb9","path":"appflowy_backend-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983bb1fa0b0f13951c6fa653a914b3fa2c","path":"appflowy_backend-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cd74f83369370fbe7e403ef8053e619","path":"appflowy_backend.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98403ef0361dbe02708d9acfefd0464e16","path":"appflowy_backend.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a1e14bed5ed1f1fb81e768bd14f444fe","name":"Support Files","path":"../../../../Pods/Target Support Files/appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835d1d3e8ff3e04e32db8f29bce41a67d","name":"appflowy_backend","path":"../.symlinks/plugins/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98624a11fdf6f56961a285723dfd21440e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985824c2dcc3b51cb92cca4c0ee6b76711","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980ce7873e1e41d09d3e0e844e23814078","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98880b04837ff2e7c369e7e0eb127f9146","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/PathMonitorConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c4b5d0d184e7bdaae9f3f567209b6a8","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ReachabilityConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982d898374f337072929ea1137cd9b0531","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/SwiftConnectivityPlusPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a0e254b7ac4017377847f08d93859c98","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98403bc21ffba3f9ff7918b7c9d3ed9571","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f8845f64df2ea83ab8319d64b1bbadde","name":"connectivity_plus","path":"connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eb115a9dec07b1f45a9c5d5afae3331","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811c71b5edc42403c378790dde71cd61e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ba6e533cf0750afe861cf587836d63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98337f70e0b3205d77252416fe9d53ade5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3ba4a1d262c8736c1a3dd2bbee95feb","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98935b95f888c988c238c3ba19998f568c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815214da764f00e2aace6e3402b97ff27","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4748e70a6f1ff450d8ad89293faf1ad","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddf25614c5494e3e2e964d3362a7e13","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98652b1ddb86551418439feb3a3cde66c8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413da6f3e8f068df77eefa2354cc1260","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b18d54474e5f2acd3f7e93b85008ecaa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ff9bc5085e73af685f00aee655d65cbd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec4569066db3377ae3957a0893989f6a","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981911c6f4ab7da1f59fb48cce8d267a76","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/connectivity_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e982050d0252ea66aa742807a457832653a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986076caee38a9da764b3dfa1e4e1a5d41","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e986c1079ccb9ad2a0dd836bbde26dfebf0","path":"connectivity_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6ddb885fad9a4542a6329a470fa2fe4","path":"connectivity_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e592f2c2e44ef3deac3cd02784cc784d","path":"connectivity_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d207fb51488c7ea7ac1275e911a94150","path":"connectivity_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898468587a6cab520cdcb8933d0b368ca","path":"connectivity_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9818c5888400978b2fa22d952059454061","path":"connectivity_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d82fe1b7b4db7bc85ab18441f1e2c0ce","path":"connectivity_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984b570fd876e00e3c1622ee7a13633dbe","name":"Support Files","path":"../../../../Pods/Target Support Files/connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a5f07478f75bc3b752964b6b533c114d","name":"connectivity_plus","path":"../.symlinks/plugins/connectivity_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba19832f3a1fec7b3b56e3071bf4c9f7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98589f97f59a6ebf70206fa946d33c6a98","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9830307b7eb9685258ae248d413d28a51f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1c115a5b698e82de643427263904f6e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b72e875a2313b2fd273ee6413267e0b7","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a016968d1537de5fee216f39b9b13a4b","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984163ac08562702d9111e13bc58eadee3","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863be3299f9535e1f37edeca566c56956","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec71f83543e78b02bb8878f2bdaa1770","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b57caee95bb0defd7fbea09e4630e95","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866f39d80aa059eb566807578dd56d3e0","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5774272732a1c387c19dcb20953a259","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9884b715eaf409b020791c09bd1a45ac40","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989064cf80f6cbf753a3f2f76769676869","name":"..","path":"..","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98b80404716ec61d84484617356e734544","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9853d1bd10368491358c120edf0879a1c1","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd41f321f927f1501da3ce4c3e1705d3","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c8a696a7575bdfd695b50552bda53e63","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986fad17d5ebb6a1b1e363579811099dac","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896b7c1d7f56d6b335d559dde146c1187","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7caa4a8157f551fd5157e38236212ff","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb0be9618fdb3c31fc76e6c391bb5a54","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d79e90f30e8071ea0755a235e7bbe47a","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f7e502581e9e563f0e716961fe919778","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ceb019bbffa37850d4328a3b5e80e961","name":"dev","path":"../dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d1e6ecf8a8f5e7866d4a3bf6a028da56","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803607fd62a02c7946a1d3477b61e1422","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfdc50cf761e86a346d06e7bbf2d3a1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e53458045fdd4d428512566882c92e7e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a82b79305f187eb48310a393f4bfe813","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e987f003eaf8659e7fff511891b3f2afc0d","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/device_info_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983862a4e9dba3759d8615b149cba8a0dc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982a7f5deb6a64ae5e5a8c21df8be555e2","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9800ef6f27822088506da77df111adaf81","path":"device_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3729b6804108439237d0ad0b05ddce2","path":"device_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984decbb29f8ccfc95e475df5348ee3959","path":"device_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98051abdaf3129a419e9bb3b8c7a83e64b","path":"device_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e913211adfbc9d70bfc43b4439f4332c","path":"device_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984bb4748a26339d307020f1110f05e895","path":"device_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981f4d52f92d8b0f360ee8bef71882f85a","path":"device_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f354426d7c8f682caf04a755240d2fde","path":"ResourceBundle-device_info_plus_privacy-device_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98623cfe9c4e66d55a221902e7792e5d43","name":"Support Files","path":"../../../../Pods/Target Support Files/device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ba853655f8a54c79510e0b66012fac89","name":"device_info_plus","path":"../.symlinks/plugins/device_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3b3f804d927f2bf6183213a182625c0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e16f08255892d0f684af6ee4b4380824","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f56ed42bf21b1a52ac8fcb9a82336a65","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e93662f755da0279387c09ed20fcb67","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982542f63e48192694c2301b53dd8da392","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987347d2eccffbba5b92a6737ac7c7f11f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b1a288da1e6fe981b46f8315944e21d","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04840a4cc59ee085ade0b92a852884e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed38e9ec1e8cd7f7427a8e3752fa5961","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e9830eb81718ca8a4637d18322144cb97c2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980cdda343b1e3945e28451531e9c1a605","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9845180a5dc5686acdc4e9429fa972713e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9836c9feed71b513f9919701e702f5ef43","name":"file_picker","path":"file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd1b4108172e277e33c8a87d3fcd70","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880d01b963c8ef608a0ad04583a5fc312","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9805619a0f02043e6fdb7a2be76189cfff","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849b67c3d88226e7c72c75fa15e3bbdc0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1d9d867fa533872b45a7339a773d9b3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0187227de80dc084fabcf302004870b","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9825d0e9fb8adf9bdf05bf7db5c5fc456d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9800d40be9558dad2dacf5f93047196827","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abd6b198ade79f4872ee96e030a87e36","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9862b34fd8ad582ae69aae1fce3d396520","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ffed80c94d5fea9d76f4fe35d4478984","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852117d814a38fe3eba010475bafd632a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e72e01936d9a14e0e9b869c88ad45424","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875e49cbfeae37a120af486d9eb2d93c5","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98e67d477f82d32d5c7c9d30d2e81d066f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/file_picker.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9808d1f81b0d4082e0417db2f18a2acfb6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cbd123e02025168eb151b7d71e556d22","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b0953817803e455c620122efd668eb5f","path":"file_picker.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9837a1507700518fa7f49a60aac4b7767c","path":"file_picker-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98d8e8a876a8ca0ce915464fbc27689c44","path":"file_picker-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98273092b724e2281270954671470d86c2","path":"file_picker-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98330b1e95388e708f5ad38ed7ce90e28c","path":"file_picker-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98893e38e9593d6a06c8522268bf23390a","path":"file_picker.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981a76198b195c337f9587084c94a43437","path":"file_picker.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e982406cc43be2014b06d264ea164e97c61","path":"ResourceBundle-file_picker_ios_privacy-file_picker-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a98dabe6e24ef65809527fcab892dd86","name":"Support Files","path":"../../../../Pods/Target Support Files/file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f62c63ccec2525926d94e2db6b738061","name":"file_picker","path":"../.symlinks/plugins/file_picker/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982640e5da9d4da0b1c1284d679e8bfff4","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982c61e60ddf03cc04542492a7725f11f3","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a2f3125bf6a20381c479cb67f97aa968","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e984ba1576a81594fe8f17aea9519f8b1fd","path":"../../../../../../../packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5424bf8b4f526e22486e2a27f3268a5","name":"Event","path":"Event","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d8845aeb0c1ed01cfe07a5f22cc0ec9","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803f83e7a1d900c14ec7e6905f0092826","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bf611f0f52fc18d7226f7e7f09c38245","name":"flowy_infra_ui","path":"flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fb46775118dd702b7316a553c95e90e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98616f2715911b1a74d97ddaada4344fbc","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cd1c794c9b9ad19f2e4f0ca3f026819","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b37b705e2925a02d2062aaafe5099089","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982add91273b39db2a9cf969fe86dca06a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b3dd8aad5aabbc2496c663812f03cfd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d656d9598f5d14273b8b8981109e8b9e","name":"..","path":"../../../../../packages/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e989d6db7e7b482c3241d9a1acce4813ee8","path":"../../../../../packages/flowy_infra_ui/ios/flowy_infra_ui.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e989fd87faba1a0f13d4d7494191f208760","path":"../../../../../packages/flowy_infra_ui/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98145bf30753fca245e5d38777385d9388","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9893b48c5d4d9ce2656928a0ee3ff7944f","path":"flowy_infra_ui.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985398a052a839596d179b48db347a52c5","path":"flowy_infra_ui-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98c1829b249c76d5ac78b6563b1ee7fde3","path":"flowy_infra_ui-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b5e2f476270884826aa113914031988","path":"flowy_infra_ui-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983e584fb8f2aede8db256844ee817b410","path":"flowy_infra_ui-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98892689e861aa6009551af6801e83c338","path":"flowy_infra_ui.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f6af4fb94ad21206a16cbaf1e6453010","path":"flowy_infra_ui.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982cc4e6d82b8278ce278988c26691765e","name":"Support Files","path":"../../../../Pods/Target Support Files/flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f43591bd9ec58ad67ffe4453fafc4a1a","name":"flowy_infra_ui","path":"../.symlinks/plugins/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98fb18cc16183813fcd8641d3cadfad33b","path":"Flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d37e3d81bea52e1b4801db4886715c89","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9812cdcc535f32a8a76cc3d8e883d2013f","path":"Flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98048531a74a78f7613c93ba91f15c7ef8","path":"Flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98152d9dfbdddbef1340635c08e31152c6","name":"Support Files","path":"../Pods/Target Support Files/Flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988b26d3d01bc6aaf3315f6d51c851186d","name":"Flutter","path":"../Flutter","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b785d8b1f51c643229e4fdd18985825e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9877147b007839a2216fd57593e0f3ba5f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987efa3ca5a1d26929ed3b109a26806afa","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98792f001acf47168aaffa10afd959b5c4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812c06060cb56dec48c82c3b93bd2fdf2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a85c4c34db915b09efc04ad74e71906","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98879342f1ae53be318ccc851c8fb5f741","name":"fluttertoast","path":"fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986e74552a0b437b7d398a0e712dfea078","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd6a60c3b14da76ce2370d2a8be6b094","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29b7bd0bc91b1bd5e1a2b480473e1d7","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984dd71cdc0b7c0e396503163601d414ef","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892ba1e9027f479ab7d1c9354ba3a2aee","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4cad64b7e33d505b2ecf5d631c9d3e7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987932e250d513fa720967e0dd955cb5c2","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9869aa178a8c06888c2f7f224a2918a492","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98316da1c1d27426250efb3febcdf6e95a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a6847ce3a370c3b3d860bf1b58a643e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898b42eaa404b9ed45fdaf7f308bef4aa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9d4812f1ec13b817bb1728f24b63ef3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850c5079f1f3a336ba56135604b1311cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ef2416841219f34ad24eb0617d29faa","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e982ae0f7d0422d2f75f6cdde13de9e63ab","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/fluttertoast.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983d3e3470809b19b23bd82e0a81446281","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afec4a601b8594f84cb4f864f71af4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e983d17b3300e70f7d2c18f92e0d276131b","path":"fluttertoast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f77f9386b6ff408a5830dc5ec967554","path":"fluttertoast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f000b535e938ba4dfcadc2d08b7f9dcb","path":"fluttertoast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980af04a50d29871944a910af21b08eb54","path":"fluttertoast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7bc0cf610c976df935aac7605febe19","path":"fluttertoast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984e7d38849f0179ae896bfe33295f4bcb","path":"fluttertoast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98bfeefd631cf811ef5ce08bdcc7b5e371","path":"fluttertoast.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98740356b6d05058396c4af19281498c88","path":"ResourceBundle-fluttertoast_privacy-fluttertoast-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98781d0486e386be35e6ed9b7fe0e393a9","name":"Support Files","path":"../../../../Pods/Target Support Files/fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988aa84318e78c044eca9d3adcb2083544","name":"fluttertoast","path":"../.symlinks/plugins/fluttertoast/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98d61d652dffc4f8948dc5e51433b55422","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984a37fbaf4a9886ee0471de51a32ce11c","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98593629a4be85977669ea4c059a1d10a3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1b4186d564806fb6d451c19a178c28c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5d0ee78f468d5980cfa1ea4ad6dc829","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985041391a222e0b4847d8b2a02427be61","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875530726d790ea59a3b2315529b92cc3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ea0a989d0c2a93f9b189cf1469cea87","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98391dbed2b1e45fe5718c27685e1d7c67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986721fa987b7c82532939c240860d8345","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb63c7bf2a24aed477a61c0970de0960","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2bbb2e48ebc4b126f757c2703266204","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817ce3811d81e530356a343c80efe8ad4","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849f3c66c3da79f5b15d867daabb3187b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fa3916bdb222eafea4d7820d6eb9e8","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd7d949543e498a3b27db6a1893718cd","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7820fbe6a5f106704f492a88059c536","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerImageUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982369589aa7940b6e167faf588a3802a0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerMetaDataUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983f29585ff52e44e910a275777a9f20c5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPhotoAssetUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9859a809cba5c8a38df54a2381d8b2365c","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98de28739697251ea419e62df80ffef732","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTPHPickerSaveImageToPathOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98919c321c361ef3f5f9d9f7385fcfcbc8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/messages.g.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9850445506038bd7f616867a4a123a3ac1","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9883e47689f14ae5fdf581d0fab5f920e0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerImageUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9831e620cfa600d0005b2000f1d54a69f4","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerMetaDataUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f45fcb71f8e934254d2b14ac634b539","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPhotoAssetUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fe75cfd0b755ea439e9f1406676fbfcb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d0c46c12ac4b3b637f4201b418d7f26f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9808b4daa14bfdb0a669f7e77d74ae3f17","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTPHPickerSaveImageToPathOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d17117714f9b0c4ea80392bb9bccf16","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/messages.g.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982e942c8a9d3701363338a82b251071ac","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98502dd1df3bee9584d607bb1b1a902b26","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982056603c1983c7c0b15b4f9d4e839e1e","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892285f318b670b27f7fbf2287b291843","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a27670c0d2f4e5b9f45ade587b13d2c","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987b8f4a03e01f25f540c609ad50dc4076","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d97a722bba91079df06c440102468bba","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98af8e2dbb1e8185946e7becdd47b97f9e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ae881df58521cd150fc286fffe35603","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803a91fc4ede7e5b0c2afbd5fab18fee9","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981a39b88b7383525d9d473bd93d1e2f0b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811f7fa0b42b0c8fb654345ec4ebb8e66","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98623219eb0a2c578df94d5883bc419325","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981125f09590385ffd9ea5a9fe5aa60ba5","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d31ae9e961eabf22319ec6a1fca4f113","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817eb7c23f31f300a1c251c4305fda8c1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec140a00b7550fc8d6fbc0aaceaa7a89","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bfbf2e646d2c92073ff2e83bebd9af2d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f2692e38d39bd674e71f6bb22b891e4a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9829706d777976177d0bf097f898b5b58f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a01f741d7f1ce665f3e325d6c6a57f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646c57f09cb01b0c762a0d93ca3f7b78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98221a992b68c7cffb1a8eb5cbfbb297e9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9853e18fbd58f9c37e8a3e2681a7b69feb","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios.podspec","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9804dde312afcdb12eedb33fc8bd45d59c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988990923df59cdef8e5b1e19216f2a97a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98aa0c16b2297382848b598f9592b0c3f0","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a37a01b5033e0b3fe7a1dbb5d490ef35","path":"image_picker_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd29ed6fd89520fddcfbd7124ff888d8","path":"image_picker_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983d86dee1d916fb29b63d5b1bfcc7b5f9","path":"image_picker_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803f06581ec423d44280cf7868eea5e93","path":"image_picker_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9862b8fc1ccbc32fefecbae61cf5888907","path":"image_picker_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985530ede90a4a8c2c7507499317499697","path":"image_picker_ios.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bd2861c7f94ca0a221c93e59dabe2757","path":"ResourceBundle-image_picker_ios_privacy-image_picker_ios-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9859b1bb772d76fea02e84d0016d099c3a","name":"Support Files","path":"../../../../Pods/Target Support Files/image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830cadcc7a1263e98e67a0cf722e5d0ab","name":"image_picker_ios","path":"../.symlinks/plugins/image_picker_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987800ae9687eb588b086e319ea6177659","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/FLTIntegrationTestRunner.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98edd6a3512846802c34d22c5afb07300b","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestIosTest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980fa8dc3bbeb73b3d252e726a70557124","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980db9a591d712346350116e799f0e85b9","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/FLTIntegrationTestRunner.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984a0d38d682beee02329dbf3bf514cce1","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestIosTest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae12dfa62d0f539c9a3638bb6061375d","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9823e5a3226da96ed3914b1c8fcf559df9","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bde8b1c5794795e3b29ba817afae8d21","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7f37a3e9ff7a757003a42461c25c365","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ede6ae88c67694e327de9c425a7f6132","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987f156cd8efcf8b450a0acccba337affe","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989dcf9316567af671febf7c01cbe9f506","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cf80e4fed23235d2406a799ca33eeca","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847a104d2bb397b64bd133d45453210d2","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989392c96daffc1a0e321f346049c20e7e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980176812f8f0fbbedf15c13f3c2ee1f26","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985aa0f0e528439e82d21295d31624797f","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db8efb64140c28021c5072d8734e4699","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba3dab5252afe70f25f2838a01bdd4b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b67e71ad7e85fc1ac5853ac3428427de","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f23f974cdcdf9412df80483d5680940","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873283974ea9ea8d983cfb681aeeee604","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db229454a7e5041b4830de422e60e499","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a2ced9ad917530dc3e50756255a1e21","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b9379d3571941dbae7d47d880a7a296","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985055bbeb70e29da915730a29ed24173f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988070745f3c2f414eef05f58b72da6f5c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863c8c730e09674801ad705a3ac5f0bb5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989899d04f24c9ff95f1314d4fabeea094","name":"..","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980c15437a062ac65671b286006afd1f2d","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d94ca862be564f152946859a75277d34","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98201d61b0d848a49c65589ceb7506e08d","path":"integration_test.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af0a4e5755a22cb43543a81c374a2d51","path":"integration_test-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a8522fb10db2dfa210c208f905afd9f3","path":"integration_test-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c0b24b129604eddd217c08a7d0c062db","path":"integration_test-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9896f6fa393191b60c6487f245272c94e3","path":"integration_test-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98105bcf1dafac8dc8d85eaff566c35643","path":"integration_test.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a9f0cec960a86a1eea7dbdb4c49fcc4","path":"integration_test.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981a27bfd3655d560ebee82c3ab7c104f3","name":"Support Files","path":"../../../../Pods/Target Support Files/integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98051dc09e6cf1b3ea30ba9f39d11035d5","name":"integration_test","path":"../.symlinks/plugins/integration_test/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8ee44916529381498405522e751f8b0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c462c42948edabe677ebac748da5d96","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fc9b7f6e025c71f553fe6444717223ed","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db377227d8253ffe98324018634bc2ba","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f2a88ed98a92034ce863ada1219ab5f","name":"irondash_engine_context","path":"irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da61f0e74006e8fbd784bf4a3b970e5f","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98635b7210c5b62e20e037994cad1bf111","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c64150b3d0970cb99fece561f5a8799c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fdbea6d8cb166625df79788fcad443","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbeb45be878891b685575f6b25a8528b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847ce44e75f1621ef4e0a6a5ae85f248d","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2ae8d52765d0e5d9bea0d19cd7aebc9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c84ed3eae59cea51b25d355ea7faec4c","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ac6782e67161e5aecf52fb2c668a4c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98710388c0277b6c02dc2bfce47465f2f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5e6f44e93118427f696de099ce587e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c97afc961fd21222514666e42cbbfd8f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e9cef67c7f53259a28a5a877e4a2131","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985b651d4fcaab066c39a7ebe7cff5daa9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98b58b99914223d1b29b5e344e157a1987","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/irondash_engine_context.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9830fe1bed9eaa6250033f52ceaadf7d59","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fa867be18f4a0a3be7766d74146209f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98425d4148b72c406e4fd6672ecc362707","path":"irondash_engine_context.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d84855c0549724120cd6c3172b83cfa2","path":"irondash_engine_context-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98de048098637f376810dd3bb08c83895c","path":"irondash_engine_context-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b78febae09cf0c8f6fc7540345bee7a3","path":"irondash_engine_context-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899f4dc54067ed61c6ad87cabdfa23817","path":"irondash_engine_context-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9859e866190126bed1c816e3db8cf733a3","path":"irondash_engine_context.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9837cfa75eec5f21f87364f26642dcde07","path":"irondash_engine_context.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9832c8cd0ed43920be52e135a0de0ef859","name":"Support Files","path":"../../../../Pods/Target Support Files/irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abf194361c2fa0e4ef4dbec1a81fd4cf","name":"irondash_engine_context","path":"../.symlinks/plugins/irondash_engine_context/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987c73d9371bc96dd532ba087fb5fe818f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/Classes/KeyboardHeightPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bc635b84f7af43f9a693a5e68a4759e0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f0eadcb14d473ce9c1a7c484bf311b2e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cf80142a4ef1966c0c5c5b502225cf5a","name":"keyboard_height_plugin","path":"keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d420609dd708f812028e4f49f0e9d67","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895cace1167e8f8a1209977ad38dfccfb","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98422a86793790ea0d327a52c60169ac60","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830a1c8fc44b202d4661674a7fad9873c","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e85d16fef38a2779c13639b43f1e3726","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9842be4aab321e08d3762331c112720d13","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d8f5a70339a345d74b94ca7b959328bb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d3eca9fadfaabd2c455a69807986068","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d98f50f9676e73dee28b8bb61da9f8ff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9807172992c96c01947c7f63e7875b1880","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982737307bd1600ca8956d8f22af636541","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98854bc6907e9bd1273078ea0de43aa17e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d53a1d0636736242ed1151c333b5fc9f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c1dd450aea5e1194ba1fff003416312","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980bfe355de52abf2bb6aedb754200dccc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/keyboard_height_plugin.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985cbc6607d34630da0312e81033a0eac3","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe47c46f6c2454354c85a993a081845d","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98619ee239792a4f0a0de0a4a0b0a159ed","path":"keyboard_height_plugin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a419ee90c48f680450b1d3ead4ab82d1","path":"keyboard_height_plugin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b22dd52696b4067993e38f2551b63980","path":"keyboard_height_plugin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ac24e13278278e052df0627e6f2cfe76","path":"keyboard_height_plugin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d785c658ed39de0a4c38be45dfbc1bb","path":"keyboard_height_plugin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b6b8fa71d08d5539059bc6a5868a679d","path":"keyboard_height_plugin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985de3091a8d3281d32c2891e218876b88","path":"keyboard_height_plugin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b6f518b56ca945c4e48ea2f80f2dde0e","name":"Support Files","path":"../../../../Pods/Target Support Files/keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de11b38cb40d0a466e962277af5c13cc","name":"keyboard_height_plugin","path":"../.symlinks/plugins/keyboard_height_plugin/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa9794d4853209dd9b9dc8e446307a60","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fda7caf052d83850aa64439013c284eb","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987c5f0895df45e85ab2470a8073a4c8f0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98edc66a76af5791867252ee9a4fb21910","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895e2c6870f605df8c86b47a953028581","name":"open_filex","path":"open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980fa51cd5f979e99f137ab48d9cfe538c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c915eac28a038451586120b5e42a4dd","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9844dffd7af5aba7efc4912e98f2c99fa8","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982963e1dbef544728f2fbcde398f7ce6b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820c60640726eda7bd6b2af35bd76df61","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb93350f484f56f7d755b99459ba03a1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5b26b9b30e5f21dd0e61b63275d1293","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986605830136cc6fec7889ae13c84cbe94","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b903bd75146f71f5101abe9a847888e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835c6d6f45f08bbdd25355fae03109fc9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b22dd54825d4836f9aeb5366f4d1942","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fc1763d383faa8bc59b0d7f9aeef3f0a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bc4e9c0a74a0677415efe8c07067e305","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898e553f7a823f6dc70a807a2f2d230a6","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98ee0863552468cb4b187a007dc6d9a9b2","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9852adb17f9ae796569014283b63994f43","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/open_filex.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9881839ee4b95080a8084a269cdb03d9cf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985fad692e094e1d0680167d0e9b810fb5","path":"open_filex.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840ba9a41d5060da79af9271d16ff75b4","path":"open_filex-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98481c5fe7d76a4040e0ad917154c7e4d5","path":"open_filex-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983eb20923999fb0272a7d7981812ac419","path":"open_filex-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98045ac1ed0e248a91c7e040ef0eb573ac","path":"open_filex-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d756e4a333f24f739d0261675ee1a4ba","path":"open_filex.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989220364d0a7adb8651f9b9241f5c7291","path":"open_filex.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984048ca38c41c417f8623767606347b2e","name":"Support Files","path":"../../../../Pods/Target Support Files/open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d744b5aadcc662d9087e0ff8ecfa7db1","name":"open_filex","path":"../.symlinks/plugins/open_filex/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981d03ed1c16bd8f4f97657768978ceefc","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989c46f3ac5eacaf84d06c89a1618d018a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b8b9ba080e191e0b180722bf92e5f6d0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/include/package_info_plus/FPPPackageInfoPlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98434c664522ec8a949437da93121dc4ea","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da4508512de7ed1cf62ba23f6e0a1782","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987977607c1ffbf525bd13f89c1d4d9af6","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a38ddce212e7e062f995ed0c81943891","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98931f5d8098ad1aa7c93eec26372ba236","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98285660b922695a353a0d34c11f224681","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850cc5da0429ea6a6d2b70a2656fcab30","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddc93fc8ade4d1b45b41d4e43f4567c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98265c9ab7615abe783c697b6cd4393241","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d03678f78b9690ff2b539e1f76337f73","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ad2375b9e2190a33ef70bfb739550e5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7ffa0b49f80dc8ef09e80c7b9ea10d9","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9870b15a53dd8bece6cba98fe4fe76a795","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4af5d8efdfb0599410662c8d5fbef98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875f8bf110ce0646b5fb64007eacf9418","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c89cf11d4dd01c776076b9aba66f6de9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9856bac1d729bbf1ed2a72d1315cebd361","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9891ba0e8cfe4277c3831c309656922ca3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980531ebbd14b9b1db9cb595aee441e728","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989afbfb7501eabf645dfdd3b2d5371318","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d5f78597ba1b1ca57b94d699cac9587","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e0fd6aabdc57252bbd2b8424bc907ff7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890c7045574c1805ae1374327a927bd10","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987939da84758bc78ea493a94d1cea8578","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981e6125d7e62187e31adc58e8a2ab9ea5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a53ea27f341ed23389e7d2e47e722d47","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989676689969e25b7e2b922fdcc545264e","path":"package_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985313e6a90d7b731193c46689110da675","path":"package_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ecaad6d841c680a103b7c06250152c10","path":"package_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e968239394f15ed507902e17a5a5d3b","path":"package_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983b443c6a2f8b2d550ac2cdd9baa30e9c","path":"package_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9833b0ea6cf67df0b17b6edee85d07e536","path":"package_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98fe3643fe82f3241c56cd3e148f9bfcfb","path":"package_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9bc7cd75e23035e788a0e7c25bf5266","path":"ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98597a17b9183cd7dd4bf1dc3afc5c61ea","name":"Support Files","path":"../../../../Pods/Target Support Files/package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aacab2d91d3b0af7ec103ba0eb959971","name":"package_info_plus","path":"../.symlinks/plugins/package_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98fa855961429fcf8f989cbc521f984b6b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b4c976140e1e1950199f10d209e74f5b","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eacafab16506b203843ef40c684430b1","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98afc251afea8ca3c7c50e9df03700de10","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e09b0557ec981a89a5d3cdcc6842f0c7","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98848154feecc038860b30933f28fcd571","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4c4edafb8828d8853578114f39e78ab","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988219086c27550c56b47393a9c0e39e4c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e704029916b2a9af5f095363d987f0fe","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98357eba85aa36d189cd46406d27f5211c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987a6b9a2268f0d1806de5375f49c7ad34","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f47342e61ef54b290a2f053ba08d9dca","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0b8c28e93e04c0badf7323d031eae30","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9837afaa61f9ce1c7f4fd458c2cf5adf98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca9305ea66f0600f4817de50cde8db74","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9808e63fd1bbc402ab215a50d1d16dcd32","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989546965c4112992540058bf89b75515b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985086f0886ccfd52bf38850cbdf060b2f","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/PathProviderPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3577bef4fc38afcab726db64a001531","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b0a750596bd71fbc8cfcf1a9c72ca42","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1402de7d04e960dca8c1c9a29277190","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0db58f72b87ea1465b9bbf9fa254cd1","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d11257d51b57c6c8f8403335be4b346e","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2fe29e812c42cd3759a2d5cda315ac9","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866a118eb431e0fc65900b0d1ed264fc9","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5612f9fa3a0fea2a9cb1e98a04ed834","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ba29a7b05ab240400ab764ea3778d63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e6d60acafabcaba2c92afe3f89dccc00","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98957bcf5769cd6c1ddb7b1bacb4045c62","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857a303219d5ef02905623f5642388e38","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0517ff6d03f19c2b5d8f5ae77998f57","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c6054740313f5e60a651b62a3a9fddff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb43e5bcfafff3075b881ff2a9506820","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98952ef0fb2ed4fabd37dfc72628c7e3ba","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c173f0cea5f6ab214f5630fe12869f9b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3e481ae0756598ee53e01ba3ade5dfc","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815a65875b9784344b15a908a00046200","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9827c3714a1d7d43b116f6f2b348cb54b5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98600a8661b70b3bcc185abb7a4e9008f4","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a8b04a5824c42b6f4f609567865faee6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981bdde8f32acfbdc05bfd1e4b808852a6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d9cf578a3f3bd7e82b6d5e62241a3287","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9861842fd411d053b57aced9948ca56491","path":"path_provider_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cc5a067b653131ca99fde3ba1debf062","path":"path_provider_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989dd8857730e4c3db203f2e8d33299b7f","path":"path_provider_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ba8eca3e056e4a5b65fa675f2756611","path":"path_provider_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a687ca6ea8584ff3e8089580a0210540","path":"path_provider_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a3ec1c60f383374577efaaaba58a590","path":"path_provider_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98c312fad1a73dfd52c70c16e3d25e37e6","path":"path_provider_foundation.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9d12e6cfc445534a2c16d64aa703cc3","path":"ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3324a2357137a370c65dc36aa758feb","name":"Support Files","path":"../../../../Pods/Target Support Files/path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a2b2d2d35d31671cf8d3a967a7db258","name":"path_provider_foundation","path":"../.symlinks/plugins/path_provider_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e318c8a8d2c9dc308e66d48a374d44d9","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerEnums.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d3c436acf3f4f4e33e39114153074d49","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987887890697cf58754f65bdc3473cc551","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a74eea3ef4e2a60a3951ba0e47037853","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f80ba72d7ab8de1bbe80c04c71d79ce6","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988f16b65cffacfc53fcc954ce586fc713","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c853197e3241e98fb93603a21ed56089","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ce0ef02ade9d0a1304458c836d16e93f","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98688a017d26dc611c7c96d1741b382970","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985e535aca2400f5f683c5e24da33fa945","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9822f423a57f83424ebc6bfefdee8b3991","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98393b75ed0ceeda793874088622fd76fd","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df055a47a4e10ed4075cbf80ce6ec767","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98df5b697e834c60412ac60dfaf5b92739","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b97a3c6c28463a93a507d1b992be86ea","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981d3465f51e1a6151315cb6853ef86eb1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b85c4c9dd82c2d504573d0304be09cbe","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cc08933266f6af7683b3abe5dc3ace","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e1614978d545aec846c24669228cfa1c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988634e5ad5b489cfb8621854418e2b38e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b79f1643454a89c62ad82f48b83100d","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98874ffa55cffa9b07dd0dc545368418e8","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d0f2795f5c06163cd56c66c1973fa5e6","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820710d8f569064b9b4711ed685cbb429","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98922dbc3046dcd8b009793d4a2382c211","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b7826319f99ae1623d37b8cf2f4eba5","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805e45e592399c0af6ed4e7318ba5d2c2","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a0d58fb5a1923e677273beb359f58ab9","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1b544d0a39e91ce76da89768ba82cb7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985eb1cc6a74764d8f7285312afe97653b","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984e680efc7f3195ff924eec54756ed68e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6c7b999fdb1f6499108be37ae374b63","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f8fe14bbd3ac1b56df04f30ce3befc7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af1cc35431393d9bafe812d1459bbdf1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985c3d41a4498df42a0c6816c971e66b90","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f22ec1e8227f9be641e5842788c56cf3","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e866dd7a325c4b0ac10a86655c960f4","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988cf99d004f1e5c25193675ec100372ac","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812e94ec5545f5483160eaed2e319fd2c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d86125607541fcdd295dcf2c90de152","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bb6db6682fe6132381afffdd0b60c949","name":"strategies","path":"strategies","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983970b54b6bc58e9c785411c2b48da98a","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852af6ca8a2b5eb428a1585731297f789","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b3b31059ef8de5598d516260c77291","name":"util","path":"util","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b21a21f20e31c9dffd8a301b181e7499","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98adf4c31a2e50275af41b8a579edc68e7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e989ac6ab6d3c4d5bfd6ee635e5bfaf86a2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adbf3486d93b28d124272c05d31c535","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873343d816dbbcd1b184b6d68e52b8f8b","name":"permission_handler_apple","path":"permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29a61315e2ef4299defb08c834d8658","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9813dc0e9e99378f2e9402361650d4f3c8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e01ef4d5c3bed4f78810b1761f0bfd2b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982268db3e1a4139e97ecd2061e336e5bb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a0dfbbd565a647af8ec44544f29a21d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98656f043d890499eb834029f3c937d260","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de17afe181ca6321b521909130e6f662","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adad874bc346fed5ccaed27e453b741","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ff2305908cc93c4a0725655e88bdc78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857ae62fcfbdc453c8d905748f5c3feee","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a7884dba25b1f761bcc93d0154f12971","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828b593dc8b9a43bd8236b7d7a05a125d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9840d397c5e3d46385c36e81d47ddb4e1d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b5d7000c1c54eb9b83d1cdaabf7aa111","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98eedb8bb38abe4a753d3e5a4a50166ce0","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98bf01ad6eb9325fb5f924a7a939b9c3d5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/permission_handler_apple.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981b1b5aec77e91493469b21f5697a5678","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9895519dd776dc6b6b6a0d5a99939759a6","path":"permission_handler_apple.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3090640601ef537b4effff19f6956f9","path":"permission_handler_apple-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98693e4bbf87f0173ce83ad9dad9c4ec64","path":"permission_handler_apple-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db4011adc2801eb0447df5d29a88d434","path":"permission_handler_apple-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872ca1772cd655bc190d399786a965f06","path":"permission_handler_apple-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f0f251835fe2b85ba8e20304af40d5bf","path":"permission_handler_apple.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98498a3b2ced2fb3f55d9af8975a87d769","path":"permission_handler_apple.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986ea3ec8648859708ff7049b01e77d7e6","path":"ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e5c39acd14afb0de574df88226e3506","name":"Support Files","path":"../../../../Pods/Target Support Files/permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98373a889c847b4c599507bdad83234fe7","name":"permission_handler_apple","path":"../.symlinks/plugins/permission_handler_apple/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881b60c802c0c2009406bdc039b7f0068","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dcbc4e0c213bbd743d9703fa7e437dfc","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f7bbe1f859cede2224d42b729d4c355e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SwiftSaverGalleryPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b49ba1d17a7857dc59f1175110cc13","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98c89b00fc416882d2d0d7c9b090fa7c9c","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9820ce2023299a06e421e4a78afc6b0b82","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865ece6bf5cc7c6cce51ccbe39d149b83","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98541499aef6d248a8301980d2821058d4","name":"saver_gallery","path":"saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9822ec8e7cdcda0404b791382d92111f01","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864d2f6413ca98b754c7ee8a5171ad1fa","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98230a7213b882ddae22c49a7606136e55","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985321a2bb5473af9a5a2bc5bc4f834a04","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc31a6400e820a903a3adf3e0472b97d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b76ca3af3e88ee3e30fcf7e21bc6d0e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988541bb9a4c72550c30a45563b09a2e33","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4d469fa76bd962f8bbbec14965af39f","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ab99b3f5ad06e51078cf9e780a1d69a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98362dc58617351a4e100aac833e0a9775","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980dde8a02284f890277e1c2802444a83b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863783b60069d614fac80a368d82f00e3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d4a0878fd359b487c9cea0fa7016714","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980cda4027ee69c106bb442ab08ef4d82b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98187e4b7080377e470ceccd8012e8f22b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98f1b23c2ea102d15d2d3364c334ab1150","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/saver_gallery.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1988bfa026ce78ad365f9598fb48ccf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dce72be9e98a0e7938fea67d5dec3f0","path":"ResourceBundle-saver_gallery-saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f8441040a6b86dbd9daae56dbd15f227","path":"saver_gallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc00fdad72f5f3771416bb7da4f843ac","path":"saver_gallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b3812fe968f19a56dff3b7b21717a930","path":"saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851858f7e87dff1cd2d14bcfec05bf9e7","path":"saver_gallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdb913e186672d6dd019c490a01906ce","path":"saver_gallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986375a6a5aae83e2825b2fa84412a7b38","path":"saver_gallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cfaff9f48d79d9aac38b1a4200ab7b7","path":"saver_gallery.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9870bd2de048ce790265ba57145fada872","name":"Support Files","path":"../../../../Pods/Target Support Files/saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e9f8053722083a22097cca53b68ac915","name":"saver_gallery","path":"../.symlinks/plugins/saver_gallery/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98988b8f30525a0740a56a45f26ec9e5ee","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b3f62b7cd428533f32ccbe502d30579a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826cd8809b1744007cf2cdc49b9ee183e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9814197e8a8c3142283755e74bb1e1a542","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPluginApple.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed1bfbd13db96f1a5a9a509f62b23475","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988a2e2c7191f3342f69288dc5b6f24eac","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b57f5aa6fe19896cfd444c1d7e3374b","name":"sentry_flutter","path":"sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e95123df781f7246754488867eecad12","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c8dbba9160b89547d42fa282bc4d833","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98408185297f1419b37849ee4ef61dd14a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db140395c21d264a6950ef09ad273630","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a317c3cc4f246a4d86dee21027be9f4b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c4e624eda92287d712d01e09bae857c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988c938458c24c5a8a3de86b13a240257d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9816b3e0cb5684faafbde3cd04104a8bf1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a1a4fb826ee2604d98a1124cda9655e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984814fee408387d1cb1ac9abf04007801","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646b3421a4027d57df2335874d741e7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d7d7eb47d8b13b56642d25993bad53b7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834206fc222aad06497489cefdfcadd01","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98472caaca9c22e04c9586a3fde6ae90f3","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988942381687e6a83dfb7f7b51b7114eae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d7a35162a09b1bafe73fd4acf4dcd74e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/sentry_flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c631af1f252c1a6185a574de8e401916","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b2b1e766516fc1973912c9ed7d38fba5","path":"sentry_flutter.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20710f0afc9a65fd11ac053ab5224c4","path":"sentry_flutter-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb88983eae4d4fb5041df897b5eb48b7","path":"sentry_flutter-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98140ec5a195fd3cd2e6672a247b3c50dc","path":"sentry_flutter-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980060be8e96d53611f0f35fc6b03e1144","path":"sentry_flutter-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98898ca6b789d6ebda968b7bf2c32c2927","path":"sentry_flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98eb2509e05e4701c0e7caf39cb9f1a7ad","path":"sentry_flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f6c519cd219bbe493b512e88f6691f63","name":"Support Files","path":"../../../../Pods/Target Support Files/sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890b2588f7d4f7005fbfcb636532e8266","name":"sentry_flutter","path":"../.symlinks/plugins/sentry_flutter/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd63b0ee8c393f403cd165102963d89a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988387a65c6b9b6cb60327ffbe6c59158a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb436b045699de5c33251ecb6224be07","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/include/share_plus/FPPSharePlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988aa5f3177cec22ad24112deb2bd5df88","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b5ccde2486e01b9f408c52039e15189","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb60a3fc698011e0520a294091444220","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c0adb9f898335bb561ebd6a92c8b5764","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987c61ae0ab682495e830ebfec4bb2d992","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fec752fcf63dc774ef6492dfb4088824","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98061408887ffc6320474b1b3b9e56e869","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ca0473827b2355cd2af25d5d7d10627","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fc5d20b622f4b201b585186291a8f67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1ceacb5959c41905f6d4367a79a62d4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d04b2310b73a2f1377c4bab4917fbb0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9858dedb5230d6424239e49823b8eaf146","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a939846af6f55181341850124da10438","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987236a4c37717175b816032b6e16c35db","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eefdcc530f2e92f8f7f62696466d831b","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e999dc81b190f4772b868a75138aa75e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a1108d6d840a33f47b9c0fc3af3275d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988dae09aab63bdbe088b9c18b957886b4","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cdcb3d9a575e64374dc1a1db82409a54","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988352975c34e4d7ee8941ab48a3068611","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864da257bade8dccae972a95edd8d4097","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981dc40745a0624f3b853ce39f5bd094cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98253b8bf66bd8ccf4c977852ef0a7714b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981e6f67f07605eeaa639fc9e31c57db8a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98851d3db594f8658c9786f56478fbb6a5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98e42d81eea266b46708846d237e6b9514","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986288ba151af560df873b930937f73b07","path":"ResourceBundle-share_plus_privacy-share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f12cd395a2605f620e5facab18ef7617","path":"share_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982db5430b3897df3342086c41d0ab0cc2","path":"share_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98875a85a28fcc15b9fc0d81e86a96d256","path":"share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e15cc77379f3a5d2d644bb59a33c227a","path":"share_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f3d67ef42ee4a797c3b450c5f684db3a","path":"share_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9874f3142bf7e3eb1d60d86ef7b8277153","path":"share_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986e07cf0736987a294d8f475ef4545d77","path":"share_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a569546e20b60ea744093ab0ba2e1905","name":"Support Files","path":"../../../../Pods/Target Support Files/share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988982bbfb1ebde9b684d127cb40ed59ae","name":"share_plus","path":"../.symlinks/plugins/share_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98bbefd940fe64ae1bdba547246e327226","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980424689f89b333dcef6b831656c09796","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d76e5d546741e7c1bff558bc1604fa29","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833fd9e8a667e58c3eab9ad18ac331952","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a2f6b7afaf2afa6bd09f4e0f94068d4d","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb40156ec424853fa943291f5b303d0f","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1e3c36a262a1ed5ad3b185a0227eb67","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9831ab42991c30549603d3d8ca536224cf","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98508ae9e3aa5011f9d4942b2f89441d82","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f334cf968485fb19584ceb692110236","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98138f3662de1a399453cb9534c664235e","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826b71c8c69e21da06f07c82c6cf362d3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828e3c8782ecd686251f0f260cb28a2d7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806bae6027bc7a368204df9fc3614abdb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd31bd7e3de1ebf8947d9a8ac4c2ca","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d049289db0016cf5f53369d79d91af15","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c588eadd95143f0918dce747cd1a1e44","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985fee1a88b88d89bbd0b0617cbca27ded","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/SharedPreferencesPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98939dcc4abff9273e20bbabe5b4fd6244","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98642d561628a9b022c32e461ac6978d8d","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c2f343ab24d82d39b3b8df75288becb2","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e8c1d85da5ff6697e97a0b62ee47e2a","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984be81f35c0b61917ec47fbad62d32af3","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803adc645aa9991244006b1d25477fdab","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830ff183ca4264558be2d7888f36dfe2d","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b46089f714cf1702ed485a5fc0b2746c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd0a1e90245c099ea267ffdb9fe7727f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806173dfed9c5271631519792136dcb97","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896acf7fc50fc7d46cf4fd58ba1a01a8e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877cd8eaf1b44ad950ab2c1de574d545e","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a4a438f84238468ae34a00396733c267","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826990e339d1e6bfb92c43f2df4021253","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9839d6e5e860b74845e418690de949729d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980437a510b83f078e975ac6e022596220","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a9c0f13a0e24494a94657ac6f06a09e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f45a8957e29b9d8d573538c7c2a6660e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989bc0f1da02a3a34375b7c9fb790aa6de","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873814c430069fbcad3cc2b2068a2f2c2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a9fea2f1146b6aa8643c58308630eb8","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987d0661ac6c8236d9bcf05223d8b5321c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e988319841cde2f015576fce1144b7c103b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98538b20e19bcb07bd154b1117ca2e7f62","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bf7e5ed8e9ee51963dd29079b941aefc","path":"ResourceBundle-shared_preferences_foundation_privacy-shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98fe484d482b057065dd2bc365ef0b1cbd","path":"shared_preferences_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987cfa1a168d224a15e60944154b171acd","path":"shared_preferences_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e9882ef96f96deea78c69f4201bad0a0f36","path":"shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98321ebbc91006eb23c9f9908d066f0fb5","path":"shared_preferences_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ce14ca997c1ee5ad8eed12f49b985ef","path":"shared_preferences_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863033d3e663dd173908d510d04d9645f","path":"shared_preferences_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983dff215e39548f9188a7b8c8d4c24c32","path":"shared_preferences_foundation.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9843a113f040e59beff6b2bad0d9500e2d","name":"Support Files","path":"../../../../Pods/Target Support Files/shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815e66823fa48c84facba1c31c881a7f2","name":"shared_preferences_foundation","path":"../.symlinks/plugins/shared_preferences_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989d8d7f21b58b6c6ea6cdbf54b4310500","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ff6e5bd448ece42e55969cd7ee2dcd22","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986016d04cf905713b9d5b98ae9bb69fd9","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982743918f80430a5f6e3fb467f2d4e58a","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db5cfaa6497e017576049d2d766fe43d","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c4ce35bf05c88dedb678a8a05f7ddb","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fbfd6a91559f348dcd214ab1cffe04a8","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a41b93d2bbcfa5fa03eef8097eb619a2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835810861c4e0c37a8ee85af809b7fae6","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e622054adbb0796c1efd972eb720d1ed","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982499ce39bf8d8ed82a87b79418b6050f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfa4ad175bfd7a9f0e02542ff45ab26","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986069180160f76ffe39b94ecee9268997","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98adeb13adbc566e980f9d252124dcea3f","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820d6e1cf31e5551a3d68c121e504d8d1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f4b97fdfbc2f7c5f93d9c36aaac6a3a","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7c1e03fcf1c567ec11990def452273d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9853d850559207c4384f4df37b5e85a414","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aba81c10b979d23a5281707bb944aebd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9868b319ba9a799ba0a2bc5271ebda13ef","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca5fc8019efea2c0a2df87621e6bfd20","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98eaab2d07055d05ca402daaf728b22722","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f164259be96c4b6bb6608f7ce49cf3de","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ad6eba05b61ecb94890b1ef3012502a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b2273f60e8670b2f5db571354e1ea07","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDB.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98463be45d0e22ba2b4d01b4ebe090555a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872cbb441a98d645c1ab53aa8198b5a91","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980a984e700108dd111790049ae08ac1e0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fee7f0226744b7cccabb95aa1a2a58d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9813f225fcdcd4540ad3a526d2488cee7d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987dca200d5c95cb57fe2d64a729f10e59","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870809aee28a31c0ce2cfe6427a7a84ca","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a82a52d3958fd262c4d9754ada0841a1","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98174fc872150c14b5ae3ef8ed6f674bac","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe7eed89bf2c775aa63c02aebf8e6b33","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.m","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c822bb9bb66315e186526fd41e667547","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqfliteImportPublic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d179bdefff96b31664c33767a93f03d1","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqflitePluginPublic.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9892e5814da7ab09018c72d77ff35a15cf","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a8b633bfa4915a0040bf5c00fcd27ba1","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7b37f7cc0762d9ca0708a155f22b8eb","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eba8369512e5541562107b2d8b760a0","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e33f20f0d475d4d5413f81e2e96ca687","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987eef1cebbe3bb46644378bc4631fe02d","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed219e0fa4be3681e4e695b0532eae32","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988972fd127184a48f0dc3e6aee302404e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877fca5587d318fba37442797f6f705b8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983ae2fa1946bb36f5e91787fb46d589c5","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984f9f6b5fd902d714436e433e3f764dd9","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd7c7199347a79274529e6303ba01ef7","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca3a121f05226c04281b755cc833b917","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9893f425a7aea6b4482e64d0a5bc657629","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e49b35d1a40e4dde1b2357ef1cb8d669","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e5e76ea8d51c8b8d79c16a34746f33e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9854e643717fa41262edb3c3f1d725d61a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed6f9da6b7cdf2cb6886e30e6cdfbc8c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de4bc8577c1a9bea6d84ede9bedadcbb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d9613248b511bc9325f7bcd3f3e4dd2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988f60c32fce1cfc81f7648e0319f50cb0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9897a924c3a55a4bbf49fa6473d66861c6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c43b6316619d3de45f006103a22d7931","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981d8665643615515f27809c8e1ce6963e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/LICENSE","sourceTree":"","type":"file"},{"fileType":"net.daringfireball.markdown","guid":"bfdfe7dc352907fc980b868725387e9852fdd6f48c5deae81f51387413e1e002","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/README.md","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98856a830972b607e532cd4373e4bd4903","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe65523c54c78b02ecd1fb77f3e46537","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f79a082bb47738f827607307a0e2452a","path":"ResourceBundle-sqflite_darwin_privacy-sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98c804c2a12db2f8031b9549edefec60e1","path":"sqflite_darwin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dd296579fd63723f2385fc3e309a485","path":"sqflite_darwin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98dd8f3f1e4afb05338b44768a2eea883a","path":"sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839943824b9f6edc4e040c5c8b2151f82","path":"sqflite_darwin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9867c38360f7a8c57f89677b1b8dd0f63b","path":"sqflite_darwin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983c5c591d1e2590f943bcf841c7250c29","path":"sqflite_darwin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc2b7f452e75bd86ca68cc787ffd0e6","path":"sqflite_darwin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863d40e6f29652cdc2796922cd0db2932","name":"Support Files","path":"../../../../Pods/Target Support Files/sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e01895d459ef3578b9180bd22234ef2","name":"sqflite_darwin","path":"../.symlinks/plugins/sqflite_darwin/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ddf54a4994459d8d533e977b9d3701b4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98227f3bf9a2096f16c498e39e527dfd82","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812ef15f301a3279be9158ecc5e2ee578","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9821ac568b46c2e99c638c4468b42476d3","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98288b3abd52cc5524befa714a293f1d78","name":"super_native_extensions","path":"super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847c7f918c61997a2cee2375876483e92","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98038dcce1516b7703d28d4d5925b0e167","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e27492f09959f12010d60e1537d0abd4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b82d7b34f027e37120d28d733c9bc63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e2d0c4c003b18b59d642ab053cd2e85","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dac882f2bd6878262999058971045bb1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98906dd99ca335afe3dd9a7c6cc05a2048","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895327431cbeda356cd4093b0b03dde53","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3779ad14c3af16a3d17c6cb4a8af745","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413ff013f5792a21699c27aa303d4851","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503a720642223ab224d97e9c5f9bbe9d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9823726b2431178718aa367a59ee6a520c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f023da7cab226ce319043a4e226d36dd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e550084af2ca7acaf14461ed1f151852","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988cb8e2ab455bb20dc19f7a3a2246df30","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9896d5871a48fd625e7d723f390e6a98b6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/super_native_extensions.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98eeb382127790a1793c33c9b12ba2e097","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a18f45e0ed141c453cff3a3c4527ecbd","path":"super_native_extensions.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985c10bd2540461e46ce7f11028e018d75","path":"super_native_extensions-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb6de20b66e6b8dbd451f64bf906989c","path":"super_native_extensions-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdcda392f627466e5edccd1508970413","path":"super_native_extensions-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c698b70fb576aa4fe26ace0b9b8afcdd","path":"super_native_extensions-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc61835f7006374d2df8ec0b73025d7","path":"super_native_extensions.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98960e413e1bdfa694f48dc871e7ef827a","path":"super_native_extensions.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f219566d0916d6a77685c33cd16653c8","name":"Support Files","path":"../../../../Pods/Target Support Files/super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f569f35312eec5d67d042173116f99c8","name":"super_native_extensions","path":"../.symlinks/plugins/super_native_extensions/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988380eb098928f2df260922e9d961ad9b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98247df5322ea5db3a84214881c52ee25d","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c8fcbc6272382fe1137437a8cfd2aa","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ab2c88ca66fa6efb7003fcd0a5fecbd","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aca3ef13270c3be3fdb24dff41e0219a","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988826612973646a4053217e2324d0644f","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d552426f9b9a7580f9c3386dc024b81","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cd73363ca2db27832a5adec82341972","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fefce717b106b030e9dc126dd095e251","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ed1fd19213f07006bd2b9430d7772b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863db1d0c1fc2b855158b6fa51dde21fd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e2efb7aff436f31626c71ead15daeef","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c34f07f7b163887aa57af2d6c5172187","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1d44c68f08f6c906df9811d7caf2ef3","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9832553b1b2fc5c507feabcb35c959e868","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fb77cebbaaabf4e807dd6ebc12b73f9","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98490bb9f80fa5964176076d4f1e6394fa","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Launcher.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98daf626000fc0d232b897ba1dda9c0f31","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985162a45777def5ce41cdd4f46ff104a9","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983b3ce4248a360b7aac31c20aa78daf2e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLaunchSession.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ea293b0366abc71ebb96acf17297ef7","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9888d93807e2dc1f667f941643f134e44c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981798185d018f93fe0298b8aba5d63560","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981019612be8bea02a74431d38ce3b9cbd","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880eb5ac131bdc80edb26f033f8a72fce","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9851161dc629e1fb4d2d3fd8bd10ee4ca2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989fc545117a2c4837792115a214e84a3e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cae5b473355e5f3f56b441bc383d1a87","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98025d2d7ef7d681723bfdf777175c2ffd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834b9faca7fd3324a1c2164a4cf7e5313","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988303e4a8f1f82c7fb4ea2702bb5cb5dd","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984516cc538c760100f78348eb10326053","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98732e82463b828bce4818d57c1ea975ab","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880fbcdef07ddfda2f3153be29f969c0f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981e967b61253c26da24779f0e1571787a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983de9bb36b851937d715c96cb7d26dc7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98611614762b8213684ec434869762ea22","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cb0470925262a8de3be403c8ae31ff0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833562037aa4c75db5121f347c9e53091","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e70d73185437e27f145504e3dc9f1033","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed5927b53a4d1ccd1f9684043eea4526","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a5500bec173c13a85e52bd5fff791d8f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9856b8b14381dcf0263199d3e9429dbbba","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cceb9263cc3f6dd5b9212b964cfa7054","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98604688695bcf046b591ad88eaebd26fc","path":"ResourceBundle-url_launcher_ios_privacy-url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e987be471ca7fbb33b50a518eea5dcb87e8","path":"url_launcher_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871fa081d7f580f4936897eb46aae0878","path":"url_launcher_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986f18d2a83f59c045caefbc68524cb3d3","path":"url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad745689d7ee76ea50404b77fa12b6f0","path":"url_launcher_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eec495e5daf9423acf950402315f3522","path":"url_launcher_ios-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b3b3d080e11ff54026ba894393f62b3a","path":"url_launcher_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e980bab71536c4bda286b310a48eedac214","path":"url_launcher_ios.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98938e093d7e3b952e819ce9ac1448e73b","name":"Support Files","path":"../../../../Pods/Target Support Files/url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833186a359a29a050b940ca2dbdf952b9","name":"url_launcher_ios","path":"../.symlinks/plugins/url_launcher_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98273c41ad57101f59fb22ee04a3de8ec9","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9877cdee8b856454ff8e4213d5b4b8e81e","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e8fed289e7a93127c4e71b0a59be8c1","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9e91c05d6fcd60cecbf709fcfae9f8","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5bb761b833aa1c82ed6c79ade71f8d2","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f9d8e7c800ff72bd4f22b25217d72bd2","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c7188e90eaef7902fa09bbd3b6c34138","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d69590ab194d9defd371868c962c163","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e1eeba79f29d46cbd63b8f82bc2f113","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e575334bd02bdf114450e05cddb31e6","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987403e6bff419acd09288ab9193a47d46","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983974551ef8940d0a77ce7e5dba345c15","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984bffff6dbf90752f811d1b628435611f","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980016f1720faf508de4c79265c93531f1","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985409f0ee94bca73500af103a1f36ec69","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98863ea06f124e08ce56a9dc6093f14a5e","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9898207ddf6fb048e55cbc82f1767304ad","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c78e7356e2edc959fdb6dcf759d9ea32","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFDataConverters.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984555345bd3de9a732d0775aeaa250a8a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFGeneratedWebKitApis.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dbff9619978e59ca5bd2bd9b78df76e8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ed9e6db451958c4b973973bda4af232","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFInstanceManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9897e653c33b1ca27c845f24161d5df94b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d512e4282ea1aaea1f31ec22635e6a73","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFObjectHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863ec51aa8f06ccdd336b2b840124b5bd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFPreferencesHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852d8f233325638788b9c883bef4c434e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9839386e23f7ec2e92ef671f9a65fe9467","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98554327c6feb943c123568d30776d18b5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988abd8337fa8780181224b1c59e11080a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bbd5fcab06bd2b4eb52aea88b514bbf5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8f2fa4ff0132b374c1eca3de40fe4ce","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cdfda639596827302a7f54cea81404f7","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLCredentialHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982d6b13259c9952197ed43ec8ae880592","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f11e46b828b92865af10c3b4c4c9d68","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984b54bebd3894ad50c5db03675b718387","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUserContentControllerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e0160a9358f35f9d1c0b5a60a3d0f682","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6eb829538253c8fd04f8ad89b11abf3","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a1bb5ceebb3ce6eafef856cbadc7f5d2","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a83be649d9b3f6edd971f560f1bdc837","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewHostApi.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dfb923094c09a8b2984e29f4ed12750a","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9854b0c1f62864b38122a6cd2adf415fc8","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dba23cc94d8fa0e09dc855fe286cf011","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFDataConverters.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba40413987e8fe9a668fab4d2f62e968","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFGeneratedWebKitApis.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b41a55f1ff6a49c7cb1bfc6754b0048a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa71e1ea8eea7861996ce4eea6fa83c7","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f56450de8c5ce26b62883f42bd50aff","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bdc5d2d37bbc00acdd9d5e37557c61aa","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9876af14ebe27607bf4d780619a0507e5a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFObjectHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812c7ccf1eea1c9c80d378418ff91f3ef","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFPreferencesHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad06b47c00b9bddb782d0454f9545c23","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c04773599c925e49e8e9df79338c2cb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c3bc52c27128b5552e2f492ee8e9d38","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983638c2d3e148c4aff7f56dae69d5bd44","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852648a53c3238ae232bc1cf732368733","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ac2710f7f8419957a975656f4f11010","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f29772b4f240416b82bfca4c7240cc52","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLCredentialHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98287942d628c319dfb58242b7a056e501","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b2e4dc50da302b519989286271f986f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da68ab6d947fd76c2248fff2397f9474","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUserContentControllerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db1f999a296f5d4b787f4f9b4b82b565","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98635d8b53208d8a44fe1b27f33c27ad74","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de08c86939fbb40b81087f28b271e5b5","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98984ca0249b9df8b5209a6dcb1c869dfc","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewHostApi.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d786586788316e81a204e06a860d9b2d","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d305bdf63057a110d9a04a946a6a0b3","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985af659c04f6cdf4a7c73cb8559d95a55","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1e70587e6aba44c0bf6b4fa49bed627","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b00ae6fdbc30a34dfacc53e5e678acb","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865432e0f8cd5ef04acb4722323bfaef7","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fe60fe58df7074f3f027dfb308c785b","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98299b41c4840e81d29fd9defe63f562c1","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a1a72cddbe31b7904461fb30eabe3f41","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866cb0dd0dbb6d8ae7c42bf7e70385636","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c9395928d2b28bc4832e7eaf6338061","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981cf382f136f655ee4ef53b31836e9976","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982695540da12cb3688e9cd89de55b4c12","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989cea8ab7b2bc9aafea94b071fcb36fe9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cb1234016d9a45a8e6ebfa24a1ca6b6","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ba95ad9f21e528ba007f114552e8faa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9004908f380362c00015c90fe116d5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fe5984c239273e4752feee9c1cd0d417","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb7ac77a35fceda15f40d2e6dfe455e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98481664ecd25a98fbf478061e4214182c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984d34857cedf24e0ffba417d4ddb7a5a7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f104ab4138053278b0c742e5da3a37b6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba8636d82c00ef31adc8e2a02c78383","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e4b951171365bdf1469e58e30bb095be","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/FlutterWebView.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98f15216db275bd0a141b390a80247ad56","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98319f1ecfeb379cf849cfb0c9a15fbdf7","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afcef86fa9f9ff1253aefecce2db52","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dc040649dc9e2eab53c3da3c40c35b0","path":"ResourceBundle-webview_flutter_wkwebview_privacy-webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d9a8732910c4132ba5e9ee6c49a6fd0","path":"webview_flutter_wkwebview.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985debbc2cab2d792fa92242781697aa86","path":"webview_flutter_wkwebview-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f10ce8a5ece3a15020d640ce3ed6466e","path":"webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9818b0186c11c004e985a36edb09183861","path":"webview_flutter_wkwebview-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98e3c8f3c679654f0d8322c0bfe86cb48c","path":"webview_flutter_wkwebview.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98de306c3f6c2c14f312be69bcd973767d","path":"webview_flutter_wkwebview.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fb0231873e76e3fdd25c08c4f7142e58","name":"Support Files","path":"../../../../Pods/Target Support Files/webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98daffe5a2e168f85d5660f57f2cc4f3d1","name":"webview_flutter_wkwebview","path":"../.symlinks/plugins/webview_flutter_wkwebview/darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880465010b585dd5bc5b7af0a27d58067","name":"Development Pods","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984bcd4feee9e1dfb73f639f9bac23b2e2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVFoundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e987724d547599f4408ee3b3d56fa903a20","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98c18474134a48ed556f51c824bfca3246","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/CoreTelephony.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9885770c7fb25aa0db0cfd09c5443f9797","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9802c3ee0032b21aafbeccc5b7db734fb2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/ImageIO.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984497b71acede5531fd260c9ac8b3d9e1","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Photos.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d671fdb5a7bd1c3267d3e6f35907cbca","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/QuartzCore.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9872b55968a8ae9b74a90d6bfa9882dd5a","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/SystemConfiguration.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d159ebe55fadf57df5691badabf215cd","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/UIKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9827102c11d594e53de5adfd4435cd953d","name":"iOS","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f57df5597ed36b645cb934c885be56d","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980f256812a9858de27fb60bd1accdd32c","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupCellItemProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9837ba02f2eab7de1730b72c879d53f332","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailBaseCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981791c992ccf9564106a690e1d96420b8","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailCameraCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981149a1d26875e8b68800278ef1ef0dd2","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailImageCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8875336f053558b1f9ed7c43e262ddd","path":"Sources/DKImagePickerController/View/DKAssetGroupDetailVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98164873936b2ccd15bdc608cc60c2cc65","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailVideoCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a7cecd4700762c7a109513bfb52989bb","path":"Sources/DKImagePickerController/View/DKAssetGroupGridLayout.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989c224ca64eb47c09a922a3d927cf7f10","path":"Sources/DKImagePickerController/View/DKAssetGroupListVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c20dce043f12383acfeb98f96470e02","path":"Sources/DKImagePickerController/DKImageAssetExporter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9855672fea159780d4dd23a1f5a30a7837","path":"Sources/DKImagePickerController/DKImageExtensionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98261f313efe36b40a087a9d35206c9607","path":"Sources/DKImagePickerController/DKImagePickerController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982975067e23a41db0b8f6979b4a4a3b55","path":"Sources/DKImagePickerController/DKImagePickerControllerBaseUIDelegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d331501c9a3bbd1cec21da6d1bed6a79","path":"Sources/DKImagePickerController/View/DKPermissionView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851006020f4c7fc277370bdaceee539fd","path":"Sources/DKImagePickerController/DKPopoverViewController.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1abbceda566cfe605dbb72a81ac8f8e","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e10cad38a7b8b791b5523321d2112ef0","path":"Sources/DKImageDataManager/Model/DKAsset.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985cdfbee7f0fa63af790094c590a82f83","path":"Sources/DKImageDataManager/Model/DKAsset+Export.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982173071859389679a6c45171b90987da","path":"Sources/DKImageDataManager/Model/DKAsset+Fetch.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a89a84fdf4f961d13d26db8520129232","path":"Sources/DKImageDataManager/Model/DKAssetGroup.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e418cf226491ab81be949451bec00d12","path":"Sources/DKImageDataManager/DKImageBaseManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878488e740dd474df94f5eec1f4ebdef4","path":"Sources/DKImageDataManager/DKImageDataManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e01ac6181d083bc327440f955529b52b","path":"Sources/DKImageDataManager/DKImageGroupDataManager.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e366accc57fcc9cc1a70ecd199c50e7","name":"ImageDataManager","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a05c3bf490f4a4639be6133ce46fc50","path":"Sources/Extensions/DKImageExtensionGallery.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9878b271eaafac7e3a83dd4dcb67d7b63e","name":"PhotoGallery","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9892de1f95603b3309408b855b150222e8","path":"Sources/DKImagePickerController/Resource/DKImagePickerControllerResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e982cb145326ee0fe9b023de780deed78c8","path":"Sources/DKImagePickerController/Resource/Resources/ar.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98aa1b813ba9ab60abaff79984557a95ae","path":"Sources/DKImagePickerController/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e980c9106c6deeaa489bece52872ddc8302","path":"Sources/DKImagePickerController/Resource/Resources/da.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98638a8ead3c4921071ce058b31e0789b1","path":"Sources/DKImagePickerController/Resource/Resources/de.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9808c6c81d6544958ee5022314e9257bea","path":"Sources/DKImagePickerController/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989d1190414344f8164f52c639a0b3923e","path":"Sources/DKImagePickerController/Resource/Resources/es.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98a3612de5bfe6103968b0ad0e24321c06","path":"Sources/DKImagePickerController/Resource/Resources/fr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9840393198352d2a493eca64a5e8366781","path":"Sources/DKImagePickerController/Resource/Resources/hu.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e9866b7b2a063b357116fff6128faab6010","path":"Sources/DKImagePickerController/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98389b54d175b1ebff57cec1e50f863fcc","path":"Sources/DKImagePickerController/Resource/Resources/it.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98653c7ebf4d1cd10720ef59b098ac5602","path":"Sources/DKImagePickerController/Resource/Resources/ja.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98104b68b4ff0a25a5296d0b83fcc56453","path":"Sources/DKImagePickerController/Resource/Resources/ko.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98d31edcc28dfbbe3d8ce637b5afc30729","path":"Sources/DKImagePickerController/Resource/Resources/nb-NO.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e984482abef551eb7f8c2328ba24695640c","path":"Sources/DKImagePickerController/Resource/Resources/nl.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987d2482c68c58caea8c964ddc88375bce","path":"Sources/DKImagePickerController/Resource/Resources/pt_BR.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98e82421c9939f56bd2bac60c90c0972a4","path":"Sources/DKImagePickerController/Resource/Resources/ru.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98641e96c7b9979000ba15756d8c4088c5","path":"Sources/DKImagePickerController/Resource/Resources/tr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987dbf20be783e65771c0b6b10146e0526","path":"Sources/DKImagePickerController/Resource/Resources/ur.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98496e8767ebc06f02f3a190467b76c669","path":"Sources/DKImagePickerController/Resource/Resources/vi.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9851d4a9e151fbaba3b43b0c1c328e7692","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989af334eda3f4b74b226c99b858e3a8ee","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hant.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9804dfbef9d9e61df9bb559e2872b5da24","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d02568085c3a0b85378f96f4eb289f1a","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985e388c776c1b334623438d45d3541462","path":"DKImagePickerController.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982407d1e8008282cbe7def1a4ffcc63a5","path":"DKImagePickerController-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ea7538dde57b4e0f1e4dd4e75d1c0d0c","path":"DKImagePickerController-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b27014ca8a98af66a8fbde29e88273ac","path":"DKImagePickerController-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c1f453bb5d9dee5b1b66f4e8eb4eb4d","path":"DKImagePickerController-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9806dc5717c6a31d76aad6bd6ece1d0e8a","path":"DKImagePickerController.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982c698840c90027623e3da75a6ca7c080","path":"DKImagePickerController.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987e62c3ecefb84e0040e723e52f3a31b8","path":"ResourceBundle-DKImagePickerController-DKImagePickerController-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e983adef97a715cd560344faddd2136ca7c","name":"Support Files","path":"../Target Support Files/DKImagePickerController","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a64e2ff11892e14ae7fcb90015c6a37","name":"DKImagePickerController","path":"DKImagePickerController","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9863e802284c73bd79713e7ebbd9884a03","path":"DKPhotoGallery/DKPhotoGallery.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980341a413639d3324d70cd433b477ad56","path":"DKPhotoGallery/DKPhotoGalleryContentVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989de872349f4afb6a3ec10554d20a0824","path":"DKPhotoGallery/Transition/DKPhotoGalleryInteractiveTransition.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9891931bbad7bcd3c15555c1ab3b5e9eda","path":"DKPhotoGallery/DKPhotoGalleryScrollView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989304b9d8408d53cfd4dc626445b7aa78","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851ce307e43c7abf9a92db60b62d92eed","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionDismiss.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98bb524791f17b91cf8d8e69a04b9d6241","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionPresent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9817d140dcad4deb3eb9494e9b79f6a54f","path":"DKPhotoGallery/DKPhotoIncrementalIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b08fe598889329654b388a853ab11345","path":"DKPhotoGallery/DKPhotoPreviewFactory.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98abec4317337fcb2bdb4f60b9acc75558","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe52c027e9b931957c809c3731fe5e8","path":"DKPhotoGallery/DKPhotoGalleryItem.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98790064f3a8ebed4c57f29d168a40864a","name":"Model","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8c44b9bad588a8cbce8ae16059cc044","path":"DKPhotoGallery/Preview/PDFPreview/DKPDFView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989def67d6c082310eb82979d6e6048439","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoBaseImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e64926604b2aa420c550979dce302c11","path":"DKPhotoGallery/Preview/DKPhotoBasePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985f43f00297e493a6c1ff3f01846dd6ab","path":"DKPhotoGallery/Preview/DKPhotoContentAnimationView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982b1b24b667eeb0aa9df1641d1afbf02f","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageDownloader.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b7c4392f573762b73d261ae2c1ad25f5","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98036bb4411bb8d063d059db4e041547ed","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageUtility.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b73272e18580fd7065bff4ab280cf9d1","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b16bf7005681538906dcee4437e1937d","path":"DKPhotoGallery/Preview/PDFPreview/DKPhotoPDFPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989a757ab8e8a9fc630d1a57794cb67b77","path":"DKPhotoGallery/Preview/PlayerPreview/DKPhotoPlayerPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988a849dc79b3d29aaf31294b33a51b25b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a03d72595a015012babd53f62ce9fe7b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicatorProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9895fb6b2018a434a37a846c5e29fe4c20","path":"DKPhotoGallery/Preview/QRCode/DKPhotoQRCodeResultVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9806b510aa1c18127530918269f3b10ecd","path":"DKPhotoGallery/Preview/QRCode/DKPhotoWebVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b5a7957a4c4d63e718fe2f2bfa1a142","path":"DKPhotoGallery/Preview/PlayerPreview/DKPlayerView.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a4f4a3f314a53e8278137c97d48a2a7d","name":"Preview","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982c920e4b6a548594014fc19d89752af0","path":"DKPhotoGallery/Resource/DKPhotoGalleryResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98322362f55daedd42cf2628aab1e7ee5a","path":"DKPhotoGallery/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98257fc87c20dddd559c3a65cb29ef2a8c","path":"DKPhotoGallery/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e98632a77eb6e8a5c194970ffbb837e3163","path":"DKPhotoGallery/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e981cdbb40ea77c1fcafa54f43ddd5ead0e","path":"DKPhotoGallery/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9833f8b21cf1e493b32aa79c353bb36c59","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e4621e932260350f9fe18d776062789","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98669eec05ea75fd44fb0e2666721dbfac","path":"DKPhotoGallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986ea1aeb64b4ee3b54e278e3a7146573c","path":"DKPhotoGallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984490f039b0eb3468d94b0ab2e6a714c8","path":"DKPhotoGallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827c39759d14ae68710c6e6f3caaa10e9","path":"DKPhotoGallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986024dcc5015a387313d8adc4bce7b36a","path":"DKPhotoGallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b1d81c0ffe868a64db997ed9da144cf0","path":"DKPhotoGallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988204bc98e0d67983074132a4f5665a6b","path":"DKPhotoGallery.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987ab489cc4f30435cd7ded82f0c6eae68","path":"ResourceBundle-DKPhotoGallery-DKPhotoGallery-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ec59054c088e47e413206915ef9028f","name":"Support Files","path":"../Target Support Files/DKPhotoGallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986c674d50d3b10e235f4f35f7a35d5ae8","name":"DKPhotoGallery","path":"DKPhotoGallery","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9896448ec1f9cbf14857b3fe6b95caa011","path":"Sources/Reachability.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989bacf7eba26d0bb486de3cbfe5ea13c2","path":"ReachabilitySwift.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6244bc1a6a40e6115703cb3d00b8446","path":"ReachabilitySwift-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987b7b6b5547766ec62d38737cd24e68cd","path":"ReachabilitySwift-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98547e39e19bf28c52f14233768cc21bd9","path":"ReachabilitySwift-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988aeea5b451523377c4d21395c5903a7c","path":"ReachabilitySwift-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98901baae58940cfd27e962f9d5011362f","path":"ReachabilitySwift.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a874d2419abc53ad8b0cd3d2a5318ae","path":"ReachabilitySwift.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9896d1894bae44836917da92d3aab54f93","name":"Support Files","path":"../Target Support Files/ReachabilitySwift","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bebab75b96c37c53dc67122f6031b002","name":"ReachabilitySwift","path":"ReachabilitySwift","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cbbfeea198fc4bce239c47fc57d8a961","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4610931d9a303cf4a5413361d1ada97","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e908f1ceaaa33a36d5403f550e8b9aa","path":"SDWebImage/Core/NSButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817f5ea7520f6058c0504b71e2865c594","path":"SDWebImage/Core/NSButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f06f7f319f35e24d3947bb38a46b4b9","path":"SDWebImage/Core/NSData+ImageContentType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987b53a8893f5f3cdd6faadb0d3bfc4d62","path":"SDWebImage/Core/NSData+ImageContentType.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890edfb9455d8d050d856d19de294b76c","path":"SDWebImage/Core/NSImage+Compatibility.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987853d28d16fa30841c1a55b61c447d01","path":"SDWebImage/Core/NSImage+Compatibility.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c8d140d68f858c8de679df2d49e39ce1","path":"SDWebImage/Core/SDAnimatedImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b0ebe90ca974f5cad05396d5c0c407cc","path":"SDWebImage/Core/SDAnimatedImage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9809df879cdb20a2d85e42e0ec9467d874","path":"SDWebImage/Core/SDAnimatedImagePlayer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e2ed6b92aae530671fb6a85f4968036a","path":"SDWebImage/Core/SDAnimatedImagePlayer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f116af502c254813038fb0fa9b6d7e43","path":"SDWebImage/Core/SDAnimatedImageRep.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a40b9365faa88cb247c44544207a3ce","path":"SDWebImage/Core/SDAnimatedImageRep.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985026f7f77e36035ebbdce01168f1c703","path":"SDWebImage/Core/SDAnimatedImageView.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d1c51ae1c8f49cab13fbed609b95490d","path":"SDWebImage/Core/SDAnimatedImageView.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c881de2f36eb982e91b6111edd2159","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98229f3dff826db691d58cbf3df3ae4b2c","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a7fb3d4eb5f455ce911e0e84b77ee5df","path":"SDWebImage/Private/SDAssociatedObject.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9889c0b922f6710e9bbaad47d90e458796","path":"SDWebImage/Private/SDAssociatedObject.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870a50afed1d5bb331637a22e1b2cdab6","path":"SDWebImage/Private/SDAsyncBlockOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a88b4352ddd2268b71af2cececb468f","path":"SDWebImage/Private/SDAsyncBlockOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894060044116c365175a4d8c9d018b47b","path":"SDWebImage/Private/SDDeviceHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379407cdd5ec5f0842afe1dfb96cddaf","path":"SDWebImage/Private/SDDeviceHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820bbf68e6d4b7e185246c728464b69b4","path":"SDWebImage/Core/SDDiskCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98556c8c3201769e199eb76c9fc9d5a0de","path":"SDWebImage/Core/SDDiskCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9822703fb114a75f5c5f2f254bb3f6484c","path":"SDWebImage/Private/SDDisplayLink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982e5995722797bf81f987a7b34e8d29db","path":"SDWebImage/Private/SDDisplayLink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98688ef71815752ca7f6167d44ae846d81","path":"SDWebImage/Private/SDFileAttributeHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4442f69c6514bc98fae925092574979","path":"SDWebImage/Private/SDFileAttributeHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cc8572110877f8beb99b8973afcea556","path":"SDWebImage/Core/SDGraphicsImageRenderer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d990503ee20a63ae3a6ac274eb15e500","path":"SDWebImage/Core/SDGraphicsImageRenderer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a81bf725f34f12d4633981ff1a9be615","path":"SDWebImage/Core/SDImageAPNGCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a93d56dd2776df0d9323eaf235ac4573","path":"SDWebImage/Core/SDImageAPNGCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98063a7a83ef11a2830cff9aef0f246a46","path":"SDWebImage/Private/SDImageAssetManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9878a035a3947c0bfb2c74de160efc90f8","path":"SDWebImage/Private/SDImageAssetManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba45c50b7e93c71a1094293aeb130683","path":"SDWebImage/Core/SDImageAWebPCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988b1062c96ec96331da01cae06e95e9bb","path":"SDWebImage/Core/SDImageAWebPCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98adff88ced90105be82ec5c254de38c39","path":"SDWebImage/Core/SDImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b59fb4d17416bbf73623c9ca148d4781","path":"SDWebImage/Core/SDImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da4185ba0b8e0c270b9045f1c0ea7f34","path":"SDWebImage/Core/SDImageCacheConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f690a5caa9ac98baa8bfaca0d8119a97","path":"SDWebImage/Core/SDImageCacheConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fc8806592e30baa20155c119711a319","path":"SDWebImage/Core/SDImageCacheDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa465b740f26e7c857d665d9e948f68f","path":"SDWebImage/Core/SDImageCacheDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98025e98798e009017d38c6b8f9f98dd3b","path":"SDWebImage/Core/SDImageCachesManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989babcbb0bec790231c9ea03d45485895","path":"SDWebImage/Core/SDImageCachesManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9848a18db96f9cc4192f5855b2c6ec65fc","path":"SDWebImage/Private/SDImageCachesManagerOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988c4bfa1a1a41475204073a9c37cc4138","path":"SDWebImage/Private/SDImageCachesManagerOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c179b604ae00d511bcfe7b1c26229a","path":"SDWebImage/Core/SDImageCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e79c00adee724aeb4ee07faf25c36816","path":"SDWebImage/Core/SDImageCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899370db25d5ccec85c5bc3841d9150c3","path":"SDWebImage/Core/SDImageCoderHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9864cb7481f2ac08dacad300433c1e0e01","path":"SDWebImage/Core/SDImageCoderHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98466aa14d7abf59b152f093b8602bd34b","path":"SDWebImage/Core/SDImageCodersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a351c522c640e5c3836b0b3b0ff15805","path":"SDWebImage/Core/SDImageCodersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989938d9775335a6ffdff595f50234b88b","path":"SDWebImage/Core/SDImageFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a8ca264bae9407c5ab570cb5c4eaa6e8","path":"SDWebImage/Core/SDImageFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a78ff5eec8840469dfa52c4df920d84","path":"SDWebImage/Core/SDImageGIFCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d22bab198a56a739526a9f9fac69088","path":"SDWebImage/Core/SDImageGIFCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b137bd091f2422235254ba87eeeebb86","path":"SDWebImage/Core/SDImageGraphics.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987ec2e6e40d48d5e1c573c77074e16edf","path":"SDWebImage/Core/SDImageGraphics.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9817f302504ade0c532160f76082a200fc","path":"SDWebImage/Core/SDImageHEICCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cec361cb5581e3f861f0c9ebc7eb79b3","path":"SDWebImage/Core/SDImageHEICCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863c1633045c8f4e6a7fefb974e14ca36","path":"SDWebImage/Core/SDImageIOAnimatedCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98728975404c77f683b561feae9c1d429f","path":"SDWebImage/Core/SDImageIOAnimatedCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9840158ea8f0a0e2c720c2f9a0c43bc29e","path":"SDWebImage/Private/SDImageIOAnimatedCoderInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a3f021a5e50a888a5ae1abc4e2e57fe","path":"SDWebImage/Core/SDImageIOCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ea71fbe3808e0193ef3ebfe97b71ed","path":"SDWebImage/Core/SDImageIOCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3114679d79d3ddb667c3e6fb257d6fb","path":"SDWebImage/Core/SDImageLoader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8bfeefcb91662f7491bdee227ce85b8","path":"SDWebImage/Core/SDImageLoader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987a235e20b10d513a95f2fe5a4faa9fb2","path":"SDWebImage/Core/SDImageLoadersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ae292dc3f6734ffa8a30d1eb750b312a","path":"SDWebImage/Core/SDImageLoadersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98555b647b7b68b3c59175202ef82bc955","path":"SDWebImage/Core/SDImageTransformer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379f432747c66b556608cfcb163dd08a","path":"SDWebImage/Core/SDImageTransformer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b63b84d1f79f14a6dc3a12cac2809ed","path":"SDWebImage/Private/SDInternalMacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aad7fdfe00389f16c787afd556ef9f8e","path":"SDWebImage/Private/SDInternalMacros.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98209eac57b1854edf591b16e3f80fec69","path":"SDWebImage/Core/SDMemoryCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b6d0c6ce7e156d1ba21129855543471","path":"SDWebImage/Core/SDMemoryCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898c0fd4390df5133c07e348bdbd55d9c","path":"SDWebImage/Private/SDmetamacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e65ef0d735983c43262cb848c637768","path":"SDWebImage/Private/SDWeakProxy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1a6e6955d89f3463af21ab50142f735","path":"SDWebImage/Private/SDWeakProxy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9802c2dd1c8e6745d6ad8dfa6ac15a50df","path":"WebImage/SDWebImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98583254b6e10a610161162b2462c9a243","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d67745e1c95b9a3b0b6afc23ba0e53d1","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6013c78eea89a230352d0a58977ee65","path":"SDWebImage/Core/SDWebImageCacheSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c798c0650901d3f454e84b38b32b7c14","path":"SDWebImage/Core/SDWebImageCacheSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987bcdc97ee957f7d3dcc46ccd3df087d6","path":"SDWebImage/Core/SDWebImageCompat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98175d683f5876f4bef28a5c568a03ee0e","path":"SDWebImage/Core/SDWebImageCompat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a1f302e052441cf2364ea9fdbb633665","path":"SDWebImage/Core/SDWebImageDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9801960d18f6952523241592bfcf8df74f","path":"SDWebImage/Core/SDWebImageDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da783a4512ad557f5bed33b07819428b","path":"SDWebImage/Core/SDWebImageDownloader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988bf2ba3046f2a1dc82c3ae3236bb5c29","path":"SDWebImage/Core/SDWebImageDownloader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987349aeb8db30aa64526eda7973f155b2","path":"SDWebImage/Core/SDWebImageDownloaderConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987233e0726650facd69de48131fbfb887","path":"SDWebImage/Core/SDWebImageDownloaderConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f6fc0eca1c4df41989a0e01130390","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b1e56d48b1b3c795cd043d008ac2d69f","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986735d6c802483fa77c3db72c66398b93","path":"SDWebImage/Core/SDWebImageDownloaderOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a5410248346eb3a5db888eeaf016ed3","path":"SDWebImage/Core/SDWebImageDownloaderOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980f2c0526259c08fb215f33283d062669","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986771283dce17b4fb9bd4b5fe643ed3b9","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae78c144f7dd1ffa4151c834e1856244","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cd959e9548caf1d3b10d572543028967","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f2f1b5fb7e7a0259d587693c70e17dc","path":"SDWebImage/Core/SDWebImageError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d7b5236ded30e2d2a5ff450a32f7bd62","path":"SDWebImage/Core/SDWebImageError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db171e7f43254466428cd7d160d4bc66","path":"SDWebImage/Core/SDWebImageIndicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988059612109acc8adb78283f1ba5f6c8e","path":"SDWebImage/Core/SDWebImageIndicator.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a4f6041648958d4aff810b46b83ec9d","path":"SDWebImage/Core/SDWebImageManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ec38f9cb9f5d4978c0311d1008e04cc","path":"SDWebImage/Core/SDWebImageManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c27b087e8cd840abc3ef59829b80efa","path":"SDWebImage/Core/SDWebImageOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ab8963206759569d84f30727fa4cba2","path":"SDWebImage/Core/SDWebImageOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b72cfadd9fa624c843ceaca86f14fba7","path":"SDWebImage/Core/SDWebImageOptionsProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b5e7d24f1e11dbac304e87bddaff0bab","path":"SDWebImage/Core/SDWebImageOptionsProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982a13b238680429b9cb2b8bf0839d5f79","path":"SDWebImage/Core/SDWebImagePrefetcher.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985fdfabd035ec7a467b035a8ce350edd5","path":"SDWebImage/Core/SDWebImagePrefetcher.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fbe1b7021e7099d47e3d29bb0d246b0","path":"SDWebImage/Core/SDWebImageTransition.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d898bb0c82f19d5c90770a13e1992bc4","path":"SDWebImage/Core/SDWebImageTransition.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e46d3f1b4958c657b86076222797d146","path":"SDWebImage/Private/SDWebImageTransitionInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b6a95fea066801733ba0d9eb7781a8d","path":"SDWebImage/Core/UIButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a7d08c8d1537b994f19071149f068bb7","path":"SDWebImage/Core/UIButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9845d7911932c79e171dd089fd4628b5b8","path":"SDWebImage/Private/UIColor+SDHexString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98008deca9be811cf53f205b0ddcc4983a","path":"SDWebImage/Private/UIColor+SDHexString.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986bb62f370bbe234b6bd0d7c77da71782","path":"SDWebImage/Core/UIImage+ExtendedCacheData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa1127430a2307c0512ba13f3695fe9e","path":"SDWebImage/Core/UIImage+ExtendedCacheData.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd7d7396bf3843012af71140da2a49c","path":"SDWebImage/Core/UIImage+ForceDecode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863a4e0085b4eca2b418099edfec4340a","path":"SDWebImage/Core/UIImage+ForceDecode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5c221940cf71976a9792c00ded2a77c","path":"SDWebImage/Core/UIImage+GIF.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d05fd41d05faa2360aeaf46fb5065514","path":"SDWebImage/Core/UIImage+GIF.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98662eda270b9081b770cb1f2a02e387b2","path":"SDWebImage/Core/UIImage+MemoryCacheCost.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd8dd526fbbf1c35d004173853ebc817","path":"SDWebImage/Core/UIImage+MemoryCacheCost.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986357ed01b566bb1d1be0954a632e35e0","path":"SDWebImage/Core/UIImage+Metadata.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a263fb34dde4d01069f152b021094c7","path":"SDWebImage/Core/UIImage+Metadata.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98deca6db8fdc22a81efbb6cd75f7c9144","path":"SDWebImage/Core/UIImage+MultiFormat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f8ce8389cf620585da7cd0d5ae68c6d","path":"SDWebImage/Core/UIImage+MultiFormat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f79a5a836cf1d081150335f28fe35761","path":"SDWebImage/Core/UIImage+Transform.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1367cac0329507a751b00959f8b1fe9","path":"SDWebImage/Core/UIImage+Transform.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9836fad0c1818f3ebd7ea94ecdb2cda58b","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e869a02fe4a657b9ddb0f148af11b090","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fc1f6b2f5b528239f7eb374a16707dd2","path":"SDWebImage/Core/UIImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a0efb32908f89fd6eaf93a60386fe37","path":"SDWebImage/Core/UIImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d835b0cd54e8e5c3eefa756ca6a8cf4f","path":"SDWebImage/Core/UIView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98da978efa2c20cf80e6a593952a64f214","path":"SDWebImage/Core/UIView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982999b5735c33706130ed303f0efe7372","path":"SDWebImage/Core/UIView+WebCacheOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983963a49bc38ac28ca13543440440738c","path":"SDWebImage/Core/UIView+WebCacheOperation.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986e6118052c889416524a66863865f2d2","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b569f5963060c97f2f2ba38385fe0047","path":"SDWebImage.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e582145da4c7bad45fd0fea722620980","path":"SDWebImage-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f69556d85c791f9f7bb4864067304e9e","path":"SDWebImage-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d7cf445f11b8a851603791bb80dccd","path":"SDWebImage-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c916368f02890a1ae21d78a5082dce","path":"SDWebImage-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98cbf68d1f922bc4817004dd6a2700f369","path":"SDWebImage.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982a978c9504e7f820a9a62749b4639eb7","path":"SDWebImage.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c5c4f2160b2c64c56cc35e1563f69dfa","name":"Support Files","path":"../Target Support Files/SDWebImage","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ab633a406a0ba749c8183ca48f70ca1","name":"SDWebImage","path":"SDWebImage","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98198cb7323cf38f49c099a19af424e38e","path":"Sources/Swift/Metrics/BucketsMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9820b096789e7bd1bfa4b58fa13bc5b50f","path":"Sources/Swift/Metrics/CounterMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98853fa02d04d0430ad3f2d7b333874af4","path":"Sources/Swift/Metrics/DistributionMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ac0780da839edf981809953008673060","path":"Sources/Swift/Metrics/EncodeMetrics.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982bd4f7e001b4dd1cf079308ca514554b","path":"Sources/Swift/Metrics/GaugeMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9824d6ee3483a76d2eb18b2c33833c5c8a","path":"Sources/Swift/Tools/HTTPHeaderSanitizer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98382df2b05839d9ea3f03c148af3ae692","path":"Sources/Swift/Metrics/LocalMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981b4b3945f043de125e7b8c8aaeacc7c3","path":"Sources/Swift/Metrics/Metric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9830b09011eccca71f62b2f66238867f76","path":"Sources/Swift/Metrics/MetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa15c1b3d311498677dcd94b75280f47","path":"Sources/Sentry/include/NSArray+SentrySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982ad87d7afe24ffd2d6bd213296aea811","path":"Sources/Sentry/NSArray+SentrySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4b859c713f2344c6fc1477b46cbafeb","path":"Sources/Sentry/include/NSLocale+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987f7b4c8e450d6bc934891b572509dd5c","path":"Sources/Sentry/NSLocale+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b9346543c1b98901f22f3c7e23aff838","path":"Sources/Swift/Extensions/NSLock.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b00bcbca3893520fc4f39ff18d0b1f5","path":"Sources/Sentry/include/NSMutableDictionary+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98902ebd6036a41aaacac357ee5ccce1b9","path":"Sources/Sentry/NSMutableDictionary+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9885b82b46b9dd005b768f8ef3c751040f","path":"Sources/Swift/Extensions/NumberExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a716e75ccb528744d3ffde348c74054d","path":"Sources/Sentry/include/HybridPublic/PrivateSentrySDKOnly.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9810ac796ebca563e59fec2d96f029829c","path":"Sources/Sentry/PrivateSentrySDKOnly.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ed4d648fc835701dd40045ac4f0eb42","path":"Sources/Sentry/include/HybridPublic/PrivatesHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9825d3d80b5d5f47d8c3577be50d208f17","path":"Sources/Sentry/Public/Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881e78b26fce5be69601e8f39debf1815","path":"Sources/Sentry/include/SentryANRTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988933b4bc3332ec51f502704c510ed48f","path":"Sources/Sentry/SentryANRTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c4aec8c696347d8a5fd3da2c9ae3b34b","path":"Sources/Sentry/include/SentryANRTrackerV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98143f06533f29ffe1f4e62e249ee7c61a","path":"Sources/Sentry/SentryANRTrackerV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805b65a9fd5a17f8804adb0010a2bb994","path":"Sources/Swift/Integrations/ANR/SentryANRTrackerV2Delegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a9377495765c6b05f4938e987beaecc4","path":"Sources/Sentry/include/SentryANRTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803a9b0b910a8e69ce5d48707b2f7b0ff","path":"Sources/Sentry/SentryANRTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853bb97ef1af68a2e8a6cd65845220a21","path":"Sources/Sentry/include/SentryANRTrackingIntegrationV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f85156689aeaf18e00084edae9890433","path":"Sources/Sentry/SentryANRTrackingIntegrationV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f3eaf81b6a498c005395bd74ff9b40c","path":"Sources/Sentry/include/HybridPublic/SentryAppStartMeasurement.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98573cf1fba6ec7158ac9e5d30872d3358","path":"Sources/Sentry/SentryAppStartMeasurement.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da6d3a4aa155bc2b99c1c5776fa1d7ab","path":"Sources/Sentry/include/SentryAppStartTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9858a59bcb56abe22c97af586957612f8a","path":"Sources/Sentry/SentryAppStartTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897bfe385980cf04d6052b9a9a48626bd","path":"Sources/Sentry/include/SentryAppStartTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c7e16723e7d6c5950d220f08a0f3a1b","path":"Sources/Sentry/SentryAppStartTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98711c5fa9ee620eb44232227aea5b4980","path":"Sources/Sentry/include/SentryAppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e102ca54129581ea6ac1fd19ca2f900d","path":"Sources/Sentry/SentryAppState.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4aff5850320b2892171d2f9050e54f0","path":"Sources/Sentry/include/SentryAppStateManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98945bba539ff28910dddfef800d776212","path":"Sources/Sentry/SentryAppStateManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7abc40702780507f67178f200c91e33","path":"Sources/Sentry/include/SentryAsynchronousOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f2c93c0f5d5274544f463c3dd147b69e","path":"Sources/Sentry/SentryAsynchronousOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9848c4b2580de96341fc0bfc5e6780e93a","path":"Sources/Sentry/SentryAsyncSafeLog.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98503f56b704c70eeeaa87f393ca606f46","path":"Sources/Sentry/SentryAsyncSafeLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839b12ad72defb9e70cd9c490eab0f56b","path":"Sources/Sentry/Public/SentryAttachment.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817625a85b70840a0b0718c986516528e","path":"Sources/Sentry/SentryAttachment.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98999febd8a8da745b5ea4843c406440a6","path":"Sources/Sentry/include/SentryAttachment+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980c19c6b2f6ae9fa9cbfb42827276a528","path":"Sources/Sentry/include/SentryAutoBreadcrumbTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982375cf366eb9fa65b8bba986be274ede","path":"Sources/Sentry/SentryAutoBreadcrumbTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e0e69f1089a15308ed825159b9e98f7","path":"Sources/Sentry/include/SentryAutoSessionTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4037e5e9e7feba04419a743f2be779b","path":"Sources/Sentry/SentryAutoSessionTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981de7f79c30c8772c5fa58d8ea6d1b1b6","path":"Sources/Sentry/SentryBacktrace.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9891ba718e9d3cff3ab8d9bfee8bd0462b","path":"Sources/Sentry/include/SentryBacktrace.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f61aa00b8a1516208dca7f64cd6a75c","path":"Sources/Sentry/Public/SentryBaggage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20f7baef1201383ed42eb1c42b1ef2a","path":"Sources/Sentry/SentryBaggage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983e4f391d969af2e5ce4e2e2dd4a41dfb","path":"Sources/Swift/Helper/SentryBaggageSerialization.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985a62059eff00244af5c87c6ddd480877","path":"Sources/Sentry/include/SentryBaseIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a9f6073313425fbba5130148b9b3a65","path":"Sources/Sentry/SentryBaseIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccba6be3c8cd08597e295bd9856dd7e3","path":"Sources/Sentry/include/HybridPublic/SentryBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98963cbdfa2428d743619bf4f2c4cdf206","path":"Sources/Sentry/SentryBinaryImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98963224f46996165eb8c62129394cfe81","path":"Sources/Sentry/Public/SentryBreadcrumb.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e6b4f1738c9e7b78c11959faf476bb67","path":"Sources/Sentry/SentryBreadcrumb.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98647433b4e086798b973e947311127f7c","path":"Sources/Sentry/include/HybridPublic/SentryBreadcrumb+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b337f47392c6e73cb95920efdda0b675","path":"Sources/Sentry/include/SentryBreadcrumbDelegate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd297ac79b8c53f88805194c653eb324","path":"Sources/Sentry/include/SentryBreadcrumbTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98917b84e096fb7a3cee094b304c0d1f1e","path":"Sources/Sentry/SentryBreadcrumbTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987869ca5f51cd67f10fb06cf6d2a072e1","path":"Sources/Sentry/include/SentryBuildAppStartSpans.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b16fdba02a928575106b9d1ac1686733","path":"Sources/Sentry/SentryBuildAppStartSpans.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899a99bf57fcbf0c333b505bdb2e414cc","path":"Sources/Sentry/include/SentryByteCountFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e29dfdbdcbf120e148ffa7d4a4c248a4","path":"Sources/Sentry/SentryByteCountFormatter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98be06b9e44be8b3f68cdabea1eab410fb","path":"Sources/Sentry/Public/SentryClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f87b3406b001d130316dbc8ada38a00","path":"Sources/Sentry/SentryClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b01f905126992881fc2dce8d8608866","path":"Sources/Sentry/include/SentryClient+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f499c7f3f52d80336b1b533a1394782","path":"Sources/Sentry/include/SentryClientReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4dc6d699fd7d93e90a761eda46c4f64","path":"Sources/Sentry/SentryClientReport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830ba7417bb270438177deb73683c5536","path":"Sources/Sentry/include/SentryCompiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985f16ffcf4dfbc88b10ce30fd3e633708","path":"Sources/Sentry/include/SentryConcurrentRateLimitsDictionary.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98869ae30e47af8e56237caf99320cf284","path":"Sources/Sentry/SentryConcurrentRateLimitsDictionary.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f66f182cfe191dbc0994d7db36647922","path":"Sources/Sentry/include/SentryContinuousProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98fe03e1800d75ed025acc7c9f643b8f04","path":"Sources/Sentry/Profiling/SentryContinuousProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9860162861b3210d454479d7e1ac71d658","path":"Sources/Sentry/include/SentryCoreDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9828efb6a05eb4ef1519bb559204168266","path":"Sources/Sentry/SentryCoreDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9813f1af6eada9adbb72a6632c8d2b2829","path":"Sources/Sentry/include/SentryCoreDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d7ced294ae24069ba05792c403f21","path":"Sources/Sentry/SentryCoreDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98373aa59d4a768c9ebed68430e50f9fd4","path":"Sources/Sentry/include/SentryCoreDataTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d656120ee1d06ad362116f5cd2c4da69","path":"Sources/Sentry/SentryCoreDataTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98971753585da08da03da7583249217f27","path":"Sources/Sentry/include/SentryCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e71ce65934ced440e8087b210d26e9dd","path":"Sources/SentryCrash/Recording/SentryCrash.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ab6f5523c4427b61de387291c7a269","path":"Sources/SentryCrash/Recording/SentryCrash.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983e0adaf7be093e8acf73fd7ca3cd8020","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986c59b016150adc6042a09462cf5f2949","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba2cf898e20c56ff809039433c33cfd0","path":"Sources/Sentry/include/SentryCrashBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f6e693d7a2699f92a735b00a3b748ec","path":"Sources/SentryCrash/Recording/SentryCrashC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988880cf5ad48383745a739a9820534574","path":"Sources/SentryCrash/Recording/SentryCrashC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c394d73a0449af8d0295e4f249392e4","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984943986cd47c7fbf04e945d0f7ce23dc","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985af7183f551d475ec21df7099e52ba19","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9819e75d68bf8cdd116d83607aa0c87f45","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b934d46e8ace1a43b18243a1180d46","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e986e6252e39b90f3ce76d83e5d6c3974b2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a418c37e4b8f0cae47ee4e0ef8a63b52","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9854f64306da17dca29bd7ff5a1c475bbe","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_32.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9858b9351f994445a2cced9788b61b0857","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9815c5e7deb3496c383b03ad2cb8c63c54","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98920a4695f60aba5f7a2d55e19953a7c3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982e916154b2f4a8ee629c521943af2be5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa25002e2a2a304eb735465f026645f3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862a562ca672b78057b80f0d6b1381746","path":"Sources/Sentry/include/SentryCrashDefaultBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986d3ae0f245877d1a34a61568db103431","path":"Sources/Sentry/SentryCrashDefaultBinaryImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f539a8dd7e053b0be13bac966b57726","path":"Sources/Sentry/include/SentryCrashDefaultMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98834119e584475e9bbe8dde34e6124eed","path":"Sources/Sentry/SentryCrashDefaultMachineContextWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987c323ed9eabfe9ca185b337c9308d491","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e86a5d681f8907d2cfc0b4615cae7aa6","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98872202ab332a20c50d416271f9d2372a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba5fbfcc911db7d4ae22629456fd9144","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98328deee97d6c62d2e5b511634b98f69a","path":"Sources/Sentry/Public/SentryCrashExceptionApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ced46e42add5fe763f983e5bb41852eb","path":"Sources/Sentry/SentryCrashExceptionApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9851df5069bd644d351f378d5a24e99531","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee672a6996837477d96028b68c1168d1","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9884856fe388c064570227a322157015ee","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895df3868e47b3489c27cba402f91a418","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eafd27653478c04bf42d5a09c9475684","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98785810b1ca7d160b372c70ed55188bdb","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98789ed62e82862651822eb9e968b4183b","path":"Sources/SentryCrash/Installations/SentryCrashInstallation+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b19143bc7df2526be6323a84f13d0cd","path":"Sources/Sentry/include/SentryCrashInstallationReporter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9843bd4e1a8c710f4388f1611823aa8703","path":"Sources/Sentry/SentryCrashInstallationReporter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa237d860d80a5137ce637c70f1d45ae","path":"Sources/Sentry/include/SentryCrashIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f9135a1bacbd9b7d700d830329cfa18","path":"Sources/Sentry/SentryCrashIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed40eb6a8415d9209bc656e3e9003d20","path":"Sources/Sentry/include/SentryCrashIsAppImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983ace9e6326466c6006d7903e835f2298","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984db0ba7ef8f9c62a4768e1244e0175a5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980949e62d558d1222c68ac80103de072a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fb551adfbd11861983f13d4ea4ac5672","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebe01a592e2d14b2852a1fb5fbd2aec6","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e63f4dd5624ba45cbd9550973bc7a64b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ac0ec3dc837141d75865164b98357241","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d7b304e56d6074933295f9cbe624568","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5dcb1a00343dc6a7015edc3f087c60a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a37d86794ca14855675168cd3383ad","path":"Sources/Sentry/include/SentryCrashMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982bc91af71f89cf0a1a1788830d8adeba","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bad5c89792bfd17e382bb695ad8ed3f7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cb3e637b4b9c0fd20f7ea80210d8b3c8","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6864c5d37412b769927b5d5c74fe602","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c4aa5985300ab845f08441631c136e0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986db33830a858c9794d31e24b7b5cdb20","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98928789958ea08294471edb78122163bf","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d88503a787c4bef8d3f64a16f8ac7431","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985631641a5bf876e0a4464325cd89f9f0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f9cccecbb59fac96ef00765633935089","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f66e36982c6f19b8196b5549c1151","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9854c3e488a79f2baa48bf1bde77c46cd4","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0ee9ce543fb5029fa8d77a33a2a927","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ef5045f7ebe869b4e0ded07a2e0b854f","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d521257b90b2beb37d3cbeb2b852f91","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989b790d6fb8d01f7933ddbca6b6c2e119","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b15723d60c434030589a9c72cc466027","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e984023b76db5ad50719b293030bedfa158","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e0d4db171ade52ab9810b83e9e46027a","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9858f76db325762543559f151e9cde7092","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98036351eb99bc61418fa7904beb886a4b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ec8643356b925433575de2c29645e403","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d43045f94dfb5f8084c33ff0795a4fb3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ff24760fdf3ff3241a74acaab8abd66","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjCApple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e52be506e681f88f6f6ca68d2f91cc4e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashPlatformSpecificDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebb3d6360d98f69e909523c8039f5805","path":"Sources/SentryCrash/Recording/SentryCrashReport.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9882eca2c2727b3da70dc9991523763b33","path":"Sources/SentryCrash/Recording/SentryCrashReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9857e1affb989c53e044956e4452fdbcea","path":"Sources/Sentry/include/SentryCrashReportConverter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982deeb46c4817dcdcd9ac3ff4d08481d4","path":"Sources/Sentry/SentryCrashReportConverter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982aad48539d3cdacf06eba4d8e46a2584","path":"Sources/SentryCrash/Recording/SentryCrashReportFields.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981e9d85b65beaeba8501a1c62b8febe5b","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980d8271e53cbfcf85f6cd065e708f2de2","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840d849ddebfb811a2974576cbaeecf80","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a9d5de5d3dfe92644037d71ee5190757","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f69b87a8cdb3abcbac109f20e35935f7","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9875fc97b0b53f66ab6b047f69319bae1f","path":"Sources/Sentry/include/SentryCrashReportSink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f0c689d1974688e39dbfb4912fa84a98","path":"Sources/Sentry/SentryCrashReportSink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9896c2e55b5af11c135ee2e941bdd0d7f7","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9837c90d38984770cab39b73a4ada41ded","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f92815bb8d5e910d80c83c261ef2665a","path":"Sources/SentryCrash/Recording/SentryCrashReportVersion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b8d6627048f9dc97cf8b1ea4105f91b","path":"Sources/SentryCrash/Recording/SentryCrashReportWriter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d078af0694b1dfb7d9ccde999b44e4fb","path":"Sources/Sentry/include/SentryCrashScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98078b4b6983c2612f332996e0802c4c78","path":"Sources/Sentry/SentryCrashScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0f9f2e8fb1eb0f1596f10d50e88f2f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983af198c0f2730501f9ce1047fe9febf5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f50e0ec35d87b0e7665d862c74ad244","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ab8a94243d149d379bbd54e70a367d2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e989255de6ce3a4e6431e337169ea12941c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98227f0ab7c55a7b2a8c57667847371c5c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981ded049e9550dae6bfea340e4044795f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985481f9043d0f6eec88a8931509d27ca7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98172b9a0d48d2f67d576708e2c15c5681","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980331a56274b42b435802d99adc0f0030","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98616e193ad92d875887b6acd5f702b997","path":"Sources/Sentry/include/SentryCrashStackEntryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fcbe81bbd35a2c2ad89d2e20c97dcc80","path":"Sources/Sentry/SentryCrashStackEntryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cac8c3e4cc76215e854ea8e9fd0b4fbd","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982bc1b6754e56222800d995cd75f8c2bb","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98e542917dc6ed22f0dc8928e137282679","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9842aa558befabfc401dc6c66d86529e41","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9805e8aec9ac44b4f557bd1c2c0ed33d7e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d038ee2ee6d13d961dcc8f8cef0e8f9","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9814ac97981888d650562ae1699fbed050","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983761e96361f55650fcff3ec415c306c0","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cf8824f371b9de403b95fafde5e63a58","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9824542f73d2e98c60530e9c276356c21d","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f8bc45a2584bffecf7c9393d70e3ac1b","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryCrashVarArgs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d8def6fa7b0dff52e0288684681cd235","path":"Sources/Sentry/include/SentryCrashWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98844dfbd5d2a8a41ef38196b8c228710e","path":"Sources/Sentry/SentryCrashWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98beaf5cf161791d03e65fca4463133522","path":"Sources/Swift/Helper/SentryCurrentDateProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820c8946792aadcff4c6b7911cd3509b1","path":"Sources/Sentry/include/SentryDataCategory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897ecba504c62f71a990fd084fdd0aa08","path":"Sources/Sentry/include/SentryDataCategoryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98993a135e0861037578f65e78f6f19fa5","path":"Sources/Sentry/SentryDataCategoryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fb2c2fb737cef61e3aa32bc173200734","path":"Sources/Sentry/include/SentryDateUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980da3c3a8a5a8e75a2b495f25b864ffff","path":"Sources/Sentry/SentryDateUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982cfb6141f563ef3b224e37c23dfd6ee7","path":"Sources/Sentry/include/SentryDateUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981e501be4e9e4ce930febba0a67b92f83","path":"Sources/Sentry/SentryDateUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986de643aa7e4d68dd257a682eb4ecf094","path":"Sources/Sentry/Public/SentryDebugImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988714d874b4c7d300f8820a9568aac3b9","path":"Sources/Sentry/SentryDebugImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd67327f2cef046d47a060a50e2b033","path":"Sources/Sentry/Public/SentryDebugMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d62e6ab2a2d7467600f6feb7e2671a52","path":"Sources/Sentry/SentryDebugMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98057cb7e081eab9c13bc45a99e2046c64","path":"Sources/Sentry/include/SentryDefaultObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98755befdde596ed98608c0f49e465f2e9","path":"Sources/Sentry/SentryDefaultObjCRuntimeWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fb8b09d68a8675e669ccaa2b3d70f8b","path":"Sources/Sentry/include/SentryDefaultRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98284e598b5703764339c9c4dd2d39c047","path":"Sources/Sentry/SentryDefaultRateLimits.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b66c1f8b8fe6afdb3bd5083ac798dd","path":"Sources/Sentry/Public/SentryDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b9ddd57b7d448702427527f15078729","path":"Sources/Sentry/include/SentryDelayedFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b80297ad046a9e2ee2fc01b38f886866","path":"Sources/Sentry/SentryDelayedFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c933c980d9fd2d6b18ab7660be826a32","path":"Sources/Sentry/include/SentryDelayedFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9883f0c58fdda69214b888d7a89386438d","path":"Sources/Sentry/SentryDelayedFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e3fca574186ef9a3fbf60c7936614dd","path":"Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e075ecfc8e39865eebcf99287896107","path":"Sources/Sentry/SentryDependencyContainer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ab472310b921cb357b3403f16d6bcc91","path":"Sources/Sentry/include/SentryDevice.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ce8faf244cb4f73da113703dbb0fd493","path":"Sources/Sentry/SentryDevice.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f496c08b830fbf3baf100455494f579e","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed54e2f0091c3b6700b4882495308ca7","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c50f7a6b19aff7f4ca9db7611b185ffc","path":"Sources/Sentry/include/SentryDiscardedEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b532fff8e4b1e30be209e64ad5f2328","path":"Sources/Sentry/SentryDiscardedEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c9e6c345c44f79bfb78251b2dbb8c7b","path":"Sources/Sentry/include/SentryDiscardReason.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c183e6d53e8117f062c3f4b7cd156530","path":"Sources/Sentry/include/SentryDiscardReasonMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8a3f333ed0b95751e7fbf78d7c98b9","path":"Sources/Sentry/SentryDiscardReasonMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bba374cd8c0dc6a0b88cd9da50d55613","path":"Sources/Sentry/include/SentryDispatchFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b61addc447754e7e5ec64e70a8a8d043","path":"Sources/Sentry/SentryDispatchFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98181ef37baebf7620fdb3ed512d9c5b2c","path":"Sources/Sentry/include/SentryDispatchQueueWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fdfd3d660615791f5b19cb7a270b3d33","path":"Sources/Sentry/SentryDispatchQueueWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98edef8192fe71dc5c3aa5e7605d8bc231","path":"Sources/Sentry/include/SentryDispatchSourceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad912bcb51aa320ac7d9ae0d6b1c29d0","path":"Sources/Sentry/SentryDispatchSourceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d9e7a648cec7e7fc0ad3cf75672ea68","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cebef9f2b8583ad43ec280bc3f601765","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eb4e19f7af34fa2f92906307734634ab","path":"Sources/Sentry/Public/SentryDsn.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc58b953a2bf19f0ba887c35a848c43d","path":"Sources/Sentry/SentryDsn.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98629100bfbc75470514774b17e16c6ed2","path":"Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f5d9dabdb733a787122fe8ba4ca78980","path":"Sources/Sentry/include/HybridPublic/SentryEnvelope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ef35f84679b5286380254cf3782d3fc","path":"Sources/Sentry/SentryEnvelope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982792cab6ec953388de02a2ff6935ac57","path":"Sources/Sentry/include/SentryEnvelope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cedb8fc664cf2a66556a10e0939328","path":"Sources/Sentry/include/SentryEnvelopeAttachmentHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e289e99946da7963f37deadbe959fdd","path":"Sources/Sentry/SentryEnvelopeAttachmentHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98141370ca334e2965d382e9f5cee9eb40","path":"Sources/Sentry/Public/SentryEnvelopeItemHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987942c3b2f4ab61de1e45f7316786b84c","path":"Sources/Sentry/SentryEnvelopeItemHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98781a30887bd009417ad1a4112c2fb3fe","path":"Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895aa5c265104e54b26110f7557073d7b","path":"Sources/Sentry/include/SentryEnvelopeRateLimit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d5a56c7cb96529d8791d765d11773537","path":"Sources/Sentry/SentryEnvelopeRateLimit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bafc78f7024b690ecf9471384a9902b4","path":"Sources/Sentry/Public/SentryError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ca1115e3b61c2229c1c80bd36f06de1b","path":"Sources/Sentry/SentryError.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98413720c3decab33490a7c5172007d190","path":"Sources/Sentry/Public/SentryEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983743f3d27289e139c0b954aaa4e0628a","path":"Sources/Sentry/SentryEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8f47a674006a0bd05b6739dc62b4f4f","path":"Sources/Sentry/include/SentryEvent+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987159465a79e7a721e0255b49a250409e","path":"Sources/Sentry/Public/SentryException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9895825e81ff4e9350316aa59835550564","path":"Sources/Sentry/SentryException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9862683b73d213711a5794050fbedb940e","path":"Sources/Swift/SentryExperimentalOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a207c835059cd1e43b5179b933097323","path":"Sources/Sentry/SentryExtraContextProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e15159df27cf33be5cf5b699238e167f","path":"Sources/Sentry/SentryExtraContextProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e61558513e2bb03c67587242c1ae2320","path":"Sources/Swift/Helper/SentryFileContents.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891743b33ff8051d960b0188178ff8d18","path":"Sources/Sentry/include/SentryFileIOTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9811f125cb4ea42b0ee92dca25f4b88671","path":"Sources/Sentry/SentryFileIOTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2c1ff1a63d933d631b4428ce2e38740","path":"Sources/Sentry/include/SentryFileManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dc757a9b6a393b594b0e7f493805852","path":"Sources/Sentry/SentryFileManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2ef2bd076cd4dd059f7e298bf852014","path":"Sources/Sentry/include/HybridPublic/SentryFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852b362bca7cc0c9e4f0ac4357b8926c2","path":"Sources/Sentry/Public/SentryFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cb0c8f2e66fc06982ca30a2cf3eb9bfc","path":"Sources/Sentry/SentryFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a64e72c9b8c3c97fd22013edcdae5ca","path":"Sources/Sentry/include/SentryFrameRemover.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887e6e078e6301374b2fef621090a7c44","path":"Sources/Sentry/SentryFrameRemover.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98dba5b9e2f053ff10581a7a52a1336881","path":"Sources/Swift/Integrations/FramesTracking/SentryFramesDelayResult.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980aa82f1dc751a60b99dfdc678c1d417c","path":"Sources/Sentry/include/HybridPublic/SentryFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9851e95210c8e69b336ec75546cb826a45","path":"Sources/Sentry/SentryFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98efaa72ef40eaa86dff924769099fe63f","path":"Sources/Sentry/include/SentryFramesTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98732b4c467888ee6e063ecfd31db00fc1","path":"Sources/Sentry/SentryFramesTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891ed9d2029d00cffa36a1f5fc06398a1","path":"Sources/Sentry/Public/SentryGeo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988d6d449e12ef4dade75902629ee9c5e5","path":"Sources/Sentry/SentryGeo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca8e4cce263f1f47c0f162e4c586c4f8","path":"Sources/Sentry/include/SentryGlobalEventProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed988e9fdf3783f895274a6a759ea0e0","path":"Sources/Sentry/SentryGlobalEventProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e3e378fc6f7cc204b68b3caa7236f27","path":"Sources/Sentry/include/SentryHttpDateParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a9f385e05164f46553c2ee2cf481b7d4","path":"Sources/Sentry/SentryHttpDateParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c3bfc35223f1b5b6943049d2fb6c4389","path":"Sources/Sentry/Public/SentryHttpStatusCodeRange.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98768627ada7e32a0631bc8af27cb1d1ff","path":"Sources/Sentry/SentryHttpStatusCodeRange.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cca6fefe9cb49705f068665278f8ac02","path":"Sources/Sentry/include/SentryHttpStatusCodeRange+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989de0a39761e8a1050f2257cbe8713a04","path":"Sources/Sentry/include/SentryHttpTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ee2ceb1a7cef57f920d5af9053a5d61","path":"Sources/Sentry/SentryHttpTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98836f5531819312d8b361c85d8e8b18a4","path":"Sources/Sentry/Public/SentryHub.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98252ac7b4e2a6feb7cd60fdd1a4f09dc4","path":"Sources/Sentry/SentryHub.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98638a129b104a232f59a5551902b1f3f4","path":"Sources/Sentry/include/SentryHub+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98118a67a7239494acc31abf04261df084","path":"Sources/Swift/Protocol/SentryId.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98070046bf0c41b80e5921e2353abd1714","path":"Sources/Sentry/include/SentryInAppLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c06ead7e0dbabb3e7c310df0e456432b","path":"Sources/Sentry/SentryInAppLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e38ea514f750e6ef90ab207cb16028c2","path":"Sources/Sentry/include/SentryInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981b188529a60da3e88d8e87a3cef822ea","path":"Sources/Sentry/SentryInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f1ecb074aa05d438550e8283fcf8840e","path":"Sources/Swift/Protocol/SentryIntegrationProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880707aac6a0b662d864e53db8ec3e873","path":"Sources/Sentry/include/SentryInternalCDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b48ba94d494b08c87d61b93a678e3dd2","path":"Sources/Sentry/include/SentryInternalDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827d2c767a3050b20c39203308cb87f8d","path":"Sources/Sentry/include/SentryInternalNotificationNames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c7220f6bfe0eeb615b9a7accf8b533","path":"Sources/Sentry/include/SentryInternalSerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98def9cc150a565d2550979ec5b69998b1","path":"Sources/Sentry/include/SentryLaunchProfiling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98911fdc6061a2135c748153f2e4a05aa3","path":"Sources/Sentry/Profiling/SentryLaunchProfiling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989df9dc056cea8fcf0f59af8f85cd8282","path":"Sources/Swift/Helper/Log/SentryLevel.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8f81c7f1106715ce45bc09d968a4cb","path":"Sources/Sentry/include/SentryLevelHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9827103d0e236a2917fac90b75c47f4fec","path":"Sources/Sentry/SentryLevelHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98844dd3ba156013068dbf7f35651a5957","path":"Sources/Sentry/include/SentryLevelMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986b5d8de5042f9b6c6debab299d006332","path":"Sources/Sentry/SentryLevelMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d673b72b0119aacb08d60c172d07c94","path":"Sources/Sentry/include/SentryLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828d4fd172c6650cc894dc077f2713654","path":"Sources/Swift/Tools/SentryLog.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9238e73552f603c4ec002a1af34b21c","path":"Sources/Sentry/include/SentryLogC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980bbcb909a9d03a552f1277782e18761a","path":"Sources/Sentry/SentryLogC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986b40c6c48b55db5046d6db511942fa38","path":"Sources/Swift/Tools/SentryLogOutput.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e9849d90f9a6a3c8d0dad49a3899b83cec1","path":"Sources/Sentry/SentryMachLogging.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9800ab9a3ecaf9fb561465a8384a1a4232","path":"Sources/Sentry/include/SentryMachLogging.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d535deadc12989119b0615b9dd28f36","path":"Sources/Sentry/Public/SentryMeasurementUnit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d114189b8a76ae18f56bcfc4aaa7ea75","path":"Sources/Sentry/SentryMeasurementUnit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ca01cfbb872cb4259cd1e35227961de","path":"Sources/Sentry/include/SentryMeasurementValue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987666c44d8e4bd63cee7a88e33191a7e6","path":"Sources/Sentry/SentryMeasurementValue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851202d61adfe91c437af0f1c67e03d04","path":"Sources/Sentry/Public/SentryMechanism.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e451424b1cdbc8b40cd309ecd4bbbf52","path":"Sources/Sentry/SentryMechanism.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bab24acd4702a28a3c4c9063e4ef0dce","path":"Sources/Sentry/Public/SentryMechanismMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982968cc739e708fdcb3738f171b8efd43","path":"Sources/Sentry/SentryMechanismMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830744347f699402adbc18f50caf4e2d7","path":"Sources/Sentry/Public/SentryMessage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981082286186bf8efb07f5f2ce82e71bfc","path":"Sources/Sentry/SentryMessage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899ce478e31a9826614e13502b4400b26","path":"Sources/Sentry/include/SentryMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d00aea86836a212dca15301d0f6be80","path":"Sources/Sentry/SentryMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ead309d87cf3d8ca9c2bc918c7485105","path":"Sources/Sentry/include/SentryMetricKitIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982f522d156c7a74cefb6fc138d7ed9eea","path":"Sources/Sentry/SentryMetricKitIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cd2cb4adcd0f896a6a9aa250d475f99","path":"Sources/Sentry/include/SentryMetricProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9869f27a6b12c3ecdb44b5cf7f02588341","path":"Sources/Sentry/SentryMetricProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986ce9c2df801c62045926d8864e84a2a1","path":"Sources/Swift/Metrics/SentryMetricsAPI.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981e970cc41f9443b9aec9727b6001ea4c","path":"Sources/Swift/Metrics/SentryMetricsClient.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f7d0a3333cb2dcfcbe9c5afd9ec20f5","path":"Sources/Sentry/include/SentryMigrateSessionInit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0abc314e5dda9b6db74ec0670b9817b","path":"Sources/Sentry/SentryMigrateSessionInit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f295a6e7dc5aee192e374504ed7fe03","path":"Sources/Sentry/include/SentryMsgPackSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9886c992067744c43e9998edd72ad5e859","path":"Sources/Sentry/SentryMsgPackSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98571b885c8f8846fcc4fd4db493b7816d","path":"Sources/Swift/MetricKit/SentryMXCallStackTree.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987aff5927f8aad996cb87fb760614dbfd","path":"Sources/Swift/MetricKit/SentryMXManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6c11cbc922ee1e497e67191309e6292","path":"Sources/Sentry/include/SentryNetworkTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845b3a6e7f24267ede88073dada9a10f1","path":"Sources/Sentry/SentryNetworkTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98940d6066dd7b5b655ab91100255b0082","path":"Sources/Sentry/include/SentryNetworkTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7ed27e60fec75e818894a08259699a4","path":"Sources/Sentry/SentryNetworkTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983581727c987fa4cca0954306818b12b7","path":"Sources/Sentry/include/SentryNoOpSpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805654afa1f82958dd293c561ac9ab271","path":"Sources/Sentry/SentryNoOpSpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9833e522abc1d5b99c6068adbf830967c2","path":"Sources/Sentry/include/SentryNSDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9833178f1b00328efc29f49ca21f6fa919","path":"Sources/Sentry/SentryNSDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a32f1dc5a94f948c465e56c4010cf7e2","path":"Sources/Sentry/include/SentryNSDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d57188a0ae1e5a026a97e63d1e37dd4","path":"Sources/Sentry/SentryNSDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e9ef0b68a6618bff633d8257bbe4807","path":"Sources/Sentry/include/SentryNSDataUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9846770a46bcf2b781d57ce3c925c8d341","path":"Sources/Sentry/SentryNSDataUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b65e1d892bcf2bcb3c5f20ec5972415","path":"Sources/Sentry/include/SentryNSDictionarySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8f20ef0729cf34cac9cba109e20051","path":"Sources/Sentry/SentryNSDictionarySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983337f11aeab3a289f5c8c19c3908b921","path":"Sources/Sentry/Public/SentryNSError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984e9914a4b4293c692a90ba05b97965b5","path":"Sources/Sentry/SentryNSError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880ca6abb4fff5d7995dc2123497283ad","path":"Sources/Sentry/include/SentryNSNotificationCenterWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986f2b065fb1bca703459a4977185b8d05","path":"Sources/Sentry/SentryNSNotificationCenterWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98663c96b18dbadd2be6f08f5b8ea31c5e","path":"Sources/Sentry/include/SentryNSProcessInfoWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9894ae4b1a56d92b5ab6ed329af307db93","path":"Sources/Sentry/SentryNSProcessInfoWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878f37430fba46c9dd937dbfac8cf691c","path":"Sources/Sentry/include/SentryNSTimerFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98692460fbec7a54719df44466b20bcd96","path":"Sources/Sentry/SentryNSTimerFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98680d2dc7ef64f30c303c18b4117568fc","path":"Sources/Sentry/include/SentryNSURLRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e288c4e7a426b08b04b780e40e1f743f","path":"Sources/Sentry/SentryNSURLRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c1a99b0cf864330bb01057e2bffc60fc","path":"Sources/Sentry/include/SentryNSURLRequestBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4217e23e64d40642d3a033d561f8d35","path":"Sources/Sentry/SentryNSURLRequestBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989354f19ee7a39a29a3b6520194111f66","path":"Sources/Sentry/include/SentryNSURLSessionTaskSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985cf2ec8edccb99946f64c83878c3c803","path":"Sources/Sentry/SentryNSURLSessionTaskSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98537f34ec2613ecc57890ea95d1c7af35","path":"Sources/Sentry/include/SentryObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987bcb2b2868ee46b3d212b5e9a35106d4","path":"Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989e50a47e4c378c019942525106400715","path":"Sources/Sentry/Public/SentryOptions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98063d68f2c40f62f6810d0ed406bb382e","path":"Sources/Sentry/SentryOptions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eeec8be2c9ebaa5285c5472c3b36bf2e","path":"Sources/Sentry/include/HybridPublic/SentryOptions+HybridSDKs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984bba6bc40fc60e061c7795ed9d14710d","path":"Sources/Sentry/include/SentryOptions+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984efbed9b76890e16efc0580a6c7f5776","path":"Sources/Sentry/include/SentryPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a15e09aff4d797481eb78d448d68934","path":"Sources/Sentry/SentryPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981abbc71e31b4628810dbd6cd1779a6ab","path":"Sources/Sentry/include/SentryPerformanceTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4b7a5dd39a2fec7b7ddc0ee48570ad5","path":"Sources/Sentry/SentryPerformanceTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b84a4cbbf82db3ee0783089fc6f3bba","path":"Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9855c159eb37097297bb381315fef4af83","path":"Sources/Sentry/include/SentryPredicateDescriptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4338a3e8437f3b1d1acce2c48ddc4fb","path":"Sources/Sentry/SentryPredicateDescriptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb932407441bd3d3ca9e06a6841edf04","path":"Sources/Sentry/include/SentryPrivate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d56cb9cee92ba68a856453c30f353d","path":"Sources/Sentry/include/SentryProfiledTracerConcurrency.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e987291ecb593ccdba0e5fc6a98cbf5fdae","path":"Sources/Sentry/Profiling/SentryProfiledTracerConcurrency.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989733858144eb7e903d25bb39b2aa1d69","path":"Sources/Sentry/SentryProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98023f426c062e3f41cac6b9ff2674eb7c","path":"Sources/Sentry/include/SentryProfiler+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981591d1e000f7e36a693757459cc651de","path":"Sources/Sentry/Profiling/SentryProfilerDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9834c19d0343e8b716ae4009d27cd5343f","path":"Sources/Sentry/include/SentryProfilerSerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e9464d6a322ac39a174f4614fab171f4","path":"Sources/Sentry/Profiling/SentryProfilerSerialization.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d6c0901f1b81ea1582095356892c849","path":"Sources/Sentry/Profiling/SentryProfilerSerialization+Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849170326f9e7324f758fad5c8fda7c26","path":"Sources/Sentry/include/SentryProfilerState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e988624d29c0763de3bcc218c7c4acf5c0e","path":"Sources/Sentry/Profiling/SentryProfilerState.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c97c721f87e21f23b35f49ab3480ddd9","path":"Sources/Sentry/include/SentryProfilerState+ObjCpp.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803b8fbdce4e2ed80e49b511afbfdf7ba","path":"Sources/Sentry/include/SentryProfilerTestHelpers.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9857b858be807d990227d8cc8a467799eb","path":"Sources/Sentry/Profiling/SentryProfilerTestHelpers.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d57d7ff8850a7f68bc5127a2de78e1c6","path":"Sources/Sentry/include/SentryProfileTimeseries.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98cf09f39b101036d9039a5e9fdd5d4910","path":"Sources/Sentry/SentryProfileTimeseries.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980fa47d49e2bb65216878780f3a37d5f1","path":"Sources/Sentry/Public/SentryProfilingConditionals.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dbbe1e35eb18685486a5c8b6577bbbe9","path":"Sources/Sentry/SentryPropagationContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9888a2658ee08672b053eb8cc3642a4e76","path":"Sources/Sentry/SentryPropagationContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989bdf26ad09027d76fb370df8816c6eb9","path":"Sources/Sentry/include/SentryQueueableRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c2d294613ba90e126869f812c1ab6325","path":"Sources/Sentry/SentryQueueableRequestManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983454e6f421a0345f33e633ea310f2406","path":"Sources/Sentry/include/SentryRandom.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980f3118093a755d081ed6850275016cf3","path":"Sources/Sentry/SentryRandom.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a60f227949bd23fe3b07ff7b9601fc22","path":"Sources/Sentry/include/SentryRateLimitParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe5ac5ab93a3da03e0e408b697c6a079","path":"Sources/Sentry/SentryRateLimitParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989d3798a7794bb34165683bfa0463151d","path":"Sources/Sentry/include/SentryRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a01eacf4fd9c5cbbfa18985456424be","path":"Sources/Sentry/include/SentryReachability.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3daca73881c4a4b4874c14922476273","path":"Sources/Sentry/SentryReachability.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b22a5ba1237fd87037d77d3641902363","path":"Sources/Swift/Protocol/SentryRedactOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9842a47bed079d580cd20989b9075294e3","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985726026a1a1c31e50325233986a3b382","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805701d4fccb49684aa1b55a60f18fb3c","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989cc077d015a9f88874f161283b85d1af","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9857bc555ad325fec19f683147a099c7e1","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de5de5142f58314b7d12a2e669c41266","path":"Sources/Sentry/Public/SentryRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871b012a3b5f1462b9d197c7012741e34","path":"Sources/Sentry/SentryRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985df69af6f60d3c7f31989a95e3f3a9e8","path":"Sources/Sentry/include/SentryRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987657341f336ac02acfcf382525f1509c","path":"Sources/Sentry/include/SentryRequestOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d263d257b6329d0a2dfee2e60a97fb5c","path":"Sources/Sentry/SentryRequestOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987942c8ac2f032c0940a6a38ab91bf734","path":"Sources/Sentry/include/SentryRetryAfterHeaderParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ac9e44632d815a3c81252e6452f71035","path":"Sources/Sentry/SentryRetryAfterHeaderParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f50457f417b0f79a0e1b0de834cf9aca","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb2f125ee7e524b3c4b954ba30af5d66","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebCustomEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988d52ddcc81c8bca32b6adcfe81c6b681","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98078862f44140b3e99c42e7d1ab12699b","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebMetaEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9846929a8c54eb68f7ae47aba553065cd4","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fbbaa747f79a3331a947c4a688bc013a","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebTouchEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb215688c9705038d49294cb6576f234","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf48b342ad07ad2abb993872033b47d0","path":"Sources/Sentry/include/SentrySample.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983cab1af79491ca23a25ea7c74d671896","path":"Sources/Sentry/Profiling/SentrySample.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d00b5598fb7c97a8153da8d65037dfa","path":"Sources/Sentry/Public/SentrySampleDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c69e66e55629c2c42ad5d27ff70b570","path":"Sources/Sentry/SentrySampleDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981142043015590766f312d36cdd419e39","path":"Sources/Sentry/include/SentrySampleDecision+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987eb65cfe85a754fabdf367fc17bec2d2","path":"Sources/Sentry/include/SentrySamplerDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9894d865a928ae3975ad2746f118b4468b","path":"Sources/Sentry/SentrySamplerDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ea213eb10687e172d1d3aded3bd41ae7","path":"Sources/Sentry/include/SentrySampling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98662bd827119a1617f1274adce996caf7","path":"Sources/Sentry/SentrySampling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d47f3af45086a117a1cc24b2df9365a","path":"Sources/Sentry/Public/SentrySamplingContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ff077c3356757e30d11f375da1ea1eb","path":"Sources/Sentry/SentrySamplingContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981a82de91e8f2e5e3932cf0c7756ee711","path":"Sources/Sentry/SentrySamplingProfiler.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e985cd5b426c8a926a07fa8d251d5dab624","path":"Sources/Sentry/include/SentrySamplingProfiler.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988185ba6a88b996493cdf38ca379baabb","path":"Sources/Sentry/Public/SentryScope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a51dc90ee182f3f234e31a6c3f115461","path":"Sources/Sentry/SentryScope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98918a5c81a8e0b096accec29a8a6d8bb3","path":"Sources/Sentry/include/SentryScope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e441058a2a5fac727c3f9be0e75a7e8e","path":"Sources/Sentry/include/SentryScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ff6e8cbde607cb070aaf521c95fb961a","path":"Sources/Sentry/SentryScopeSyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881d0f7b202c7e8a98e9936b29655c95d","path":"Sources/Sentry/include/SentryScopeSyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a369f24a3d33c353bfec1e7f64b3ea6","path":"Sources/Sentry/include/HybridPublic/SentryScreenFrames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e46fe301c2548cc68a7e96ce8366b59a","path":"Sources/Sentry/SentryScreenFrames.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d09812498914ddbf97a6798491555ab6","path":"Sources/Sentry/include/SentryScreenshot.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04f95ec1867f216e77231ddd1dda094","path":"Sources/Sentry/SentryScreenshot.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1ff561b93fc4c5e149438256f956387","path":"Sources/Sentry/include/SentryScreenshotIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989e9f3ce88297e823291af52c5ad53475","path":"Sources/Sentry/SentryScreenshotIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9826acbc0b304b2a0ad2003d840178f0ff","path":"Sources/Sentry/Public/SentrySDK.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d69924caebf6884c96f9ce74bc334","path":"Sources/Sentry/SentrySDK.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7d38ced97e72f78e89610670b1b3fb3","path":"Sources/Sentry/include/SentrySDK+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d5ae9898138c992b2021f68ec8babf6","path":"Sources/Sentry/include/SentrySdkInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98faa847346efca28ffffb35376ea3a952","path":"Sources/Sentry/SentrySdkInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf616d9f6cd818fe3348d29c0c916aa2","path":"Sources/Sentry/Public/SentrySerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e1dd28873beb5b2dcb1af5beb95d271b","path":"Sources/Sentry/include/SentrySerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d6f259ec16188737b09287bd283b367","path":"Sources/Sentry/SentrySerialization.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f0fafebc44b49d7ff8414673d3cef1c9","path":"Sources/Sentry/include/SentrySession.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b8b57442bb9cab2603b9a999081af38","path":"Sources/Sentry/SentrySession.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982116f2e7bb2c8e2ac20df990e0f81236","path":"Sources/Sentry/include/SentrySession+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8c985666c5352b6af3c7433239a020","path":"Sources/Sentry/include/SentrySessionCrashedHandler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b12e7dd603ab333ae8345872f96182a8","path":"Sources/Sentry/SentrySessionCrashedHandler.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98928b60c2da7f7825875efa439d557e6b","path":"Sources/Swift/Protocol/SentrySessionListener.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828b59ccaf8ff87aa8c8c271b9b5bf184","path":"Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aed94b002bc11a182b8826fa79b3c5e5","path":"Sources/Sentry/include/SentrySessionReplayIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fa40304186e2c99ee5ccbfc8d92800fc","path":"Sources/Sentry/SentrySessionReplayIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cc66d7b02be2cb3d51c5934a525009a","path":"Sources/Sentry/include/SentrySessionReplayIntegration+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ada9c823043bb19207c45e2346ff5a6a","path":"Sources/Sentry/include/HybridPublic/SentrySessionReplayIntegration-Hybrid.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9894dee7e19fea06bda4dbaaf5c1d9d35b","path":"Sources/Sentry/SentrySessionReplaySyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864e08ba6ae71bf1d1c7b269fd8a8fe0f","path":"Sources/Sentry/include/SentrySessionReplaySyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e28c0aa12b28173b3abf435b1fa19a34","path":"Sources/Sentry/include/SentrySessionTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0c10fbaa1cb5e98abbbef20d36e7b41","path":"Sources/Sentry/SentrySessionTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6bf18b7cbf57a94d6c0d96399d10e5c","path":"Sources/Sentry/include/SentrySpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d09bbbc9887ced326bec2d6d7246dce1","path":"Sources/Sentry/SentrySpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f21c40f0c349752b7f501e01c22fa48b","path":"Sources/Sentry/include/SentrySpan+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ecbbe5eadcd9001b4c2fc21e078f887a","path":"Sources/Sentry/Public/SentrySpanContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980572088b35705ae125400e5eaf1ca5c0","path":"Sources/Sentry/SentrySpanContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c456af0a27c58300581e4f45771983","path":"Sources/Sentry/include/SentrySpanContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c593d7d98c929c6a2b0f7d4d0589a1d4","path":"Sources/Sentry/Public/SentrySpanId.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826f4fe2fca7fe75078c0e9e4e65a2d02","path":"Sources/Sentry/SentrySpanId.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980a3fbb743bd3ac9e28a18b2bd756f025","path":"Sources/Sentry/include/SentrySpanOperations.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9871bde8593bddf6bbbc9f5418fd2e33c4","path":"Sources/Sentry/Public/SentrySpanProtocol.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c897661c68cc4ff7868fa62493291e","path":"Sources/Sentry/Public/SentrySpanStatus.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c18eedf9441788560deccc91341f4ef1","path":"Sources/Sentry/SentrySpanStatus.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98786a47c67a0dc6ec0050a48c5a5e4e3d","path":"Sources/Sentry/include/SentrySpotlightTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98571366b1f9a87519f69133aa94ea1592","path":"Sources/Sentry/SentrySpotlightTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985987b898ae3639d21a5bfb374115d528","path":"Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98a1f775d7fcfd5f7d435fe7abffb91fb3","path":"Sources/Sentry/include/SentryStackBounds.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98ea3cc80c8121c0454193a096db3b0641","path":"Sources/Sentry/include/SentryStackFrame.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ebb06d6eb20ea09e0d5cce625106b6e","path":"Sources/Sentry/Public/SentryStacktrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803eaef84f8cf151765e8f2dacff23bae","path":"Sources/Sentry/SentryStacktrace.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863ffcb1f013b44034ba0bfda59953036","path":"Sources/Sentry/include/SentryStacktraceBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985614a6bc35ea979167d2101aaf82ca72","path":"Sources/Sentry/SentryStacktraceBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e70d13d7772d677d98ce5f06ab6a2bc7","path":"Sources/Sentry/include/SentryStatsdClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988a1606defceb762419fa23b4bb125351","path":"Sources/Sentry/SentryStatsdClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e05ba937b597322372e39fc0358edb5f","path":"Sources/Sentry/include/SentrySubClassFinder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989c1b2a8ac0bcddb420b0941ef640d0eb","path":"Sources/Sentry/SentrySubClassFinder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d4ee0056f4fbde7672fb570961a8583","path":"Sources/Sentry/include/SentrySwift.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bdb04ca4987d86965932ea3417f118b","path":"Sources/Sentry/include/SentrySwiftAsyncIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a09e471bf53c0e8d38c894fb2f23deda","path":"Sources/Sentry/SentrySwiftAsyncIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864929f6d55b4bf658809924b262e5263","path":"Sources/Sentry/include/HybridPublic/SentrySwizzle.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d97ee0c09e9e900424b1603d10be7375","path":"Sources/Sentry/SentrySwizzle.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9877c9f1b3633c0e198960f68aa0c0810f","path":"Sources/Sentry/include/SentrySwizzleWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98711558a1dd411bb478cd26d8f3f745f2","path":"Sources/Sentry/SentrySwizzleWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98993f9b39cd9919f8ec83374ff13b1328","path":"Sources/Sentry/include/SentrySysctl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985deb0edc70afd30a8360f3dc8e856686","path":"Sources/Sentry/SentrySysctl.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ff36fbd967725834366ae2130fb2bed0","path":"Sources/Sentry/include/SentrySystemEventBreadcrumbs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a94a3a2ea869ff4381e8499b0d762c37","path":"Sources/Sentry/SentrySystemEventBreadcrumbs.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988821b58f8dee309405f48b809d77c389","path":"Sources/Sentry/include/SentrySystemWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e7b36496f0059bd09052fe0723a1482e","path":"Sources/Sentry/SentrySystemWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9846f8b71aa0d77e70aad6baf2780ede40","path":"Sources/Sentry/Public/SentryThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98333f641f00d2f2094339ae17ba284755","path":"Sources/Sentry/SentryThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98f983405d6088b8487df98cbf1cf08289","path":"Sources/Sentry/SentryThreadHandle.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9804a7db4ceccb6811912f7206f78e9447","path":"Sources/Sentry/include/SentryThreadHandle.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862bc8fb6ad96b53f2b26b863c155ac1e","path":"Sources/Sentry/include/SentryThreadInspector.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9880761a78208e56b6a4707d7016a455cc","path":"Sources/Sentry/SentryThreadInspector.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98bebf1492e8f631b28d50d71933f1372b","path":"Sources/Sentry/SentryThreadMetadataCache.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9826a9f2e728b361a2543a40d1abedebd0","path":"Sources/Sentry/include/SentryThreadMetadataCache.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98706ef18fd5b8c82cc56ff430d4229cb2","path":"Sources/Sentry/include/SentryThreadState.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980755b34b65dc8dfadeb1feee1a3cf958","path":"Sources/Sentry/include/SentryThreadWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b6ae641edc0eb8755e4ed4a2488c062","path":"Sources/Sentry/SentryThreadWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da71b3b7edd671b3d5c2cd01db469838","path":"Sources/Sentry/include/SentryTime.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989800de374e76adb4a26b0bcddeecc2fd","path":"Sources/Sentry/SentryTime.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980cb9271b7f81c1a2d6b0f37452893839","path":"Sources/Sentry/include/SentryTimeToDisplayTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7ad08268e0d328f29de3b10cd63ddc2","path":"Sources/Sentry/SentryTimeToDisplayTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a75db7baa0413a63ae20e85adb1d22b","path":"Sources/Swift/Integrations/SessionReplay/SentryTouchTracker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a2a1ef82a610bc27d79d0c54c67af7f0","path":"Sources/Sentry/Public/SentryTraceContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887cc63e57392bb0beca2e79621425813","path":"Sources/Sentry/SentryTraceContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9892f3522efd5a3f19de8e39f523c688d6","path":"Sources/Sentry/Public/SentryTraceHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df898a62101ed695fd27677ba8483559","path":"Sources/Sentry/SentryTraceHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6f2a07c2d050533d09cf4c390de1f8c","path":"Sources/Sentry/include/SentryTraceOrigins.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b7d38aba29d59ffea50820df6a97c3d1","path":"Sources/Sentry/include/SentryTraceProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98d882e4234aaaf8e0069c816fdd59f579","path":"Sources/Sentry/Profiling/SentryTraceProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981398c4eb84cdf497790fb1fe607e2e63","path":"Sources/Sentry/include/SentryTracer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982bf9fe4d6f2326a7fccee18a75faa388","path":"Sources/Sentry/SentryTracer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98872ffad4b49352b651b261a73dad0060","path":"Sources/Sentry/include/SentryTracer+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9810cd37e42899add31fcc33f4bb739aac","path":"Sources/Sentry/include/SentryTracerConfiguration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986e709c30b562ffcb2b1eda4a0ad426d4","path":"Sources/Sentry/SentryTracerConfiguration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9866a291b84196da3f36541b841f97bd0e","path":"Sources/Sentry/include/SentryTransaction.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98221eaf92859927540a5d4291e050542e","path":"Sources/Sentry/SentryTransaction.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da808eab441d9e75217ff3d80a5e32a4","path":"Sources/Sentry/Public/SentryTransactionContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9841ff7a58c398688fb8514f02d4c41c49","path":"Sources/Sentry/SentryTransactionContext.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb1d9988a5b4400c251fe18f3b1dd801","path":"Sources/Sentry/include/SentryTransactionContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9836194029038246f4ec8bf1410be089cc","path":"Sources/Swift/Integrations/Performance/SentryTransactionNameSource.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e2f9ec322d655c0ea0950183b52221d8","path":"Sources/Sentry/include/SentryTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d6a32bf0e617b57a20d2c33c46eb7914","path":"Sources/Sentry/include/SentryTransportAdapter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984be035c6e10d36e66be434b1c21a073b","path":"Sources/Sentry/SentryTransportAdapter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98954fa85d06420d5ec52380486fd5cb33","path":"Sources/Sentry/include/SentryTransportFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b676ca7f4497f4d1fe4138d1f5e18cf7","path":"Sources/Sentry/SentryTransportFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a376fd26c1eb7097e69832b6f02467c","path":"Sources/Sentry/include/SentryUIApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad8cadda5d19b22d7d5e391f3f2e5773","path":"Sources/Sentry/SentryUIApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894a25cc56626f69559a458a15ac02efd","path":"Sources/Sentry/include/SentryUIDeviceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ef575e6594a639f646ed7e15b622f05","path":"Sources/Sentry/SentryUIDeviceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d16e83a31b866dde2772c9ce54293036","path":"Sources/Sentry/include/SentryUIEventTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7648303d42528f7384cd7e96635cb91","path":"Sources/Sentry/SentryUIEventTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98727154a288bb63516c37f191b7f989fc","path":"Sources/Sentry/include/SentryUIEventTrackerMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ec06dab8b33576c79bb7f8e7c442836d","path":"Sources/Sentry/include/SentryUIEventTrackerTransactionMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e4854f9e24894360225810635de170d4","path":"Sources/Sentry/SentryUIEventTrackerTransactionMode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98521d00d65862188e87f09af5fdd2b143","path":"Sources/Sentry/include/SentryUIEventTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c9a689aa4dad67a6b1ca280f2f6ebea4","path":"Sources/Sentry/SentryUIEventTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e3a99429d2074eae3ed0f01f406b861","path":"Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845042217e88518e3e855ac6610a6bd28","path":"Sources/Sentry/SentryUIViewControllerPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9889e3af4a95fcd5340aa42b761c61304c","path":"Sources/Sentry/include/SentryUIViewControllerSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983176fb557a25d7b21f81988edd0f03bb","path":"Sources/Sentry/SentryUIViewControllerSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9893fe513507e3ed2aee8e215050e2e0d7","path":"Sources/Sentry/Public/SentryUser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981599944e2dac3b6e693aef6d13475bc3","path":"Sources/Sentry/SentryUser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98306548f7ff648626ef748253421486b8","path":"Sources/Sentry/include/HybridPublic/SentryUser+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983d67248f1449eda03bef4a9ab05fe6a4","path":"Sources/Sentry/Public/SentryUserFeedback.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aea6546754779f273d8124b565989345","path":"Sources/Sentry/SentryUserFeedback.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9821d0b241774df222e3b7377348f85f63","path":"Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bb1557164f81c9e1bfbb7f8363a7867","path":"Sources/Sentry/include/SentryViewHierarchy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98299ac7ba68df1b240a6b268ff9a86b76","path":"Sources/Sentry/SentryViewHierarchy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980afc7a0cee689b8418b92fe22b0c3615","path":"Sources/Sentry/include/SentryViewHierarchyIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b53e903d92672c16fb54d8ff0231cef9","path":"Sources/Sentry/SentryViewHierarchyIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98817181aab5ecfaf90b5b85e17c49dec8","path":"Sources/Swift/Tools/SentryViewPhotographer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889f837ea12a895bd661390ab3127aa6d","path":"Sources/Swift/Tools/SentryViewScreenshotProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98831b777b6c8ddefb76a396274e08cf65","path":"Sources/Sentry/include/SentryWatchdogTerminationLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98018b75d110252e6219e0ee545362ac37","path":"Sources/Sentry/SentryWatchdogTerminationLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b4d68ef1663be58c2c7d79a11afa4df","path":"Sources/Sentry/include/SentryWatchdogTerminationScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98364193a20b48ba275110ac3d6d5e9cda","path":"Sources/Sentry/SentryWatchdogTerminationScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd01427898ca74956b565fd99d02dc1c","path":"Sources/Sentry/include/SentryWatchdogTerminationTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e91816c36c7362ce51491a1a85d80cd","path":"Sources/Sentry/SentryWatchdogTerminationTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98780c1b9a14bc63cecf76b3990a59877b","path":"Sources/Sentry/include/SentryWatchdogTerminationTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98491b83da9ba737ee9f38229ad6e420d5","path":"Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed78e714a1400e941f04e4a22e3d5b2e","path":"Sources/Sentry/Public/SentryWithoutUIKit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe228c1e3eb9e98e34f508e02afeb4d","path":"Sources/Swift/Metrics/SetMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fa7dc020d34d966016e6329a66dd8e9f","path":"Sources/Swift/Extensions/StringExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d9c9b6d5fc746b0b6e694918a128dda3","path":"Sources/Swift/SwiftDescriptor.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb607a81a62d906f3b39c57798758ac5","path":"Sources/Swift/Tools/UIImageHelper.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988bedd1aa102f8e0c2b086358fe8c4265","path":"Sources/Swift/Tools/UIRedactBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98199af03d7bbe972473ba296e49e9e28f","path":"Sources/Sentry/include/UIViewController+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c67c9dd02046243d9509ed2cd4798160","path":"Sources/Sentry/UIViewController+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a9bdf6e3af1025f342b74ed83538e99f","path":"Sources/Swift/Extensions/UIViewExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9847561447b26da1538d79830f9f25d534","path":"Sources/Swift/Tools/UrlSanitized.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e0f7059daf81ae00dbaea4f94fd0940e","path":"Sources/Swift/Tools/URLSessionTaskHelper.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98720140f27bb1633c64c1b1da36ff7150","path":"Sources/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984e8e4b6b4cdf0c8e787fc62391dd92fc","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fde7c7fa201b545fa7346c2dbc65d01e","name":"HybridSDK","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ef1bcc008e87a9a25e802f910bd3b338","path":"ResourceBundle-Sentry-Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98379c05280a321bf688329fe3aef3105b","path":"Sentry.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98743f2bd1841f8f09decb589fed2636c8","path":"Sentry-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983b019ef6e78b06a8a837bc6d70936ca6","path":"Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a244d1195fc83689d6ce3ffc5e36f6e2","path":"Sentry-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983938971700c25e06de2a31eeb4ea8038","path":"Sentry-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98008eb440e134dedec2875572805c0b95","path":"Sentry.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9802292b204c9bad4ac7f8961219de2b07","path":"Sentry.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fb9ef72a20518718c75331fd4883cdd","name":"Support Files","path":"../Target Support Files/Sentry","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982359f49a15cf54f5a5642b64cc3eb2e3","name":"Sentry","path":"Sentry","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d16ccbe7206fb0e2ba6426ac4ec38dd9","path":"SwiftyGif/NSImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889573bb4021c2391fbff0a825ae4178b","path":"SwiftyGif/NSImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9835fe91271911430f451a39e60aa1986c","path":"SwiftyGif/ObjcAssociatedWeakObject.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee06371f21d2935a5ee20d4d5bb4e44f","path":"SwiftyGif/SwiftyGif.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e7350beaaae8ffbf946713feea0a4648","path":"SwiftyGif/SwiftyGifManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98595dbe4b870633814d56f32dc4618746","path":"SwiftyGif/UIImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d770d4ce570ae939fbded8f93e00ff8e","path":"SwiftyGif/UIImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9875483e8f93a2b38f9b1fe471ba33c3a7","path":"SwiftyGif.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c6490049da5fff353f3196233e5aa86b","path":"SwiftyGif-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e988ad7a0fe1ae6306e9a821e4ed13fff44","path":"SwiftyGif-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981915be642f44f92bbb0c663859b70dfe","path":"SwiftyGif-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980626803cd8030a41b813ca8f28fffea0","path":"SwiftyGif-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ed5df08130fde981e5066c74824ca0ad","path":"SwiftyGif.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98be3854286ad572fdc485c3f4251ca09f","path":"SwiftyGif.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98810193ceb3c555979788ed2a5c372602","name":"Support Files","path":"../Target Support Files/SwiftyGif","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ae2061b06cbc4928ef7854c13f38740","name":"SwiftyGif","path":"SwiftyGif","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98632304d423ab2bf91c956eac7a76a9c7","path":"Toast-Framework/Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a726412a53fe90ba2295a50dfd22a2","path":"Toast/UIView+Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989752157b2cd16354797296f760ae1aef","path":"Toast/UIView+Toast.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989938462c628741b000a566d13b66d51d","path":"Toast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e3d5685abb47d94d856240e992947bc4","path":"Toast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989038dc03edeabc512af644ec374031bf","path":"Toast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985032ba3070912dc548c2a9e4e7a902a9","path":"Toast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c66dfb77b319009b7b9b119ff175df41","path":"Toast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863fe2c666a46f95fbbe4c0f7414d7d8b","path":"Toast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ca2bbfca057495e362d7fe67afdfd6b9","path":"Toast.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987533233d249f8595cd310d0355eed423","name":"Support Files","path":"../Target Support Files/Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984ea7b243f231f45db36c593b6182c199","name":"Toast","path":"Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cfe4b62ae2e73f7f7be4602344a0f59","name":"Pods","path":"","sourceTree":"","type":"group"},{"guid":"bfdfe7dc352907fc980b868725387e9879aabb07c832697b70c102efdf5309db","name":"Products","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98bc9b33687cf974a825db433d5a4ce66f","path":"Pods-Runner.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987edd064bed57826d8d017ae149a3d52e","path":"Pods-Runner-acknowledgements.markdown","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987fb378bbcd6c5ed58c790a44f8de1ad8","path":"Pods-Runner-acknowledgements.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d314b0e0b0622c4b6805a741f2686226","path":"Pods-Runner-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e98c868b1c7c8b6f1d4f98ebdaeb296a675","path":"Pods-Runner-frameworks.sh","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986fd78883b66d5d9077a96975628773ce","path":"Pods-Runner-Info.plist","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e983a42af4f91479a2ff6a94702f9452725","path":"Pods-Runner-resources.sh","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9847c36cf1fe2165ccb62332bdeff26348","path":"Pods-Runner-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985f6ec3891a0be6525111807007bbcf76","path":"Pods-Runner.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988db807c74690161e03dc575ee7464945","path":"Pods-Runner.profile.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98778a51cd43710d278531fd4c4e5ed80f","path":"Pods-Runner.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863ac39dbb3c5e6697e1e483c96fdcf12","name":"Pods-Runner","path":"Target Support Files/Pods-Runner","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d87df373aa945c7cffc56572b5b5c1d0","name":"Targets Support Files","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98677e601b37074db53aff90e47c8f96d1","name":"Pods","path":"","sourceTree":"","type":"group"},"guid":"bfdfe7dc352907fc980b868725387e98","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj","projectDirectory":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods","targets":["TARGET@v11_hash=8afac9aa7f2f7ae8a47d13626813e422","TARGET@v11_hash=dba02e4185326fadc962f9bcc306afff","TARGET@v11_hash=9ed764e6a36ac0463803ed04679a098b","TARGET@v11_hash=343abb3a1787caba848689df913b48cc","TARGET@v11_hash=18cc54ce823da0aaace1f19b54169b79","TARGET@v11_hash=455e18a547e744c268f0ab0be67b484f","TARGET@v11_hash=ce18edbb47580206399cf4783eec7ed5","TARGET@v11_hash=50777e38e58c4490a53094c0b174a83e","TARGET@v11_hash=be4bfd549192ab886b16634e611a5cfb","TARGET@v11_hash=bc8a844879af7a4b46ae41879f040474","TARGET@v11_hash=41f53d07703b6cdfcf27ef7c9560cf8c","TARGET@v11_hash=fe89e7ef4549c341d03d86fb3e6322bd","TARGET@v11_hash=8fb782e24ce265c815ff1b320853f917","TARGET@v11_hash=532913e38c7e06e5eea62089d1193ee4","TARGET@v11_hash=3c05d2ce5e305ec83a99f3301a5236aa","TARGET@v11_hash=a588ecdf5bfe2f1ad6c5d9a6bb9940f1","TARGET@v11_hash=c433bd69b99230b9785c1be714ce0175","TARGET@v11_hash=2438ab2bbd7e02a80b06e65e631e1ee9","TARGET@v11_hash=559c3084339e631943ea8fbb0ff14658","TARGET@v11_hash=78c419d7e36f388dac9ad87ec6534e43","TARGET@v11_hash=72545b20ee6d4e64d463a237167e469f","TARGET@v11_hash=7931e16ef4631bcfa5b05077cd140cef","TARGET@v11_hash=91393af516387dfbbafa2eb5029109fc","TARGET@v11_hash=c51f85455c2588dcd567c74a4396fcbf","TARGET@v11_hash=a1a63d5178cbcd2daae2e0cba9b032e5","TARGET@v11_hash=03dea4a492a969d9433ed28b4b2a0aec","TARGET@v11_hash=1dcf7cc21e4184e0f28a9789b4c382c9","TARGET@v11_hash=eaabb77f2569c0713fe5909f5362b3fa","TARGET@v11_hash=817de712cb6fac2be24baa7ec42aaf97","TARGET@v11_hash=fbd6377f91e5f0cc1620995c99b99ff0","TARGET@v11_hash=b5817aa8a8a5b233abd08d304efe013d","TARGET@v11_hash=86aab23948c9cd257baaff836f7414a1","TARGET@v11_hash=29ec1227ae80fa5e85545dce343417e5","TARGET@v11_hash=ab8b0fc009ec3b369e9ae605936ce603","TARGET@v11_hash=64039072b063670902e1ef354134e49d","TARGET@v11_hash=5449496bc380a949b05257eb8db9d316","TARGET@v11_hash=3cca9ca389e095b67ce3af588be9188d","TARGET@v11_hash=f78ac38fdf215d89c0281e470c44b101","TARGET@v11_hash=692a56120a7530dd608fbaa413d3d410","TARGET@v11_hash=bd3c446e66dacbac35fda866591052c9","TARGET@v11_hash=7d8a079c75bc93528df1276ff6c1a06e","TARGET@v11_hash=22014fcd8061c49ec1a7011011fa29d2","TARGET@v11_hash=0c4ac04efd08ba24acda74bf403c30fe","TARGET@v11_hash=6d6f324e26347bf163f3e2dcaa278075","TARGET@v11_hash=36e48dc34e49b20eaf26c3a4d1213a82","TARGET@v11_hash=583d53cd439ec89e8ee070a321e88f4e","TARGET@v11_hash=65477760b7bea77bb4f50c90f24afed5","TARGET@v11_hash=40f8368e8026f113aa896b4bd218efee","TARGET@v11_hash=78da11ebe0789216e22d2e6aaa220c0a"]} \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json new file mode 100644 index 0000000000..0d35e5fa9a --- /dev/null +++ b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json @@ -0,0 +1 @@ +{"guid":"dc4b70c03e8043e50e38f2068887b1d4","name":"Pods","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj/project.xcworkspace","projects":["PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1"]} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile index 8c77835677..012a925033 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.13' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj index ac3debdf8e..b6c5559634 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -26,11 +26,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; }; - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 49D7864808B727FDFB82A4C2 /* Pods_Runner.framework */; }; - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; }; - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,8 +46,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */, - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */, ); name = "Bundle Framework"; runOnlyForDeploymentPostprocessing = 0; @@ -71,7 +65,6 @@ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FlutterMacOS.framework; path = Flutter/ephemeral/FlutterMacOS.framework; sourceTree = SOURCE_ROOT; }; 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; @@ -80,7 +73,6 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; A82DF8E6F43DF0AD4D0653DC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - D73912EF22F37F9E000D13A0 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/ephemeral/App.framework; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -88,8 +80,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */, - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */, B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -145,8 +135,6 @@ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - D73912EF22F37F9E000D13A0 /* App.framework */, - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */, ); path = Flutter; sourceTree = ""; @@ -215,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0930; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -268,6 +256,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -281,7 +270,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename\n"; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; }; 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -414,7 +403,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -497,7 +486,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -544,7 +533,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3d8205cf56..758981e665 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart index 69e287f117..f69fd16927 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart @@ -4,11 +4,11 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:logger/logger.dart'; import 'ffi.dart' as ffi; @@ -62,28 +62,15 @@ class RustLogStreamReceiver { late StreamController _streamController; late StreamSubscription _subscription; int get port => _ffiPort.sendPort.nativePort; - late Logger _logger; RustLogStreamReceiver._internal() { _ffiPort = RawReceivePort(); _streamController = StreamController(); _ffiPort.handler = _streamController.add; - _logger = Logger( - printer: PrettyPrinter( - methodCount: 0, // number of method calls to be displayed - errorMethodCount: 8, // number of method calls if stacktrace is provided - lineLength: 120, // width of the output - colors: false, // Colorful log messages - printEmojis: false, // Print an emoji for each log message - dateTimeFormat: - DateTimeFormat.none, // Should each log print contain a timestamp - ), - level: kDebugMode ? Level.trace : Level.info, - ); _subscription = _streamController.stream.listen((data) { String decodedString = utf8.decode(data); - _logger.i(decodedString); + Log.info(decodedString); }); } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index 355a196621..ce0a4e2248 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -3,64 +3,43 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart' as ffi; import 'package:flutter/foundation.dart'; -import 'package:logger/logger.dart'; +import 'package:talker/talker.dart'; import 'ffi.dart'; class Log { static final shared = Log(); - // ignore: unused_field - late Logger _logger; - bool _enabled = false; + late Talker _logger; + + bool enableFlutterLog = true; // used to disable log in tests @visibleForTesting bool disableLog = false; Log() { - _logger = Logger( - printer: PrettyPrinter( - methodCount: 2, // Number of method calls to be displayed - errorMethodCount: 8, // Number of method calls if stacktrace is provided - lineLength: 120, // Width of the output - colors: true, // Colorful log messages - printEmojis: true, // Print an emoji for each log message - ), - level: kDebugMode ? Level.trace : Level.info, + _logger = Talker( + filter: LogLevelTalkerFilter(), ); } - static void enableFlutterLog() { - shared._enabled = true; - } - // Generic internal logging function to reduce code duplication - static void _log(Level level, int rustLevel, dynamic msg, - [dynamic error, StackTrace? stackTrace]) { - if (shared._enabled) { - switch (level) { - case Level.info: - shared._logger.i(msg, stackTrace: stackTrace); - break; - case Level.debug: - shared._logger.d(msg, stackTrace: stackTrace); - break; - case Level.warning: - shared._logger.w(msg, stackTrace: stackTrace); - break; - case Level.error: - shared._logger.e(msg, stackTrace: stackTrace); - break; - case Level.trace: - shared._logger.t(msg, stackTrace: stackTrace); - break; - default: - shared._logger.log(level, msg, stackTrace: stackTrace); - } + static void _log( + LogLevel level, + int rustLevel, + dynamic msg, [ + dynamic error, + StackTrace? stackTrace, + ]) { + // only forward logs to flutter in debug mode, otherwise log to rust to + // persist logs in the file system + if (shared.enableFlutterLog && kDebugMode) { + shared._logger.log(msg, logLevel: level, stackTrace: stackTrace); + } else { + String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); + rust_log(rustLevel, toNativeUtf8(formattedMessage)); } - String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); - rust_log(rustLevel, toNativeUtf8(formattedMessage)); } static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -68,7 +47,7 @@ class Log { return; } - _log(Level.info, 0, msg, error, stackTrace); + _log(LogLevel.info, 0, msg, error, stackTrace); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -76,7 +55,7 @@ class Log { return; } - _log(Level.debug, 1, msg, error, stackTrace); + _log(LogLevel.debug, 1, msg, error, stackTrace); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -84,7 +63,7 @@ class Log { return; } - _log(Level.warning, 3, msg, error, stackTrace); + _log(LogLevel.warning, 3, msg, error, stackTrace); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -92,7 +71,7 @@ class Log { return; } - _log(Level.trace, 2, msg, error, stackTrace); + _log(LogLevel.verbose, 2, msg, error, stackTrace); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { @@ -100,7 +79,7 @@ class Log { return; } - _log(Level.error, 4, msg, error, stackTrace); + _log(LogLevel.error, 4, msg, error, stackTrace); } } @@ -119,3 +98,11 @@ String _formatMessageWithStackTrace(dynamic msg, StackTrace? stackTrace) { } return msg.toString(); } + +class LogLevelTalkerFilter implements TalkerFilter { + @override + bool filter(TalkerData data) { + // filter out the debug logs in release mode + return kDebugMode ? true : data.logLevel != LogLevel.debug; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml index 9ff267929a..18aea4838b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ffi: ^2.0.2 isolates: ^3.0.3+8 protobuf: ^3.1.0 - logger: ^2.4.0 + talker: ^4.7.1 plugin_platform_interface: ^2.1.3 appflowy_result: path: ../appflowy_result diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart index ebd757aad3..12bfd87ac3 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart @@ -23,7 +23,7 @@ class _PopoverMenuState extends State { borderRadius: const BorderRadius.all(Radius.circular(8)), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.5), + color: Colors.grey.withValues(alpha: 0.5), spreadRadius: 5, blurRadius: 7, offset: const Offset(0, 3), // changes position of shadow diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart index 925b9cad02..0b4208e6fc 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart @@ -1,5 +1,5 @@ /// AppFlowyBoard library -library appflowy_popover; +library; export 'src/mutex.dart'; export 'src/popover.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart index 97b81cfe1a..d91c9e4954 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart @@ -1,4 +1,5 @@ -library appflowy_result; +/// AppFlowyPopover library +library; export 'src/async_result.dart'; export 'src/result.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml index fa2e35f329..5d8f0d88c2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -1,7 +1,7 @@ name: appflowy_result description: "A new Flutter package project." version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=3.3.0 <4.0.0" @@ -9,40 +9,3 @@ environment: dev_dependencies: flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore new file mode 100644 index 0000000000..da0bb7ce97 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/.metadata new file mode 100644 index 0000000000..79932b61d5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: package diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE new file mode 100644 index 0000000000..ba75c69f7f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/README.md new file mode 100644 index 0000000000..953d3545f1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/README.md @@ -0,0 +1,39 @@ +# AppFlowy UI + +AppFlowy UI is a Flutter package that provides a collection of reusable UI components following the AppFlowy design system. These components are designed to be consistent, accessible, and easy to use. + +## Features + +- **Design System Components**: Buttons, text fields, and more UI components that follow the AppFlowy design system +- **Theming**: Consistent theming across all components with light and dark mode support + +## Installation + +Add the following to your `pubspec.yaml` file: + +```yaml +dependencies: + appflowy_ui: ^1.0.0 +``` + +## Supported components + +- [x] Button +- [x] TextField +- [ ] Avatar +- [ ] Checkbox +- [ ] Grid +- [ ] Link +- [ ] Loading & Progress Indicator +- [ ] Menu +- [ ] Message Box +- [ ] Navigation Bar +- [ ] Popover +- [ ] Scroll Bar +- [ ] Tab Bar +- [ ] Toggle +- [ ] Tooltip + +## Reference + +Figma: https://www.figma.com/design/aphWa2OgkqyIragpatdk7a/Design-System diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml new file mode 100644 index 0000000000..abba19b4fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml @@ -0,0 +1,29 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - require_trailing_commas + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace + - use_decorated_box + + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + +errors: + invalid_annotation_target: ignore diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata new file mode 100644 index 0000000000..777c932a64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: macos + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md new file mode 100644 index 0000000000..2ccc9e658d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md @@ -0,0 +1,41 @@ +# AppFlowy UI Example + +This example demonstrates how to use the `appflowy_ui` package in a Flutter application. + +## Getting Started + +To run this example: + +1. Ensure you have Flutter installed and set up on your machine +2. Clone this repository +3. Navigate to the example directory: + ```bash + cd example + ``` +4. Get the dependencies: + ```bash + flutter pub get + ``` +5. Run the example: + ```bash + flutter run + ``` + +## Features Demonstrated + +- Basic app structure using AppFlowy UI components +- Material 3 design integration +- Responsive layout + +## Project Structure + +- `lib/main.dart`: The main application file +- `pubspec.yaml`: Project dependencies and configuration + +## Additional Resources + +For more information about the AppFlowy UI package, please refer to: + +- The main package documentation +- [AppFlowy Website](https://appflowy.io) +- [AppFlowy GitHub Repository](https://github.com/AppFlowy-IO/AppFlowy) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart new file mode 100644 index 0000000000..0d23746ebd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -0,0 +1,117 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import 'src/buttons/buttons_page.dart'; +import 'src/modal/modal_page.dart'; +import 'src/textfield/textfield_page.dart'; + +enum ThemeMode { + light, + dark, +} + +final themeMode = ValueNotifier(ThemeMode.light); + +void main() { + runApp( + const MyApp(), + ); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, themeMode, child) { + final themeBuilder = AppFlowyDefaultTheme(); + final themeData = + themeMode == ThemeMode.light ? ThemeData.light() : ThemeData.dark(); + + return AnimatedAppFlowyTheme( + data: themeMode == ThemeMode.light + ? themeBuilder.light() + : themeBuilder.dark(), + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'AppFlowy UI Example', + theme: themeData.copyWith( + visualDensity: VisualDensity.standard, + ), + home: const MyHomePage( + title: 'AppFlowy UI', + ), + ), + ); + }, + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + required this.title, + }); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final tabs = [ + Tab(text: 'Button'), + Tab(text: 'TextField'), + Tab(text: 'Modal'), + ]; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text( + widget.title, + style: theme.textStyle.title.enhanced( + color: theme.textColorScheme.primary, + ), + ), + actions: [ + IconButton( + icon: Icon( + Theme.of(context).brightness == Brightness.light + ? Icons.dark_mode + : Icons.light_mode, + ), + onPressed: _toggleTheme, + tooltip: 'Toggle theme', + ), + ], + ), + body: TabBarView( + children: [ + ButtonsPage(), + TextFieldPage(), + ModalPage(), + ], + ), + bottomNavigationBar: TabBar( + tabs: tabs, + ), + floatingActionButton: null, + ), + ); + } + + void _toggleTheme() { + themeMode.value = + themeMode.value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart new file mode 100644 index 0000000000..0d0c018222 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart @@ -0,0 +1,287 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ButtonsPage extends StatelessWidget { + const ButtonsPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'Filled Text Buttons', + [ + AFFilledTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Filled Icon Text Buttons', + [ + AFFilledButton.primary( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Primary Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Text Buttons', + [ + AFOutlinedTextButton.normal( + text: 'Normal Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Icon Text Buttons', + [ + AFOutlinedButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Normal Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.primary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Ghost Buttons', + [ + AFGhostTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFGhostTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button with alignment', + [ + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Left Button', + onTap: () {}, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Center Button', + onTap: () {}, + alignment: Alignment.center, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Right Button', + onTap: () {}, + alignment: Alignment.centerRight, + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button Sizes', + [ + AFFilledTextButton.primary( + text: 'Small Button', + onTap: () {}, + size: AFButtonSize.s, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Medium Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Large Button', + onTap: () {}, + size: AFButtonSize.l, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Extra Large Button', + onTap: () {}, + size: AFButtonSize.xl, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart new file mode 100644 index 0000000000..4a9480d1b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart @@ -0,0 +1,153 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ModalPage extends StatefulWidget { + const ModalPage({super.key}); + + @override + State createState() => _ModalPageState(); +} + +class _ModalPageState extends State { + double width = AFModalDimension.M; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Container( + constraints: BoxConstraints(maxWidth: 600), + padding: EdgeInsets.symmetric(horizontal: theme.spacing.xl), + child: Column( + spacing: theme.spacing.l, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + spacing: theme.spacing.m, + mainAxisSize: MainAxisSize.min, + children: [ + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.S), + builder: (context, isHovering, disabled) { + return Text( + 'S', + style: TextStyle( + color: width == AFModalDimension.S + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.M), + builder: (context, isHovering, disabled) { + return Text( + 'M', + style: TextStyle( + color: width == AFModalDimension.M + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.L), + builder: (context, isHovering, disabled) { + return Text( + 'L', + style: TextStyle( + color: width == AFModalDimension.L + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + ], + ), + AFFilledButton.primary( + builder: (context, isHovering, disabled) { + return Text( + 'Show Modal', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ); + }, + onTap: () { + showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + builder: (context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: AFModal( + constraints: BoxConstraints( + maxWidth: width, + maxHeight: AFModalDimension.dialogHeight, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + 'Header', + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Icon(Icons.close); + }, + ) + ], + ), + Expanded( + child: AFModalBody( + child: Text( + 'A dialog briefly presents information or requests confirmation, allowing users to continue their workflow after interaction.'), + ), + ), + AFModalFooter( + trailing: [ + AFOutlinedButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Text('Cancel'); + }, + ), + AFFilledButton.primary( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return Text( + 'Apply', + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ), + ], + ) + ], + )), + ); + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart new file mode 100644 index 0000000000..9e3436ecd4 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart @@ -0,0 +1,90 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class TextFieldPage extends StatelessWidget { + const TextFieldPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'TextField Sizes', + [ + AFTextField( + hintText: 'Please enter your name', + size: AFTextFieldSize.m, + ), + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with hint text', + [ + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with initial text', + [ + AFTextField( + initialText: 'https://appflowy.com', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with validator ', + [ + AFTextField( + validator: (controller) { + if (controller.text.isEmpty) { + return (true, 'This field is required'); + } + + final emailRegex = + RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(controller.text)) { + return (true, 'Please enter a valid email address'); + } + + return (false, ''); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..345181d730 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "appflowy_ui_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..04d5b736e6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..47821fa6d8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = appflowy_ui_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml new file mode 100644 index 0000000000..af361ecfab --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: appflowy_ui_example +description: "Example app showcasing AppFlowy UI components and widgets" +publish_to: "none" + +version: 1.0.0+1 + +environment: + flutter: ">=3.27.4" + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + appflowy_ui: + path: ../ + cupertino_icons: ^1.0.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart new file mode 100644 index 0000000000..423052a342 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appflowy_ui_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart new file mode 100644 index 0000000000..974907f940 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart @@ -0,0 +1,2 @@ +export 'src/component/component.dart'; +export 'src/theme/theme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart new file mode 100644 index 0000000000..39d5175af1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart @@ -0,0 +1,54 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/widgets.dart'; + +enum AFButtonSize { + s, + m, + l, + xl; + + TextStyle buildTextStyle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.textStyle.body.enhanced(), + AFButtonSize.m => theme.textStyle.body.enhanced(), + AFButtonSize.l => theme.textStyle.body.enhanced(), + AFButtonSize.xl => theme.textStyle.title.enhanced(), + }; + } + + EdgeInsetsGeometry buildPadding(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => EdgeInsets.symmetric( + horizontal: theme.spacing.l, + vertical: theme.spacing.xs, + ), + AFButtonSize.m => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: theme.spacing.s, + ), + AFButtonSize.l => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 10, // why? + ), + AFButtonSize.xl => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 14, // why? + ), + }; + } + + double buildBorderRadius(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.borderRadius.m, + AFButtonSize.m => theme.borderRadius.m, + AFButtonSize.l => 10, // why? + AFButtonSize.xl => theme.borderRadius.xl, + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart new file mode 100644 index 0000000000..9bb36507e8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -0,0 +1,152 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFBaseButtonColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +typedef AFBaseButtonBorderColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, + bool isFocused, +); + +class AFBaseButton extends StatefulWidget { + const AFBaseButton({ + super.key, + required this.onTap, + required this.builder, + required this.padding, + required this.borderRadius, + this.borderColor, + this.backgroundColor, + this.ringColor, + this.disabled = false, + }); + + final VoidCallback? onTap; + + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? ringColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final EdgeInsetsGeometry padding; + final double borderRadius; + final bool disabled; + + final Widget Function( + BuildContext context, + bool isHovering, + bool disabled, + ) builder; + + @override + State createState() => _AFBaseButtonState(); +} + +class _AFBaseButtonState extends State { + final FocusNode focusNode = FocusNode(); + + bool isHovering = false; + bool isFocused = false; + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color borderColor = _buildBorderColor(context); + final Color backgroundColor = _buildBackgroundColor(context); + final Color ringColor = _buildRingColor(context); + + return Actions( + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + if (!widget.disabled) { + widget.onTap?.call(); + } + return; + }, + ), + }, + child: Focus( + focusNode: focusNode, + onFocusChange: (isFocused) { + setState(() => this.isFocused = isFocused); + }, + child: MouseRegion( + cursor: widget.disabled + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + onTap: widget.disabled ? null : widget.onTap, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + border: isFocused + ? Border.all( + color: ringColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ) + : null, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.padding, + child: widget.builder( + context, + isHovering, + widget.disabled, + ), + ), + ), + ), + ), + ), + ), + ); + } + + Color _buildBorderColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.borderColor + ?.call(context, isHovering, widget.disabled, isFocused) ?? + theme.borderColorScheme.greyTertiary; + } + + Color _buildBackgroundColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.backgroundColor?.call(context, isHovering, widget.disabled) ?? + theme.fillColorScheme.transparent; + } + + Color _buildRingColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + if (widget.ringColor != null) { + return widget.ringColor! + .call(context, isHovering, widget.disabled, isFocused); + } + + if (isFocused) { + return theme.borderColorScheme.themeThick.withAlpha(128); + } + + return theme.borderColorScheme.transparent; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart new file mode 100644 index 0000000000..035307d10b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart @@ -0,0 +1,55 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AFBaseTextButton extends StatelessWidget { + const AFBaseTextButton({ + super.key, + required this.text, + required this.onTap, + this.disabled = false, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.textColor, + this.backgroundColor, + this.alignment, + this.textStyle, + }); + + /// The text of the button. + final String text; + + /// Whether the button is disabled. + final bool disabled; + + /// The callback when the button is tapped. + final VoidCallback onTap; + + /// The size of the button. + final AFButtonSize size; + + /// The padding of the button. + final EdgeInsetsGeometry? padding; + + /// The border radius of the button. + final double? borderRadius; + + /// The text color of the button. + final AFBaseButtonColorBuilder? textColor; + + /// The background color of the button. + final AFBaseButtonColorBuilder? backgroundColor; + + /// The alignment of the button. + /// + /// If it's null, the button size will be the size of the text with padding. + final Alignment? alignment; + + /// The text style of the button. + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart new file mode 100644 index 0000000000..31a3a20b5f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart @@ -0,0 +1,16 @@ +// Base button +export 'base_button/base.dart'; +export 'base_button/base_button.dart'; +export 'base_button/base_text_button.dart'; +// Filled buttons +export 'filled_button/filled_button.dart'; +export 'filled_button/filled_icon_text_button.dart'; +export 'filled_button/filled_text_button.dart'; +// Ghost buttons +export 'ghost_button/ghost_button.dart'; +export 'ghost_button/ghost_icon_text_button.dart'; +export 'ghost_button/ghost_text_button.dart'; +// Outlined buttons +export 'outlined_button/outlined_button.dart'; +export 'outlined_button/outlined_icon_text_button.dart'; +export 'outlined_button/outlined_text_button.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart new file mode 100644 index 0000000000..e871626b59 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledButton extends StatelessWidget { + const AFFilledButton._({ + super.key, + required this.builder, + required this.onTap, + required this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary text button. + factory AFFilledButton.primary({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledButton.destructive({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledButton.disabled({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + disabled: true, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFFilledButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart new file mode 100644 index 0000000000..04c49d0b01 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart @@ -0,0 +1,199 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledIconTextButton extends StatelessWidget { + const AFFilledIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + }); + + /// Primary filled text button. + factory AFFilledIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.fillColorScheme.themeThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Destructive filled text button. + factory AFFilledIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Disabled filled text button. + factory AFFilledIconTextButton.disabled({ + Key? key, + required String text, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.fillColorScheme.tertiary; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Ghost filled text button with transparent background that shows color on hover. + factory AFFilledIconTextButton.ghost({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + final String text; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFFilledIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.onFill; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart new file mode 100644 index 0000000000..d1b1d868d0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart @@ -0,0 +1,149 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFFilledTextButton extends AFBaseTextButton { + const AFFilledTextButton({ + super.key, + required super.text, + required super.onTap, + required super.backgroundColor, + required super.textColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + super.textStyle, + }); + + /// Primary text button. + factory AFFilledTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + AppFlowyTheme.of(context).textColorScheme.onFill; + Widget child = Text( + text, + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart new file mode 100644 index 0000000000..6300c6f5a8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart @@ -0,0 +1,96 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostButton extends StatelessWidget { + const AFGhostButton._({ + super.key, + required this.onTap, + required this.backgroundColor, + required this.builder, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal ghost button. + factory AFGhostButton.normal({ + Key? key, + required VoidCallback onTap, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + /// Disabled ghost button. + factory AFGhostButton.disabled({ + Key? key, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFGhostButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart new file mode 100644 index 0000000000..af65599ea3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart @@ -0,0 +1,141 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostIconTextButton extends StatelessWidget { + const AFGhostIconTextButton({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary ghost text button. + factory AFGhostIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostIconTextButton.disabled({ + Key? key, + required String text, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) { + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.tertiary; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFGhostIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (context, isHovering, disabled, isFocused) { + return Colors.transparent; + }, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder( + context, + isHovering, + disabled, + ), + SizedBox(width: theme.spacing.m), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart new file mode 100644 index 0000000000..d154d67dbd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFGhostTextButton extends AFBaseTextButton { + const AFGhostTextButton({ + super.key, + required super.text, + required super.onTap, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal ghost text button. + factory AFGhostTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart new file mode 100644 index 0000000000..205d9931d6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart @@ -0,0 +1,168 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedButton extends StatelessWidget { + const AFOutlinedButton._({ + super.key, + required this.onTap, + required this.builder, + this.borderColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal outlined button. + factory AFOutlinedButton.normal({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Destructive outlined button. + factory AFOutlinedButton.destructive({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedButton.disabled({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFOutlinedButton._( + key: key, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final AFOutlinedButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart new file mode 100644 index 0000000000..350594cd46 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart @@ -0,0 +1,226 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedIconTextButton extends StatelessWidget { + const AFOutlinedIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.borderColor, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + this.alignment = MainAxisAlignment.center, + }); + + /// Normal outlined text button. + factory AFOutlinedIconTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedIconTextButton.disabled({ + Key? key, + required String text, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + final MainAxisAlignment alignment; + + final AFOutlinedIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + disabled: disabled, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: alignment, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart new file mode 100644 index 0000000000..d809d981b0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart @@ -0,0 +1,212 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFOutlinedTextButton extends AFBaseTextButton { + const AFOutlinedTextButton._({ + super.key, + required super.text, + required super.onTap, + this.borderColor, + super.textStyle, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal outlined text button. + factory AFOutlinedTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final AFBaseButtonBorderColorBuilder? borderColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart new file mode 100644 index 0000000000..584d50c07b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart @@ -0,0 +1,3 @@ +export 'button/button.dart'; +export 'modal/modal.dart'; +export 'textfield/textfield.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart new file mode 100644 index 0000000000..72a7dbb5cf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart @@ -0,0 +1,9 @@ +class AFModalDimension { + const AFModalDimension._(); + + static const double S = 400.0; + static const double M = 560.0; + static const double L = 720.0; + + static const double dialogHeight = 200.0; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart new file mode 100644 index 0000000000..4b40aebcbd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +export 'dimension.dart'; + +class AFModal extends StatelessWidget { + const AFModal({ + super.key, + this.constraints = const BoxConstraints(), + required this.child, + }); + + final BoxConstraints constraints; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Padding( + padding: EdgeInsets.all(theme.spacing.xl), + child: ConstrainedBox( + constraints: constraints, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: theme.shadow.medium, + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + color: theme.surfaceColorScheme.primary, + ), + child: Material( + color: Colors.transparent, + child: child, + ), + ), + ), + ), + ); + } +} + +class AFModalHeader extends StatelessWidget { + const AFModalHeader({ + super.key, + required this.leading, + this.trailing = const [], + }); + + final Widget leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + top: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.s, + children: [ + Expanded(child: leading), + ...trailing, + ], + ), + ); + } +} + +class AFModalFooter extends StatelessWidget { + const AFModalFooter({ + super.key, + this.leading = const [], + this.trailing = const [], + }); + + final List leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + bottom: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.l, + children: [ + ...leading, + Spacer(), + ...trailing, + ], + ), + ); + } +} + +class AFModalBody extends StatelessWidget { + const AFModalBody({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.symmetric( + vertical: theme.spacing.l, + horizontal: theme.spacing.xxl, + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart new file mode 100644 index 0000000000..3f5ad4cfed --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -0,0 +1,254 @@ +import 'package:appflowy_ui/src/theme/theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFTextFieldValidator = (bool result, String errorText) Function( + TextEditingController controller, +); + +abstract class AFTextFieldState extends State { + // Error handler + void syncError({required String errorText}) {} + void clearError() {} + + /// Obscure the text. + void syncObscured(bool isObscured) {} +} + +class AFTextField extends StatefulWidget { + const AFTextField({ + super.key, + this.hintText, + this.initialText, + this.keyboardType, + this.size = AFTextFieldSize.l, + this.validator, + this.controller, + this.onChanged, + this.onSubmitted, + this.autoFocus, + this.obscureText = false, + this.suffixIconBuilder, + this.suffixIconConstraints, + }); + + /// The hint text to display when the text field is empty. + final String? hintText; + + /// The initial text to display in the text field. + final String? initialText; + + /// The type of keyboard to display. + final TextInputType? keyboardType; + + /// The size variant of the text field. + final AFTextFieldSize size; + + /// The validator to use for the text field. + final AFTextFieldValidator? validator; + + /// The controller to use for the text field. + /// + /// If it's not provided, the text field will use a new controller. + final TextEditingController? controller; + + /// The callback to call when the text field changes. + final void Function(String)? onChanged; + + /// The callback to call when the text field is submitted. + final void Function(String)? onSubmitted; + + /// Enable auto focus. + final bool? autoFocus; + + /// Obscure the text. + final bool obscureText; + + /// The trailing widget to display. + final Widget Function(BuildContext context, bool isObscured)? + suffixIconBuilder; + + /// The size of the suffix icon. + final BoxConstraints? suffixIconConstraints; + + @override + State createState() => _AFTextFieldState(); +} + +class _AFTextFieldState extends AFTextFieldState { + late final TextEditingController effectiveController; + + bool hasError = false; + String errorText = ''; + + bool isObscured = false; + + @override + void initState() { + super.initState(); + + effectiveController = widget.controller ?? TextEditingController(); + + final initialText = widget.initialText; + if (initialText != null) { + effectiveController.text = initialText; + } + + effectiveController.addListener(_validate); + + isObscured = widget.obscureText; + } + + @override + void dispose() { + effectiveController.removeListener(_validate); + if (widget.controller == null) { + effectiveController.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final borderRadius = widget.size.borderRadius(theme); + final contentPadding = widget.size.contentPadding(theme); + + final errorBorderColor = theme.borderColorScheme.errorThick; + final defaultBorderColor = theme.borderColorScheme.greyTertiary; + + Widget child = TextField( + controller: effectiveController, + keyboardType: widget.keyboardType, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + obscureText: isObscured, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autoFocus ?? false, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.tertiary, + ), + isDense: true, + constraints: BoxConstraints(), + contentPadding: contentPadding, + border: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError + ? errorBorderColor + : theme.borderColorScheme.themeThick, + ), + borderRadius: borderRadius, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + hoverColor: theme.borderColorScheme.greyTertiaryHover, + suffixIcon: widget.suffixIconBuilder?.call(context, isObscured), + suffixIconConstraints: widget.suffixIconConstraints, + ), + ); + + if (hasError && errorText.isNotEmpty) { + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + SizedBox(height: theme.spacing.xs), + Text( + errorText, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.error, + ), + ), + ], + ); + } + + return child; + } + + void _validate() { + final validator = widget.validator; + if (validator != null) { + final result = validator(effectiveController); + setState(() { + hasError = result.$1; + errorText = result.$2; + }); + } + } + + @override + void syncError({ + required String errorText, + }) { + setState(() { + hasError = true; + this.errorText = errorText; + }); + } + + @override + void clearError() { + setState(() { + hasError = false; + errorText = ''; + }); + } + + @override + void syncObscured(bool isObscured) { + setState(() { + this.isObscured = isObscured; + }); + } +} + +enum AFTextFieldSize { + m, + l; + + EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { + return EdgeInsets.symmetric( + vertical: switch (this) { + AFTextFieldSize.m => theme.spacing.s, + AFTextFieldSize.l => 10.0, + }, + horizontal: theme.spacing.m, + ); + } + + BorderRadius borderRadius(AppFlowyThemeData theme) { + return BorderRadius.circular( + switch (this) { + AFTextFieldSize.m => theme.borderRadius.m, + AFTextFieldSize.l => 10.0, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart new file mode 100644 index 0000000000..26e45ca8f1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart @@ -0,0 +1,152 @@ +import 'package:appflowy_ui/src/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyTheme extends StatelessWidget { + const AppFlowyTheme({ + super.key, + required this.data, + required this.child, + }); + + final AppFlowyThemeData data; + final Widget child; + + static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { + final provider = maybeOf(context, listen: listen); + if (provider == null) { + throw FlutterError( + ''' + AppFlowyTheme.of() called with a context that does not contain a AppFlowyTheme.\n + No AppFlowyTheme ancestor could be found starting from the context that was passed to AppFlowyTheme.of(). + This can happen because you do not have a AppFlowyTheme widget (which introduces a AppFlowyTheme), + or it can happen if the context you use comes from a widget above this widget.\n + The context used was: $context''', + ); + } + return provider; + } + + static AppFlowyThemeData? maybeOf( + BuildContext context, { + bool listen = true, + }) { + if (listen) { + return context + .dependOnInheritedWidgetOfExactType() + ?.themeData; + } + final provider = context + .getElementForInheritedWidgetOfExactType() + ?.widget; + + return (provider as AppFlowyInheritedTheme?)?.themeData; + } + + @override + Widget build(BuildContext context) { + return AppFlowyInheritedTheme( + themeData: data, + child: child, + ); + } +} + +class AppFlowyInheritedTheme extends InheritedTheme { + const AppFlowyInheritedTheme({ + super.key, + required this.themeData, + required super.child, + }); + + final AppFlowyThemeData themeData; + + @override + Widget wrap(BuildContext context, Widget child) { + return AppFlowyTheme(data: themeData, child: child); + } + + @override + bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => + themeData != oldWidget.themeData; +} + +/// An interpolation between two [AppFlowyThemeData]s. +/// +/// This class specializes the interpolation of [Tween] to +/// call the [AppFlowyThemeData.lerp] method. +/// +/// See [Tween] for a discussion on how to use interpolation objects. +class AppFlowyThemeDataTween extends Tween { + /// Creates a [AppFlowyThemeData] tween. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + AppFlowyThemeDataTween({super.begin, super.end}); + + @override + AppFlowyThemeData lerp(double t) => AppFlowyThemeData.lerp(begin!, end!, t); +} + +class AnimatedAppFlowyTheme extends ImplicitlyAnimatedWidget { + /// Creates an animated theme. + /// + /// By default, the theme transition uses a linear curve. + const AnimatedAppFlowyTheme({ + super.key, + required this.data, + super.curve, + super.duration = kThemeAnimationDuration, + super.onEnd, + required this.child, + }); + + /// Specifies the color and typography values for descendant widgets. + final AppFlowyThemeData data; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + AnimatedWidgetBaseState createState() => + _AnimatedThemeState(); +} + +class _AnimatedThemeState + extends AnimatedWidgetBaseState { + AppFlowyThemeDataTween? data; + + @override + void forEachTween(TweenVisitor visitor) { + data = visitor( + data, + widget.data, + (dynamic value) => + AppFlowyThemeDataTween(begin: value as AppFlowyThemeData), + )! as AppFlowyThemeDataTween; + } + + @override + Widget build(BuildContext context) { + return AppFlowyTheme( + data: data!.evaluate(animation), + child: widget.child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add( + DiagnosticsProperty( + 'data', + data, + showName: false, + defaultValue: null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart new file mode 100644 index 0000000000..2bd6d619d8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart @@ -0,0 +1,658 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-19T13:45:56.076897 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._(); + + /// #f8faff + static Color get neutral100 => Color(0xFFF8FAFF); + + /// #e4e8f5 + static Color get neutral200 => Color(0xFFE4E8F5); + + /// #ced3e6 + static Color get neutral300 => Color(0xFFCED3E6); + + /// #b5bbd3 + static Color get neutral400 => Color(0xFFB5BBD3); + + /// #989eb7 + static Color get neutral500 => Color(0xFF989EB7); + + /// #6f748c + static Color get neutral600 => Color(0xFF6F748C); + + /// #54596e + static Color get neutral700 => Color(0xFF54596E); + + /// #3c3f4e + static Color get neutral800 => Color(0xFF3C3F4E); + + /// #272930 + static Color get neutral900 => Color(0xFF272930); + + /// #21232a + static Color get neutral1000 => Color(0xFF21232A); + + /// #000000 + static Color get neutralBlack => Color(0xFF000000); + + /// #00000099 + static Color get neutralAlphaBlack60 => Color(0x99000000); + + /// #ffffff + static Color get neutralWhite => Color(0xFFFFFFFF); + + /// #ffffff00 + static Color get neutralAlphaWhite0 => Color(0x00FFFFFF); + + /// #ffffff33 + static Color get neutralAlphaWhite20 => Color(0x33FFFFFF); + + /// #ffffff4d + static Color get neutralAlphaWhite30 => Color(0x4DFFFFFF); + + /// #f9fafd0d + static Color get neutralAlphaGrey10005 => Color(0x0DF9FAFD); + + /// #f9fafd1a + static Color get neutralAlphaGrey10010 => Color(0x1AF9FAFD); + + /// #1f23290d + static Color get neutralAlphaGrey100005 => Color(0x0D1F2329); + + /// #1f23291a + static Color get neutralAlphaGrey100010 => Color(0x1A1F2329); + + /// #1f2329b2 + static Color get neutralAlphaGrey100070 => Color(0xB21F2329); + + /// #1f2329cc + static Color get neutralAlphaGrey100080 => Color(0xCC1F2329); + + /// #e3f6ff + static Color get blue100 => Color(0xFFE3F6FF); + + /// #a9e2ff + static Color get blue200 => Color(0xFFA9E2FF); + + /// #80d2ff + static Color get blue300 => Color(0xFF80D2FF); + + /// #4ec1ff + static Color get blue400 => Color(0xFF4EC1FF); + + /// #00b5ff + static Color get blue500 => Color(0xFF00B5FF); + + /// #0092d6 + static Color get blue600 => Color(0xFF0092D6); + + /// #0078c0 + static Color get blue700 => Color(0xFF0078C0); + + /// #0065a9 + static Color get blue800 => Color(0xFF0065A9); + + /// #00508f + static Color get blue900 => Color(0xFF00508F); + + /// #003c77 + static Color get blue1000 => Color(0xFF003C77); + + /// #00b5ff26 + static Color get blueAlphaBlue50015 => Color(0x2600B5FF); + + /// #ecf9f5 + static Color get green100 => Color(0xFFECF9F5); + + /// #c3e5d8 + static Color get green200 => Color(0xFFC3E5D8); + + /// #9ad1bc + static Color get green300 => Color(0xFF9AD1BC); + + /// #71bd9f + static Color get green400 => Color(0xFF71BD9F); + + /// #48a982 + static Color get green500 => Color(0xFF48A982); + + /// #248569 + static Color get green600 => Color(0xFF248569); + + /// #29725d + static Color get green700 => Color(0xFF29725D); + + /// #2e6050 + static Color get green800 => Color(0xFF2E6050); + + /// #305548 + static Color get green900 => Color(0xFF305548); + + /// #305244 + static Color get green1000 => Color(0xFF305244); + + /// #f1e0ff + static Color get purple100 => Color(0xFFF1E0FF); + + /// #e1b3ff + static Color get purple200 => Color(0xFFE1B3FF); + + /// #d185ff + static Color get purple300 => Color(0xFFD185FF); + + /// #bc58ff + static Color get purple400 => Color(0xFFBC58FF); + + /// #9327ff + static Color get purple500 => Color(0xFF9327FF); + + /// #7a1dcc + static Color get purple600 => Color(0xFF7A1DCC); + + /// #6617b3 + static Color get purple700 => Color(0xFF6617B3); + + /// #55138f + static Color get purple800 => Color(0xFF55138F); + + /// #470c72 + static Color get purple900 => Color(0xFF470C72); + + /// #380758 + static Color get purple1000 => Color(0xFF380758); + + /// #ffe5ef + static Color get magenta100 => Color(0xFFFFE5EF); + + /// #ffb8d1 + static Color get magenta200 => Color(0xFFFFB8D1); + + /// #ff8ab2 + static Color get magenta300 => Color(0xFFFF8AB2); + + /// #ff5c93 + static Color get magenta400 => Color(0xFFFF5C93); + + /// #fb006d + static Color get magenta500 => Color(0xFFFB006D); + + /// #d2005f + static Color get magenta600 => Color(0xFFD2005F); + + /// #d2005f + static Color get magenta700 => Color(0xFFD2005F); + + /// #850040 + static Color get magenta800 => Color(0xFF850040); + + /// #610031 + static Color get magenta900 => Color(0xFF610031); + + /// #400022 + static Color get magenta1000 => Color(0xFF400022); + + /// #ffd2dd + static Color get red100 => Color(0xFFFFD2DD); + + /// #ffa5b4 + static Color get red200 => Color(0xFFFFA5B4); + + /// #ff7d87 + static Color get red300 => Color(0xFFFF7D87); + + /// #ff5050 + static Color get red400 => Color(0xFFFF5050); + + /// #f33641 + static Color get red500 => Color(0xFFF33641); + + /// #e71d32 + static Color get red600 => Color(0xFFE71D32); + + /// #ad1625 + static Color get red700 => Color(0xFFAD1625); + + /// #8c101c + static Color get red800 => Color(0xFF8C101C); + + /// #6e0a1e + static Color get red900 => Color(0xFF6E0A1E); + + /// #4c0a17 + static Color get red1000 => Color(0xFF4C0A17); + + /// #f336411a + static Color get redAlphaRed50010 => Color(0x1AF33641); + + /// #fff3d5 + static Color get orange100 => Color(0xFFFFF3D5); + + /// #ffe4ab + static Color get orange200 => Color(0xFFFFE4AB); + + /// #ffd181 + static Color get orange300 => Color(0xFFFFD181); + + /// #ffbe62 + static Color get orange400 => Color(0xFFFFBE62); + + /// #ffa02e + static Color get orange500 => Color(0xFFFFA02E); + + /// #db7e21 + static Color get orange600 => Color(0xFFDB7E21); + + /// #b75f17 + static Color get orange700 => Color(0xFFB75F17); + + /// #93450e + static Color get orange800 => Color(0xFF93450E); + + /// #7a3108 + static Color get orange900 => Color(0xFF7A3108); + + /// #602706 + static Color get orange1000 => Color(0xFF602706); + + /// #fff9b2 + static Color get yellow100 => Color(0xFFFFF9B2); + + /// #ffec66 + static Color get yellow200 => Color(0xFFFFEC66); + + /// #ffdf1a + static Color get yellow300 => Color(0xFFFFDF1A); + + /// #ffcc00 + static Color get yellow400 => Color(0xFFFFCC00); + + /// #ffce00 + static Color get yellow500 => Color(0xFFFFCE00); + + /// #e6b800 + static Color get yellow600 => Color(0xFFE6B800); + + /// #cc9f00 + static Color get yellow700 => Color(0xFFCC9F00); + + /// #b38a00 + static Color get yellow800 => Color(0xFFB38A00); + + /// #9a7500 + static Color get yellow900 => Color(0xFF9A7500); + + /// #7f6200 + static Color get yellow1000 => Color(0xFF7F6200); + + /// #fcf2f2 + static Color get subtleColorRose100 => Color(0xFFFCF2F2); + + /// #fae3e3 + static Color get subtleColorRose200 => Color(0xFFFAE3E3); + + /// #fad9d9 + static Color get subtleColorRose300 => Color(0xFFFAD9D9); + + /// #edadad + static Color get subtleColorRose400 => Color(0xFFEDADAD); + + /// #cc4e4e + static Color get subtleColorRose500 => Color(0xFFCC4E4E); + + /// #702828 + static Color get subtleColorRose600 => Color(0xFF702828); + + /// #fcf4f0 + static Color get subtleColorPapaya100 => Color(0xFFFCF4F0); + + /// #fae8de + static Color get subtleColorPapaya200 => Color(0xFFFAE8DE); + + /// #fadfd2 + static Color get subtleColorPapaya300 => Color(0xFFFADFD2); + + /// #f0bda3 + static Color get subtleColorPapaya400 => Color(0xFFF0BDA3); + + /// #d67240 + static Color get subtleColorPapaya500 => Color(0xFFD67240); + + /// #6b3215 + static Color get subtleColorPapaya600 => Color(0xFF6B3215); + + /// #fff7ed + static Color get subtleColorTangerine100 => Color(0xFFFFF7ED); + + /// #fcedd9 + static Color get subtleColorTangerine200 => Color(0xFFFCEDD9); + + /// #fae5ca + static Color get subtleColorTangerine300 => Color(0xFFFAE5CA); + + /// #f2cb99 + static Color get subtleColorTangerine400 => Color(0xFFF2CB99); + + /// #db8f2c + static Color get subtleColorTangerine500 => Color(0xFFDB8F2C); + + /// #613b0a + static Color get subtleColorTangerine600 => Color(0xFF613B0A); + + /// #fff9ec + static Color get subtleColorMango100 => Color(0xFFFFF9EC); + + /// #fcf1d7 + static Color get subtleColorMango200 => Color(0xFFFCF1D7); + + /// #fae9c3 + static Color get subtleColorMango300 => Color(0xFFFAE9C3); + + /// #f5d68e + static Color get subtleColorMango400 => Color(0xFFF5D68E); + + /// #e0a416 + static Color get subtleColorMango500 => Color(0xFFE0A416); + + /// #5c4102 + static Color get subtleColorMango600 => Color(0xFF5C4102); + + /// #fffbe8 + static Color get subtleColorLemon100 => Color(0xFFFFFBE8); + + /// #fcf5cf + static Color get subtleColorLemon200 => Color(0xFFFCF5CF); + + /// #faefb9 + static Color get subtleColorLemon300 => Color(0xFFFAEFB9); + + /// #f5e282 + static Color get subtleColorLemon400 => Color(0xFFF5E282); + + /// #e0bb00 + static Color get subtleColorLemon500 => Color(0xFFE0BB00); + + /// #574800 + static Color get subtleColorLemon600 => Color(0xFF574800); + + /// #f9fae6 + static Color get subtleColorOlive100 => Color(0xFFF9FAE6); + + /// #f6f7d0 + static Color get subtleColorOlive200 => Color(0xFFF6F7D0); + + /// #f0f2b3 + static Color get subtleColorOlive300 => Color(0xFFF0F2B3); + + /// #dbde83 + static Color get subtleColorOlive400 => Color(0xFFDBDE83); + + /// #adb204 + static Color get subtleColorOlive500 => Color(0xFFADB204); + + /// #4a4c03 + static Color get subtleColorOlive600 => Color(0xFF4A4C03); + + /// #f6f9e6 + static Color get subtleColorLime100 => Color(0xFFF6F9E6); + + /// #eef5ce + static Color get subtleColorLime200 => Color(0xFFEEF5CE); + + /// #e7f0bb + static Color get subtleColorLime300 => Color(0xFFE7F0BB); + + /// #cfdb91 + static Color get subtleColorLime400 => Color(0xFFCFDB91); + + /// #92a822 + static Color get subtleColorLime500 => Color(0xFF92A822); + + /// #414d05 + static Color get subtleColorLime600 => Color(0xFF414D05); + + /// #f4faeb + static Color get subtleColorGrass100 => Color(0xFFF4FAEB); + + /// #e9f5d7 + static Color get subtleColorGrass200 => Color(0xFFE9F5D7); + + /// #def0c5 + static Color get subtleColorGrass300 => Color(0xFFDEF0C5); + + /// #bfd998 + static Color get subtleColorGrass400 => Color(0xFFBFD998); + + /// #75a828 + static Color get subtleColorGrass500 => Color(0xFF75A828); + + /// #334d0c + static Color get subtleColorGrass600 => Color(0xFF334D0C); + + /// #f1faf0 + static Color get subtleColorForest100 => Color(0xFFF1FAF0); + + /// #e2f5df + static Color get subtleColorForest200 => Color(0xFFE2F5DF); + + /// #d7f0d3 + static Color get subtleColorForest300 => Color(0xFFD7F0D3); + + /// #a8d6a1 + static Color get subtleColorForest400 => Color(0xFFA8D6A1); + + /// #49a33b + static Color get subtleColorForest500 => Color(0xFF49A33B); + + /// #1e4f16 + static Color get subtleColorForest600 => Color(0xFF1E4F16); + + /// #f0faf6 + static Color get subtleColorJade100 => Color(0xFFF0FAF6); + + /// #dff5eb + static Color get subtleColorJade200 => Color(0xFFDFF5EB); + + /// #cef0e1 + static Color get subtleColorJade300 => Color(0xFFCEF0E1); + + /// #90d1b5 + static Color get subtleColorJade400 => Color(0xFF90D1B5); + + /// #1c9963 + static Color get subtleColorJade500 => Color(0xFF1C9963); + + /// #075231 + static Color get subtleColorJade600 => Color(0xFF075231); + + /// #f0f9fa + static Color get subtleColorAqua100 => Color(0xFFF0F9FA); + + /// #dff3f5 + static Color get subtleColorAqua200 => Color(0xFFDFF3F5); + + /// #ccecf0 + static Color get subtleColorAqua300 => Color(0xFFCCECF0); + + /// #83ccd4 + static Color get subtleColorAqua400 => Color(0xFF83CCD4); + + /// #008e9e + static Color get subtleColorAqua500 => Color(0xFF008E9E); + + /// #004e57 + static Color get subtleColorAqua600 => Color(0xFF004E57); + + /// #f0f6fa + static Color get subtleColorAzure100 => Color(0xFFF0F6FA); + + /// #e1eef7 + static Color get subtleColorAzure200 => Color(0xFFE1EEF7); + + /// #d3e6f5 + static Color get subtleColorAzure300 => Color(0xFFD3E6F5); + + /// #88c0eb + static Color get subtleColorAzure400 => Color(0xFF88C0EB); + + /// #0877cc + static Color get subtleColorAzure500 => Color(0xFF0877CC); + + /// #154469 + static Color get subtleColorAzure600 => Color(0xFF154469); + + /// #f0f3fa + static Color get subtleColorDenim100 => Color(0xFFF0F3FA); + + /// #e3ebfa + static Color get subtleColorDenim200 => Color(0xFFE3EBFA); + + /// #d7e2f7 + static Color get subtleColorDenim300 => Color(0xFFD7E2F7); + + /// #9ab6ed + static Color get subtleColorDenim400 => Color(0xFF9AB6ED); + + /// #3267d1 + static Color get subtleColorDenim500 => Color(0xFF3267D1); + + /// #223c70 + static Color get subtleColorDenim600 => Color(0xFF223C70); + + /// #f2f2fc + static Color get subtleColorMauve100 => Color(0xFFF2F2FC); + + /// #e6e6fa + static Color get subtleColorMauve200 => Color(0xFFE6E6FA); + + /// #dcdcf7 + static Color get subtleColorMauve300 => Color(0xFFDCDCF7); + + /// #aeaef5 + static Color get subtleColorMauve400 => Color(0xFFAEAEF5); + + /// #5555e0 + static Color get subtleColorMauve500 => Color(0xFF5555E0); + + /// #36366b + static Color get subtleColorMauve600 => Color(0xFF36366B); + + /// #f6f3fc + static Color get subtleColorLavender100 => Color(0xFFF6F3FC); + + /// #ebe3fa + static Color get subtleColorLavender200 => Color(0xFFEBE3FA); + + /// #e4daf7 + static Color get subtleColorLavender300 => Color(0xFFE4DAF7); + + /// #c1aaf0 + static Color get subtleColorLavender400 => Color(0xFFC1AAF0); + + /// #8153db + static Color get subtleColorLavender500 => Color(0xFF8153DB); + + /// #462f75 + static Color get subtleColorLavender600 => Color(0xFF462F75); + + /// #f7f0fa + static Color get subtleColorLilac100 => Color(0xFFF7F0FA); + + /// #f0e1f7 + static Color get subtleColorLilac200 => Color(0xFFF0E1F7); + + /// #edd7f7 + static Color get subtleColorLilac300 => Color(0xFFEDD7F7); + + /// #d3a9e8 + static Color get subtleColorLilac400 => Color(0xFFD3A9E8); + + /// #9e4cc7 + static Color get subtleColorLilac500 => Color(0xFF9E4CC7); + + /// #562d6b + static Color get subtleColorLilac600 => Color(0xFF562D6B); + + /// #faf0fa + static Color get subtleColorMallow100 => Color(0xFFFAF0FA); + + /// #f5e1f4 + static Color get subtleColorMallow200 => Color(0xFFF5E1F4); + + /// #f5d7f4 + static Color get subtleColorMallow300 => Color(0xFFF5D7F4); + + /// #dea4dc + static Color get subtleColorMallow400 => Color(0xFFDEA4DC); + + /// #b240af + static Color get subtleColorMallow500 => Color(0xFFB240AF); + + /// #632861 + static Color get subtleColorMallow600 => Color(0xFF632861); + + /// #f9eff3 + static Color get subtleColorCamellia100 => Color(0xFFF9EFF3); + + /// #f7e1eb + static Color get subtleColorCamellia200 => Color(0xFFF7E1EB); + + /// #f7d7e5 + static Color get subtleColorCamellia300 => Color(0xFFF7D7E5); + + /// #e5a3c0 + static Color get subtleColorCamellia400 => Color(0xFFE5A3C0); + + /// #c24279 + static Color get subtleColorCamellia500 => Color(0xFFC24279); + + /// #6e2343 + static Color get subtleColorCamellia600 => Color(0xFF6E2343); + + /// #f5f5f5 + static Color get subtleColorSmoke100 => Color(0xFFF5F5F5); + + /// #e8e8e8 + static Color get subtleColorSmoke200 => Color(0xFFE8E8E8); + + /// #dedede + static Color get subtleColorSmoke300 => Color(0xFFDEDEDE); + + /// #b8b8b8 + static Color get subtleColorSmoke400 => Color(0xFFB8B8B8); + + /// #6e6e6e + static Color get subtleColorSmoke500 => Color(0xFF6E6E6E); + + /// #404040 + static Color get subtleColorSmoke600 => Color(0xFF404040); + + /// #f2f4f7 + static Color get subtleColorIron100 => Color(0xFFF2F4F7); + + /// #e6e9f0 + static Color get subtleColorIron200 => Color(0xFFE6E9F0); + + /// #dadee5 + static Color get subtleColorIron300 => Color(0xFFDADEE5); + + /// #b0b5bf + static Color get subtleColorIron400 => Color(0xFFB0B5BF); + + /// #666f80 + static Color get subtleColorIron500 => Color(0xFF666F80); + + /// #394152 + static Color get subtleColorIron600 => Color(0xFF394152); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart new file mode 100644 index 0000000000..fe774d3561 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart @@ -0,0 +1,326 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-19T13:45:56.089922 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../shared.dart'; +import 'primitive.dart'; + +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder { + @override + AppFlowyThemeData light() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + inverse: AppFlowyPrimitiveTokens.neutralWhite, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red600, + errorHover: AppFlowyPrimitiveTokens.red700, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral1000, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral900, + greySecondary: AppFlowyPrimitiveTokens.neutral800, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral700, + greyTertiary: AppFlowyPrimitiveTokens.neutral300, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral400, + greyQuaternary: AppFlowyPrimitiveTokens.neutral100, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + primaryHover: AppFlowyPrimitiveTokens.neutral900, + secondary: AppFlowyPrimitiveTokens.neutral600, + secondaryHover: AppFlowyPrimitiveTokens.neutral500, + tertiary: AppFlowyPrimitiveTokens.neutral300, + tertiaryHover: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral100, + quaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey100005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + secondary: AppFlowyPrimitiveTokens.neutral100, + tertiary: AppFlowyPrimitiveTokens.neutral200, + quaternary: AppFlowyPrimitiveTokens.neutral300, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } + + @override + AppFlowyThemeData dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + inverse: AppFlowyPrimitiveTokens.neutral1000, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red500, + errorHover: AppFlowyPrimitiveTokens.red400, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: Color(0xFFFFFFFF), + purpleThickHover: Color(0xFFFFFFFF), + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral100, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral200, + greySecondary: AppFlowyPrimitiveTokens.neutral300, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral400, + greyTertiary: AppFlowyPrimitiveTokens.neutral800, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral700, + greyQuaternary: AppFlowyPrimitiveTokens.neutral1000, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red500, + errorThickHover: AppFlowyPrimitiveTokens.red400, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral100, + primaryHover: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral300, + secondaryHover: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + tertiaryHover: AppFlowyPrimitiveTokens.neutral500, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + quaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey10005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey10010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue400, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red500, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutral900, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral900, + tertiary: AppFlowyPrimitiveTokens.neutral800, + quaternary: AppFlowyPrimitiveTokens.neutral700, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart new file mode 100644 index 0000000000..2b29371433 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart @@ -0,0 +1 @@ +export 'appflowy_default/semantic.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart new file mode 100644 index 0000000000..6ef43076c5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; + +class CustomTheme implements AppFlowyThemeBuilder { + const CustomTheme({ + required this.lightThemeJson, + required this.darkThemeJson, + }); + + final Map lightThemeJson; + final Map darkThemeJson; + + @override + AppFlowyThemeData light() { + // TODO: implement light + throw UnimplementedError(); + } + + @override + AppFlowyThemeData dark() { + // TODO: implement dark + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart new file mode 100644 index 0000000000..c9c3c3adb0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart @@ -0,0 +1,87 @@ +import 'package:appflowy_ui/src/theme/definition/border_radius/border_radius.dart'; +import 'package:appflowy_ui/src/theme/definition/shadow/shadow.dart'; +import 'package:appflowy_ui/src/theme/definition/spacing/spacing.dart'; +import 'package:flutter/material.dart'; + +class AppFlowySpacingConstant { + static const double spacing100 = 4; + static const double spacing200 = 6; + static const double spacing300 = 8; + static const double spacing400 = 12; + static const double spacing500 = 16; + static const double spacing600 = 20; +} + +class AppFlowyBorderRadiusConstant { + static const double radius100 = 4; + static const double radius200 = 6; + static const double radius300 = 8; + static const double radius400 = 12; + static const double radius500 = 16; + static const double radius600 = 20; +} + +class AppFlowySharedTokens { + const AppFlowySharedTokens(); + + static AppFlowyBorderRadius buildBorderRadius() { + return AppFlowyBorderRadius( + xs: AppFlowyBorderRadiusConstant.radius100, + s: AppFlowyBorderRadiusConstant.radius200, + m: AppFlowyBorderRadiusConstant.radius300, + l: AppFlowyBorderRadiusConstant.radius400, + xl: AppFlowyBorderRadiusConstant.radius500, + xxl: AppFlowyBorderRadiusConstant.radius600, + ); + } + + static AppFlowySpacing buildSpacing() { + return AppFlowySpacing( + xs: AppFlowySpacingConstant.spacing100, + s: AppFlowySpacingConstant.spacing200, + m: AppFlowySpacingConstant.spacing300, + l: AppFlowySpacingConstant.spacing400, + xl: AppFlowySpacingConstant.spacing500, + xxl: AppFlowySpacingConstant.spacing600, + ); + } + + static AppFlowyShadow buildShadow( + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x1F000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x1F000000), + ), + ], + ), + Brightness.dark => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x7A000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x7A000000), + ), + ], + ), + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart new file mode 100644 index 0000000000..fb07a5fe64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart @@ -0,0 +1,17 @@ +class AppFlowyBorderRadius { + const AppFlowyBorderRadius({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart new file mode 100644 index 0000000000..c7324c34fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBackgroundColorScheme { + const AppFlowyBackgroundColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + + AppFlowyBackgroundColorScheme lerp( + AppFlowyBackgroundColorScheme other, + double t, + ) { + return AppFlowyBackgroundColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart new file mode 100644 index 0000000000..28eee5b145 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBorderColorScheme { + const AppFlowyBorderColorScheme({ + required this.greyPrimary, + required this.greyPrimaryHover, + required this.greySecondary, + required this.greySecondaryHover, + required this.greyTertiary, + required this.greyTertiaryHover, + required this.greyQuaternary, + required this.greyQuaternaryHover, + required this.transparent, + required this.themeThick, + required this.themeThickHover, + required this.infoThick, + required this.infoThickHover, + required this.successThick, + required this.successThickHover, + required this.warningThick, + required this.warningThickHover, + required this.errorThick, + required this.errorThickHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color greyPrimary; + final Color greyPrimaryHover; + final Color greySecondary; + final Color greySecondaryHover; + final Color greyTertiary; + final Color greyTertiaryHover; + final Color greyQuaternary; + final Color greyQuaternaryHover; + final Color transparent; + final Color themeThick; + final Color themeThickHover; + final Color infoThick; + final Color infoThickHover; + final Color successThick; + final Color successThickHover; + final Color warningThick; + final Color warningThickHover; + final Color errorThick; + final Color errorThickHover; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyBorderColorScheme lerp( + AppFlowyBorderColorScheme other, + double t, + ) { + return AppFlowyBorderColorScheme( + greyPrimary: Color.lerp(greyPrimary, other.greyPrimary, t)!, + greyPrimaryHover: + Color.lerp(greyPrimaryHover, other.greyPrimaryHover, t)!, + greySecondary: Color.lerp(greySecondary, other.greySecondary, t)!, + greySecondaryHover: + Color.lerp(greySecondaryHover, other.greySecondaryHover, t)!, + greyTertiary: Color.lerp(greyTertiary, other.greyTertiary, t)!, + greyTertiaryHover: + Color.lerp(greyTertiaryHover, other.greyTertiaryHover, t)!, + greyQuaternary: Color.lerp(greyQuaternary, other.greyQuaternary, t)!, + greyQuaternaryHover: + Color.lerp(greyQuaternaryHover, other.greyQuaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart new file mode 100644 index 0000000000..4140f6924a --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBrandColorScheme { + const AppFlowyBrandColorScheme({ + required this.skyline, + required this.aqua, + required this.violet, + required this.amethyst, + required this.berry, + required this.coral, + required this.golden, + required this.amber, + required this.lemon, + }); + + final Color skyline; + final Color aqua; + final Color violet; + final Color amethyst; + final Color berry; + final Color coral; + final Color golden; + final Color amber; + final Color lemon; + + AppFlowyBrandColorScheme lerp( + AppFlowyBrandColorScheme other, + double t, + ) { + return AppFlowyBrandColorScheme( + skyline: Color.lerp(skyline, other.skyline, t)!, + aqua: Color.lerp(aqua, other.aqua, t)!, + violet: Color.lerp(violet, other.violet, t)!, + amethyst: Color.lerp(amethyst, other.amethyst, t)!, + berry: Color.lerp(berry, other.berry, t)!, + coral: Color.lerp(coral, other.coral, t)!, + golden: Color.lerp(golden, other.golden, t)!, + amber: Color.lerp(amber, other.amber, t)!, + lemon: Color.lerp(lemon, other.lemon, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart new file mode 100644 index 0000000000..01952e1461 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart @@ -0,0 +1,8 @@ +export 'background_color_scheme.dart'; +export 'border_color_scheme.dart'; +export 'brand_color_scheme.dart'; +export 'fill_color_scheme.dart'; +export 'icon_color_scheme.dart'; +export 'other_color_scheme.dart'; +export 'surface_color_scheme.dart'; +export 'text_color_scheme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart new file mode 100644 index 0000000000..3faac64dfc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +class AppFlowyFillColorScheme { + const AppFlowyFillColorScheme({ + required this.primary, + required this.primaryHover, + required this.secondary, + required this.secondaryHover, + required this.tertiary, + required this.tertiaryHover, + required this.quaternary, + required this.quaternaryHover, + required this.transparent, + required this.primaryAlpha5, + required this.primaryAlpha5Hover, + required this.primaryAlpha80, + required this.primaryAlpha80Hover, + required this.white, + required this.whiteAlpha, + required this.whiteAlphaHover, + required this.black, + required this.themeLight, + required this.themeLightHover, + required this.themeThick, + required this.themeThickHover, + required this.themeSelect, + required this.infoLight, + required this.infoLightHover, + required this.infoThick, + required this.infoThickHover, + required this.successLight, + required this.successLightHover, + required this.successThick, + required this.successThickHover, + required this.warningLight, + required this.warningLightHover, + required this.warningThick, + required this.warningThickHover, + required this.errorLight, + required this.errorLightHover, + required this.errorThick, + required this.errorThickHover, + required this.errorSelect, + required this.purpleLight, + required this.purpleLightHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color primaryHover; + final Color secondary; + final Color secondaryHover; + final Color tertiary; + final Color tertiaryHover; + final Color quaternary; + final Color quaternaryHover; + final Color transparent; + final Color primaryAlpha5; + final Color primaryAlpha5Hover; + final Color primaryAlpha80; + final Color primaryAlpha80Hover; + final Color white; + final Color whiteAlpha; + final Color whiteAlphaHover; + final Color black; + final Color themeLight; + final Color themeLightHover; + final Color themeThick; + final Color themeThickHover; + final Color themeSelect; + final Color infoLight; + final Color infoLightHover; + final Color infoThick; + final Color infoThickHover; + final Color successLight; + final Color successLightHover; + final Color successThick; + final Color successThickHover; + final Color warningLight; + final Color warningLightHover; + final Color warningThick; + final Color warningThickHover; + final Color errorLight; + final Color errorLightHover; + final Color errorThick; + final Color errorThickHover; + final Color errorSelect; + final Color purpleLight; + final Color purpleLightHover; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyFillColorScheme lerp( + AppFlowyFillColorScheme other, + double t, + ) { + return AppFlowyFillColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + tertiaryHover: Color.lerp(tertiaryHover, other.tertiaryHover, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + quaternaryHover: Color.lerp(quaternaryHover, other.quaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + primaryAlpha5: Color.lerp(primaryAlpha5, other.primaryAlpha5, t)!, + primaryAlpha5Hover: + Color.lerp(primaryAlpha5Hover, other.primaryAlpha5Hover, t)!, + primaryAlpha80: Color.lerp(primaryAlpha80, other.primaryAlpha80, t)!, + primaryAlpha80Hover: + Color.lerp(primaryAlpha80Hover, other.primaryAlpha80Hover, t)!, + white: Color.lerp(white, other.white, t)!, + whiteAlpha: Color.lerp(whiteAlpha, other.whiteAlpha, t)!, + whiteAlphaHover: Color.lerp(whiteAlphaHover, other.whiteAlphaHover, t)!, + black: Color.lerp(black, other.black, t)!, + themeLight: Color.lerp(themeLight, other.themeLight, t)!, + themeLightHover: Color.lerp(themeLightHover, other.themeLightHover, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + themeSelect: Color.lerp(themeSelect, other.themeSelect, t)!, + infoLight: Color.lerp(infoLight, other.infoLight, t)!, + infoLightHover: Color.lerp(infoLightHover, other.infoLightHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successLight: Color.lerp(successLight, other.successLight, t)!, + successLightHover: + Color.lerp(successLightHover, other.successLightHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningLight: Color.lerp(warningLight, other.warningLight, t)!, + warningLightHover: + Color.lerp(warningLightHover, other.warningLightHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorLight: Color.lerp(errorLight, other.errorLight, t)!, + errorLightHover: Color.lerp(errorLightHover, other.errorLightHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + errorSelect: Color.lerp(errorSelect, other.errorSelect, t)!, + purpleLight: Color.lerp(purpleLight, other.purpleLight, t)!, + purpleLightHover: + Color.lerp(purpleLightHover, other.purpleLightHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart new file mode 100644 index 0000000000..efe59b8b99 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class AppFlowyIconColorScheme { + const AppFlowyIconColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.white, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color white; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyIconColorScheme lerp( + AppFlowyIconColorScheme other, + double t, + ) { + return AppFlowyIconColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + white: Color.lerp(white, other.white, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart new file mode 100644 index 0000000000..9bb21e54e6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart @@ -0,0 +1,18 @@ +import 'dart:ui'; + +class AppFlowyOtherColorsColorScheme { + const AppFlowyOtherColorsColorScheme({ + required this.textHighlight, + }); + + final Color textHighlight; + + AppFlowyOtherColorsColorScheme lerp( + AppFlowyOtherColorsColorScheme other, + double t, + ) { + return AppFlowyOtherColorsColorScheme( + textHighlight: Color.lerp(textHighlight, other.textHighlight, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart new file mode 100644 index 0000000000..67be450a04 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class AppFlowySurfaceColorScheme { + const AppFlowySurfaceColorScheme({ + required this.primary, + required this.overlay, + }); + + final Color primary; + final Color overlay; + + AppFlowySurfaceColorScheme lerp( + AppFlowySurfaceColorScheme other, + double t, + ) { + return AppFlowySurfaceColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + overlay: Color.lerp(overlay, other.overlay, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart new file mode 100644 index 0000000000..17e1f057ce --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class AppFlowyTextColorScheme { + const AppFlowyTextColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.inverse, + required this.onFill, + required this.theme, + required this.themeHover, + required this.action, + required this.actionHover, + required this.info, + required this.infoHover, + required this.success, + required this.successHover, + required this.warning, + required this.warningHover, + required this.error, + required this.errorHover, + required this.purple, + required this.purpleHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color inverse; + final Color onFill; + final Color theme; + final Color themeHover; + final Color action; + final Color actionHover; + final Color info; + final Color infoHover; + final Color success; + final Color successHover; + final Color warning; + final Color warningHover; + final Color error; + final Color errorHover; + final Color purple; + final Color purpleHover; + + AppFlowyTextColorScheme lerp( + AppFlowyTextColorScheme other, + double t, + ) { + return AppFlowyTextColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + inverse: Color.lerp(inverse, other.inverse, t)!, + onFill: Color.lerp(onFill, other.onFill, t)!, + theme: Color.lerp(theme, other.theme, t)!, + themeHover: Color.lerp(themeHover, other.themeHover, t)!, + action: Color.lerp(action, other.action, t)!, + actionHover: Color.lerp(actionHover, other.actionHover, t)!, + info: Color.lerp(info, other.info, t)!, + infoHover: Color.lerp(infoHover, other.infoHover, t)!, + success: Color.lerp(success, other.success, t)!, + successHover: Color.lerp(successHover, other.successHover, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningHover: Color.lerp(warningHover, other.warningHover, t)!, + error: Color.lerp(error, other.error, t)!, + errorHover: Color.lerp(errorHover, other.errorHover, t)!, + purple: Color.lerp(purple, other.purple, t)!, + purpleHover: Color.lerp(purpleHover, other.purpleHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart new file mode 100644 index 0000000000..457b86265e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +class AppFlowyShadow { + AppFlowyShadow({ + required this.small, + required this.medium, + }); + + final List small; + final List medium; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart new file mode 100644 index 0000000000..ea90784db3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart @@ -0,0 +1,17 @@ +class AppFlowySpacing { + const AppFlowySpacing({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart new file mode 100644 index 0000000000..3cdf267fe0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart @@ -0,0 +1,517 @@ +import 'package:flutter/widgets.dart'; + +abstract class TextThemeType { + const TextThemeType(); + + TextStyle standard({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle enhanced({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle prominent({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle underline({ + String family = '', + Color? color, + FontWeight? weight, + }); +} + +class TextThemeHeading1 extends TextThemeType { + const TextThemeHeading1(); + + @override + TextStyle standard({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.bold, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + required double fontSize, + required double height, + TextDecoration decoration = TextDecoration.none, + Color? color, + FontWeight weight = FontWeight.bold, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading2 extends TextThemeType { + const TextThemeHeading2(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 32 / 24, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading3 extends TextThemeType { + const TextThemeHeading3(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading4 extends TextThemeType { + const TextThemeHeading4(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 16, + double height = 22 / 16, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeadline extends TextThemeType { + const TextThemeHeadline(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 36 / 24, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.normal, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeTitle extends TextThemeType { + const TextThemeTitle(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeBody extends TextThemeType { + const TextThemeBody(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 14, + double height = 20 / 14, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeCaption extends TextThemeType { + const TextThemeCaption(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 12, + double height = 16 / 12, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart new file mode 100644 index 0000000000..d96ca0f557 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_ui/src/theme/definition/text_style/base/default_text_style.dart'; + +class AppFlowyBaseTextStyle { + const AppFlowyBaseTextStyle({ + this.heading1 = const TextThemeHeading1(), + this.heading2 = const TextThemeHeading2(), + this.heading3 = const TextThemeHeading3(), + this.heading4 = const TextThemeHeading4(), + this.headline = const TextThemeHeadline(), + this.title = const TextThemeTitle(), + this.body = const TextThemeBody(), + this.caption = const TextThemeCaption(), + }); + + final TextThemeType heading1; + final TextThemeType heading2; + final TextThemeType heading3; + final TextThemeType heading4; + final TextThemeType headline; + final TextThemeType title; + final TextThemeType body; + final TextThemeType caption; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart new file mode 100644 index 0000000000..515e6b2ecf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart @@ -0,0 +1,86 @@ +import 'border_radius/border_radius.dart'; +import 'color_scheme/color_scheme.dart'; +import 'shadow/shadow.dart'; +import 'spacing/spacing.dart'; +import 'text_style/text_style.dart'; + +/// [AppFlowyThemeData] defines the structure of the design system, and contains +/// the data that all child widgets will have access to. +class AppFlowyThemeData { + const AppFlowyThemeData({ + required this.textColorScheme, + required this.textStyle, + required this.iconColorScheme, + required this.borderColorScheme, + required this.backgroundColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.shadow, + required this.brandColorScheme, + required this.otherColorsColorScheme, + }); + + final AppFlowyTextColorScheme textColorScheme; + + final AppFlowyBaseTextStyle textStyle; + + final AppFlowyIconColorScheme iconColorScheme; + + final AppFlowyBorderColorScheme borderColorScheme; + + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + final AppFlowyFillColorScheme fillColorScheme; + + final AppFlowySurfaceColorScheme surfaceColorScheme; + + final AppFlowyBorderRadius borderRadius; + + final AppFlowySpacing spacing; + + final AppFlowyShadow shadow; + + final AppFlowyBrandColorScheme brandColorScheme; + + final AppFlowyOtherColorsColorScheme otherColorsColorScheme; + + static AppFlowyThemeData lerp( + AppFlowyThemeData begin, + AppFlowyThemeData end, + double t, + ) { + return AppFlowyThemeData( + textColorScheme: begin.textColorScheme.lerp(end.textColorScheme, t), + textStyle: end.textStyle, + iconColorScheme: begin.iconColorScheme.lerp(end.iconColorScheme, t), + borderColorScheme: begin.borderColorScheme.lerp(end.borderColorScheme, t), + backgroundColorScheme: + begin.backgroundColorScheme.lerp(end.backgroundColorScheme, t), + fillColorScheme: begin.fillColorScheme.lerp(end.fillColorScheme, t), + surfaceColorScheme: + begin.surfaceColorScheme.lerp(end.surfaceColorScheme, t), + borderRadius: end.borderRadius, + spacing: end.spacing, + shadow: end.shadow, + brandColorScheme: begin.brandColorScheme.lerp(end.brandColorScheme, t), + otherColorsColorScheme: + begin.otherColorsColorScheme.lerp(end.otherColorsColorScheme, t), + ); + } +} + +/// [AppFlowyThemeBuilder] is used to build the light and dark themes. Extend +/// this class to create a built-in theme, or use the [CustomTheme] class to +/// create a custom theme from JSON data. +/// +/// See also: +/// +/// - [AppFlowyThemeData] for the main theme data class. +abstract class AppFlowyThemeBuilder { + const AppFlowyThemeBuilder(); + + AppFlowyThemeData light(); + AppFlowyThemeData dark(); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000000..000b7a0372 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart @@ -0,0 +1,8 @@ +export 'appflowy_theme.dart'; +export 'data/built_in_themes.dart'; +export 'definition/border_radius/border_radius.dart'; +export 'definition/color_scheme/color_scheme.dart'; +export 'definition/theme_data.dart'; +export 'definition/spacing/spacing.dart'; +export 'definition/shadow/shadow.dart'; +export 'definition/text_style/text_style.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml new file mode 100644 index 0000000000..2f5633bb1e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml @@ -0,0 +1,17 @@ +name: appflowy_ui +description: "A Flutter package for AppFlowy UI components and widgets" +version: 1.0.0 +homepage: https://github.com/appflowy-io/appflowy + +environment: + sdk: ^3.6.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_lints: ^5.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json new file mode 100644 index 0000000000..c46354b599 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json @@ -0,0 +1,984 @@ +{ + "Neutral": { + "100": { + "$type": "color", + "$value": "#f8faff" + }, + "200": { + "$type": "color", + "$value": "#e4e8f5" + }, + "300": { + "$type": "color", + "$value": "#ced3e6" + }, + "400": { + "$type": "color", + "$value": "#b5bbd3" + }, + "500": { + "$type": "color", + "$value": "#989eb7" + }, + "600": { + "$type": "color", + "$value": "#6f748c" + }, + "700": { + "$type": "color", + "$value": "#54596e" + }, + "800": { + "$type": "color", + "$value": "#3c3f4e" + }, + "900": { + "$type": "color", + "$value": "#272930" + }, + "1000": { + "$type": "color", + "$value": "#21232a" + }, + "black": { + "$type": "color", + "$value": "#000000" + }, + "alpha-black-60": { + "$type": "color", + "$value": "#00000099" + }, + "white": { + "$type": "color", + "$value": "#ffffff" + }, + "alpha-white-0": { + "$type": "color", + "$value": "#ffffff00" + }, + "alpha-white-20": { + "$type": "color", + "$value": "#ffffff33" + }, + "alpha-white-30": { + "$type": "color", + "$value": "#ffffff4d" + }, + "alpha-grey-100-05": { + "$type": "color", + "$value": "#f9fafd0d" + }, + "alpha-grey-100-10": { + "$type": "color", + "$value": "#f9fafd1a" + }, + "alpha-grey-1000-05": { + "$type": "color", + "$value": "#1f23290d" + }, + "alpha-grey-1000-10": { + "$type": "color", + "$value": "#1f23291a" + }, + "alpha-grey-1000-70": { + "$type": "color", + "$value": "#1f2329b2" + }, + "alpha-grey-1000-80": { + "$type": "color", + "$value": "#1f2329cc" + } + }, + "Blue": { + "100": { + "$type": "color", + "$value": "#e3f6ff" + }, + "200": { + "$type": "color", + "$value": "#a9e2ff" + }, + "300": { + "$type": "color", + "$value": "#80d2ff" + }, + "400": { + "$type": "color", + "$value": "#4ec1ff" + }, + "500": { + "$type": "color", + "$value": "#00b5ff" + }, + "600": { + "$type": "color", + "$value": "#0092d6" + }, + "700": { + "$type": "color", + "$value": "#0078c0" + }, + "800": { + "$type": "color", + "$value": "#0065a9" + }, + "900": { + "$type": "color", + "$value": "#00508f" + }, + "1000": { + "$type": "color", + "$value": "#003c77" + }, + "alpha-blue-500-15": { + "$type": "color", + "$value": "#00b5ff26" + } + }, + "Green": { + "100": { + "$type": "color", + "$value": "#ecf9f5" + }, + "200": { + "$type": "color", + "$value": "#c3e5d8" + }, + "300": { + "$type": "color", + "$value": "#9ad1bc" + }, + "400": { + "$type": "color", + "$value": "#71bd9f" + }, + "500": { + "$type": "color", + "$value": "#48a982" + }, + "600": { + "$type": "color", + "$value": "#248569" + }, + "700": { + "$type": "color", + "$value": "#29725d" + }, + "800": { + "$type": "color", + "$value": "#2e6050" + }, + "900": { + "$type": "color", + "$value": "#305548" + }, + "1000": { + "$type": "color", + "$value": "#305244" + } + }, + "Purple": { + "100": { + "$type": "color", + "$value": "#f1e0ff" + }, + "200": { + "$type": "color", + "$value": "#e1b3ff" + }, + "300": { + "$type": "color", + "$value": "#d185ff" + }, + "400": { + "$type": "color", + "$value": "#bc58ff" + }, + "500": { + "$type": "color", + "$value": "#9327ff" + }, + "600": { + "$type": "color", + "$value": "#7a1dcc" + }, + "700": { + "$type": "color", + "$value": "#6617b3" + }, + "800": { + "$type": "color", + "$value": "#55138f" + }, + "900": { + "$type": "color", + "$value": "#470c72" + }, + "1000": { + "$type": "color", + "$value": "#380758" + } + }, + "Magenta": { + "100": { + "$type": "color", + "$value": "#ffe5ef" + }, + "200": { + "$type": "color", + "$value": "#ffb8d1" + }, + "300": { + "$type": "color", + "$value": "#ff8ab2" + }, + "400": { + "$type": "color", + "$value": "#ff5c93" + }, + "500": { + "$type": "color", + "$value": "#fb006d" + }, + "600": { + "$type": "color", + "$value": "#d2005f" + }, + "700": { + "$type": "color", + "$value": "#d2005f" + }, + "800": { + "$type": "color", + "$value": "#850040" + }, + "900": { + "$type": "color", + "$value": "#610031" + }, + "1000": { + "$type": "color", + "$value": "#400022" + } + }, + "Red": { + "100": { + "$type": "color", + "$value": "#ffd2dd" + }, + "200": { + "$type": "color", + "$value": "#ffa5b4" + }, + "300": { + "$type": "color", + "$value": "#ff7d87" + }, + "400": { + "$type": "color", + "$value": "#ff5050" + }, + "500": { + "$type": "color", + "$value": "#f33641" + }, + "600": { + "$type": "color", + "$value": "#e71d32" + }, + "700": { + "$type": "color", + "$value": "#ad1625" + }, + "800": { + "$type": "color", + "$value": "#8c101c" + }, + "900": { + "$type": "color", + "$value": "#6e0a1e" + }, + "1000": { + "$type": "color", + "$value": "#4c0a17" + }, + "alpha-red-500-10": { + "$type": "color", + "$value": "#f336411a" + } + }, + "Orange": { + "100": { + "$type": "color", + "$value": "#fff3d5" + }, + "200": { + "$type": "color", + "$value": "#ffe4ab" + }, + "300": { + "$type": "color", + "$value": "#ffd181" + }, + "400": { + "$type": "color", + "$value": "#ffbe62" + }, + "500": { + "$type": "color", + "$value": "#ffa02e" + }, + "600": { + "$type": "color", + "$value": "#db7e21" + }, + "700": { + "$type": "color", + "$value": "#b75f17" + }, + "800": { + "$type": "color", + "$value": "#93450e" + }, + "900": { + "$type": "color", + "$value": "#7a3108" + }, + "1000": { + "$type": "color", + "$value": "#602706" + } + }, + "Yellow": { + "100": { + "$type": "color", + "$value": "#fff9b2" + }, + "200": { + "$type": "color", + "$value": "#ffec66" + }, + "300": { + "$type": "color", + "$value": "#ffdf1a" + }, + "400": { + "$type": "color", + "$value": "#ffcc00" + }, + "500": { + "$type": "color", + "$value": "#ffce00" + }, + "600": { + "$type": "color", + "$value": "#e6b800" + }, + "700": { + "$type": "color", + "$value": "#cc9f00" + }, + "800": { + "$type": "color", + "$value": "#b38a00" + }, + "900": { + "$type": "color", + "$value": "#9a7500" + }, + "1000": { + "$type": "color", + "$value": "#7f6200" + } + }, + "Subtle_Color": { + "Rose": { + "100": { + "$type": "color", + "$value": "#fcf2f2" + }, + "200": { + "$type": "color", + "$value": "#fae3e3" + }, + "300": { + "$type": "color", + "$value": "#fad9d9" + }, + "400": { + "$type": "color", + "$value": "#edadad" + }, + "500": { + "$type": "color", + "$value": "#cc4e4e" + }, + "600": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "100": { + "$type": "color", + "$value": "#fcf4f0" + }, + "200": { + "$type": "color", + "$value": "#fae8de" + }, + "300": { + "$type": "color", + "$value": "#fadfd2" + }, + "400": { + "$type": "color", + "$value": "#f0bda3" + }, + "500": { + "$type": "color", + "$value": "#d67240" + }, + "600": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "100": { + "$type": "color", + "$value": "#fff7ed" + }, + "200": { + "$type": "color", + "$value": "#fcedd9" + }, + "300": { + "$type": "color", + "$value": "#fae5ca" + }, + "400": { + "$type": "color", + "$value": "#f2cb99" + }, + "500": { + "$type": "color", + "$value": "#db8f2c" + }, + "600": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "100": { + "$type": "color", + "$value": "#fff9ec" + }, + "200": { + "$type": "color", + "$value": "#fcf1d7" + }, + "300": { + "$type": "color", + "$value": "#fae9c3" + }, + "400": { + "$type": "color", + "$value": "#f5d68e" + }, + "500": { + "$type": "color", + "$value": "#e0a416" + }, + "600": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "100": { + "$type": "color", + "$value": "#fffbe8" + }, + "200": { + "$type": "color", + "$value": "#fcf5cf" + }, + "300": { + "$type": "color", + "$value": "#faefb9" + }, + "400": { + "$type": "color", + "$value": "#f5e282" + }, + "500": { + "$type": "color", + "$value": "#e0bb00" + }, + "600": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "100": { + "$type": "color", + "$value": "#f9fae6" + }, + "200": { + "$type": "color", + "$value": "#f6f7d0" + }, + "300": { + "$type": "color", + "$value": "#f0f2b3" + }, + "400": { + "$type": "color", + "$value": "#dbde83" + }, + "500": { + "$type": "color", + "$value": "#adb204" + }, + "600": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "100": { + "$type": "color", + "$value": "#f6f9e6" + }, + "200": { + "$type": "color", + "$value": "#eef5ce" + }, + "300": { + "$type": "color", + "$value": "#e7f0bb" + }, + "400": { + "$type": "color", + "$value": "#cfdb91" + }, + "500": { + "$type": "color", + "$value": "#92a822" + }, + "600": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "100": { + "$type": "color", + "$value": "#f4faeb" + }, + "200": { + "$type": "color", + "$value": "#e9f5d7" + }, + "300": { + "$type": "color", + "$value": "#def0c5" + }, + "400": { + "$type": "color", + "$value": "#bfd998" + }, + "500": { + "$type": "color", + "$value": "#75a828" + }, + "600": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "100": { + "$type": "color", + "$value": "#f1faf0" + }, + "200": { + "$type": "color", + "$value": "#e2f5df" + }, + "300": { + "$type": "color", + "$value": "#d7f0d3" + }, + "400": { + "$type": "color", + "$value": "#a8d6a1" + }, + "500": { + "$type": "color", + "$value": "#49a33b" + }, + "600": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "100": { + "$type": "color", + "$value": "#f0faf6" + }, + "200": { + "$type": "color", + "$value": "#dff5eb" + }, + "300": { + "$type": "color", + "$value": "#cef0e1" + }, + "400": { + "$type": "color", + "$value": "#90d1b5" + }, + "500": { + "$type": "color", + "$value": "#1c9963" + }, + "600": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "100": { + "$type": "color", + "$value": "#f0f9fa" + }, + "200": { + "$type": "color", + "$value": "#dff3f5" + }, + "300": { + "$type": "color", + "$value": "#ccecf0" + }, + "400": { + "$type": "color", + "$value": "#83ccd4" + }, + "500": { + "$type": "color", + "$value": "#008e9e" + }, + "600": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "100": { + "$type": "color", + "$value": "#f0f6fa" + }, + "200": { + "$type": "color", + "$value": "#e1eef7" + }, + "300": { + "$type": "color", + "$value": "#d3e6f5" + }, + "400": { + "$type": "color", + "$value": "#88c0eb" + }, + "500": { + "$type": "color", + "$value": "#0877cc" + }, + "600": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "100": { + "$type": "color", + "$value": "#f0f3fa" + }, + "200": { + "$type": "color", + "$value": "#e3ebfa" + }, + "300": { + "$type": "color", + "$value": "#d7e2f7" + }, + "400": { + "$type": "color", + "$value": "#9ab6ed" + }, + "500": { + "$type": "color", + "$value": "#3267d1" + }, + "600": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "100": { + "$type": "color", + "$value": "#f2f2fc" + }, + "200": { + "$type": "color", + "$value": "#e6e6fa" + }, + "300": { + "$type": "color", + "$value": "#dcdcf7" + }, + "400": { + "$type": "color", + "$value": "#aeaef5" + }, + "500": { + "$type": "color", + "$value": "#5555e0" + }, + "600": { + "$type": "color", + "$value": "#36366b" + } + }, + "Lavender": { + "100": { + "$type": "color", + "$value": "#f6f3fc" + }, + "200": { + "$type": "color", + "$value": "#ebe3fa" + }, + "300": { + "$type": "color", + "$value": "#e4daf7" + }, + "400": { + "$type": "color", + "$value": "#c1aaf0" + }, + "500": { + "$type": "color", + "$value": "#8153db" + }, + "600": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "100": { + "$type": "color", + "$value": "#f7f0fa" + }, + "200": { + "$type": "color", + "$value": "#f0e1f7" + }, + "300": { + "$type": "color", + "$value": "#edd7f7" + }, + "400": { + "$type": "color", + "$value": "#d3a9e8" + }, + "500": { + "$type": "color", + "$value": "#9e4cc7" + }, + "600": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "100": { + "$type": "color", + "$value": "#faf0fa" + }, + "200": { + "$type": "color", + "$value": "#f5e1f4" + }, + "300": { + "$type": "color", + "$value": "#f5d7f4" + }, + "400": { + "$type": "color", + "$value": "#dea4dc" + }, + "500": { + "$type": "color", + "$value": "#b240af" + }, + "600": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "100": { + "$type": "color", + "$value": "#f9eff3" + }, + "200": { + "$type": "color", + "$value": "#f7e1eb" + }, + "300": { + "$type": "color", + "$value": "#f7d7e5" + }, + "400": { + "$type": "color", + "$value": "#e5a3c0" + }, + "500": { + "$type": "color", + "$value": "#c24279" + }, + "600": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "100": { + "$type": "color", + "$value": "#f5f5f5" + }, + "200": { + "$type": "color", + "$value": "#e8e8e8" + }, + "300": { + "$type": "color", + "$value": "#dedede" + }, + "400": { + "$type": "color", + "$value": "#b8b8b8" + }, + "500": { + "$type": "color", + "$value": "#6e6e6e" + }, + "600": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "100": { + "$type": "color", + "$value": "#f2f4f7" + }, + "200": { + "$type": "color", + "$value": "#e6e9f0" + }, + "300": { + "$type": "color", + "$value": "#dadee5" + }, + "400": { + "$type": "color", + "$value": "#b0b5bf" + }, + "500": { + "$type": "color", + "$value": "#666f80" + }, + "600": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Spacing": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + }, + "Border-Radius": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json new file mode 100644 index 0000000000..99d266c008 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "#ffffff" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "#ffffff" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.400}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.700}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "#fcf2f2" + }, + "rose-light-2": { + "$type": "color", + "$value": "#fae3e3" + }, + "rose-light-3": { + "$type": "color", + "$value": "#fad9d9" + }, + "rose-thick-1": { + "$type": "color", + "$value": "#edadad" + }, + "rose-thick-2": { + "$type": "color", + "$value": "#cc4e4e" + }, + "rose-thick-3": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "#fcf4f0" + }, + "papaya-light-2": { + "$type": "color", + "$value": "#fae8de" + }, + "papaya-light-3": { + "$type": "color", + "$value": "#fadfd2" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "#f0bda3" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "#d67240" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "#fff7ed" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "#fcedd9" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "#fae5ca" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "#f2cb99" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "#db8f2c" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "#fff9ec" + }, + "mango-light-2": { + "$type": "color", + "$value": "#fcf1d7" + }, + "mango-light-3": { + "$type": "color", + "$value": "#fae9c3" + }, + "mango-thick-1": { + "$type": "color", + "$value": "#f5d68e" + }, + "mango-thick-2": { + "$type": "color", + "$value": "#e0a416" + }, + "mango-thick-3": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "#fffbe8" + }, + "lemon-light-2": { + "$type": "color", + "$value": "#fcf5cf" + }, + "lemon-light-3": { + "$type": "color", + "$value": "#faefb9" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "#f5e282" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "#e0bb00" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "#f9fae6" + }, + "olive-light-2": { + "$type": "color", + "$value": "#f6f7d0" + }, + "olive-light-3": { + "$type": "color", + "$value": "#f0f2b3" + }, + "olive-thick-1": { + "$type": "color", + "$value": "#dbde83" + }, + "olive-thick-2": { + "$type": "color", + "$value": "#adb204" + }, + "olive-thick-3": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "#f6f9e6" + }, + "lime-light-2": { + "$type": "color", + "$value": "#eef5ce" + }, + "lime-light-3": { + "$type": "color", + "$value": "#e7f0bb" + }, + "lime-thick-1": { + "$type": "color", + "$value": "#cfdb91" + }, + "lime-thick-2": { + "$type": "color", + "$value": "#92a822" + }, + "lime-thick-3": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "#f4faeb" + }, + "grass-light-2": { + "$type": "color", + "$value": "#e9f5d7" + }, + "grass-light-3": { + "$type": "color", + "$value": "#def0c5" + }, + "grass-thick-1": { + "$type": "color", + "$value": "#bfd998" + }, + "grass-thick-2": { + "$type": "color", + "$value": "#75a828" + }, + "grass-thick-3": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "#f1faf0" + }, + "forest-light-2": { + "$type": "color", + "$value": "#e2f5df" + }, + "forest-light-3": { + "$type": "color", + "$value": "#d7f0d3" + }, + "forest-thick-1": { + "$type": "color", + "$value": "#a8d6a1" + }, + "forest-thick-2": { + "$type": "color", + "$value": "#49a33b" + }, + "forest-thick-3": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "#f0faf6" + }, + "jade-light-2": { + "$type": "color", + "$value": "#dff5eb" + }, + "jade-light-3": { + "$type": "color", + "$value": "#cef0e1" + }, + "jade-thick-1": { + "$type": "color", + "$value": "#90d1b5" + }, + "jade-thick-2": { + "$type": "color", + "$value": "#1c9963" + }, + "jade-thick-3": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "#f0f9fa" + }, + "aqua-light-2": { + "$type": "color", + "$value": "#dff3f5" + }, + "aqua-light-3": { + "$type": "color", + "$value": "#ccecf0" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "#83ccd4" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "#008e9e" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "#f0f6fa" + }, + "azure-light-2": { + "$type": "color", + "$value": "#e1eef7" + }, + "azure-light-3": { + "$type": "color", + "$value": "#d3e6f5" + }, + "azure-thick-1": { + "$type": "color", + "$value": "#88c0eb" + }, + "azure-thick-2": { + "$type": "color", + "$value": "#0877cc" + }, + "azure-thick-3": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "#f0f3fa" + }, + "denim-light-2": { + "$type": "color", + "$value": "#e3ebfa" + }, + "denim-light-3": { + "$type": "color", + "$value": "#d7e2f7" + }, + "denim-thick-1": { + "$type": "color", + "$value": "#9ab6ed" + }, + "denim-thick-2": { + "$type": "color", + "$value": "#3267d1" + }, + "denim-thick-3": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "#f2f2fc" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "#5555e0" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "#36366b" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "#aeaef5" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "#f6f3fc" + }, + "lavender-light-2": { + "$type": "color", + "$value": "#ebe3fa" + }, + "lavender-light-3": { + "$type": "color", + "$value": "#e4daf7" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "#c1aaf0" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "#8153db" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "#f7f0fa" + }, + "liliac-light-2": { + "$type": "color", + "$value": "#f0e1f7" + }, + "liliac-light-3": { + "$type": "color", + "$value": "#edd7f7" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "#d3a9e8" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "#9e4cc7" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "#faf0fa" + }, + "mallow-light-2": { + "$type": "color", + "$value": "#f5e1f4" + }, + "mallow-light-3": { + "$type": "color", + "$value": "#f5d7f4" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "#dea4dc" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "#b240af" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "#f9eff3" + }, + "camellia-light-2": { + "$type": "color", + "$value": "#f7e1eb" + }, + "camellia-light-3": { + "$type": "color", + "$value": "#f7d7e5" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "#e5a3c0" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "#c24279" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "#f5f5f5" + }, + "smoke-light-2": { + "$type": "color", + "$value": "#e8e8e8" + }, + "smoke-light-3": { + "$type": "color", + "$value": "#dedede" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "#b8b8b8" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "#6e6e6e" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "#f2f4f7" + }, + "icon-light-2": { + "$type": "color", + "$value": "#e6e9f0" + }, + "icon-light-3": { + "$type": "color", + "$value": "#dadee5" + }, + "icon-thick-1": { + "$type": "color", + "$value": "#b0b5bf" + }, + "icon-thick-2": { + "$type": "color", + "$value": "#666f80" + }, + "icon-thick-3": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json new file mode 100644 index 0000000000..4e6b0543dc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.300}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.100}" + }, + "rose-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.200}" + }, + "rose-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.300}" + }, + "rose-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.400}" + }, + "rose-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.500}" + }, + "rose-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.600}" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.100}" + }, + "papaya-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.200}" + }, + "papaya-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.300}" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.400}" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.500}" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.600}" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.100}" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.200}" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.300}" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.400}" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.500}" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.600}" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.100}" + }, + "mango-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.200}" + }, + "mango-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.300}" + }, + "mango-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.400}" + }, + "mango-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.500}" + }, + "mango-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.600}" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.100}" + }, + "lemon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.200}" + }, + "lemon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.300}" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.400}" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.500}" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.600}" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.100}" + }, + "olive-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.200}" + }, + "olive-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.300}" + }, + "olive-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.400}" + }, + "olive-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.500}" + }, + "olive-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.600}" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.100}" + }, + "lime-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.200}" + }, + "lime-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.300}" + }, + "lime-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.400}" + }, + "lime-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.500}" + }, + "lime-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.600}" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.100}" + }, + "grass-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.200}" + }, + "grass-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.300}" + }, + "grass-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.400}" + }, + "grass-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.500}" + }, + "grass-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.600}" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.100}" + }, + "forest-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.200}" + }, + "forest-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.300}" + }, + "forest-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.400}" + }, + "forest-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.500}" + }, + "forest-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.600}" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.100}" + }, + "jade-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.200}" + }, + "jade-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.300}" + }, + "jade-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.400}" + }, + "jade-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.500}" + }, + "jade-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.600}" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.100}" + }, + "aqua-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.200}" + }, + "aqua-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.300}" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.400}" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.500}" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.600}" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.100}" + }, + "azure-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.200}" + }, + "azure-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.300}" + }, + "azure-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.400}" + }, + "azure-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.500}" + }, + "azure-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.600}" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.100}" + }, + "denim-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.200}" + }, + "denim-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.300}" + }, + "denim-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.400}" + }, + "denim-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.500}" + }, + "denim-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.600}" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.100}" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.500}" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.600}" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.400}" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.100}" + }, + "lavender-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.200}" + }, + "lavender-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.300}" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.400}" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.500}" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.600}" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.100}" + }, + "liliac-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.200}" + }, + "liliac-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.300}" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.400}" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.500}" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.600}" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.100}" + }, + "mallow-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.200}" + }, + "mallow-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.300}" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.400}" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.500}" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.600}" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.100}" + }, + "camellia-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.200}" + }, + "camellia-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.300}" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.400}" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.500}" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.600}" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.100}" + }, + "smoke-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.200}" + }, + "smoke-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.300}" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.400}" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.500}" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.600}" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.100}" + }, + "icon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.200}" + }, + "icon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.300}" + }, + "icon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.400}" + }, + "icon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.500}" + }, + "icon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.600}" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart new file mode 100644 index 0000000000..bddcdb4eae --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart @@ -0,0 +1,300 @@ +// ignore_for_file: avoid_print, depend_on_referenced_packages + +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; + +void main() { + generatePrimitive(); + generateSemantic(); +} + +void generatePrimitive() { + // 1. Load the JSON file. + final jsonString = + File('script/Primitive.Mode 1.tokens.json').readAsStringSync(); + final jsonData = jsonDecode(jsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._();'''); + + // 3. Process each color category. + jsonData.forEach((categoryName, categoryData) { + categoryData.forEach((tokenName, tokenData) { + processPrimitiveTokenData( + buffer, + tokenData, + '${categoryName}_$tokenName', + ); + }); + }); + + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/primitive.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +void processPrimitiveTokenData( + StringBuffer buffer, + Map tokenData, + final String currentTokenName, +) { + if (tokenData + case { + r'$type': 'color', + r'$value': final String colorValue, + }) { + final dartColorValue = convertColor(colorValue); + final dartTokenName = currentTokenName.replaceAll('-', '_').toCamelCase(); + + buffer.writeln(''' + + /// $colorValue + static Color get $dartTokenName => Color(0x$dartColorValue);'''); + } else { + tokenData.forEach((key, value) { + if (value is Map) { + processPrimitiveTokenData(buffer, value, '${currentTokenName}_$key'); + } + }); + } +} + +void generateSemantic() { + // 1. Load the JSON file. + final lightJsonString = + File('script/Semantic.Light Mode.tokens.json').readAsStringSync(); + final darkJsonString = + File('script/Semantic.Dark Mode.tokens.json').readAsStringSync(); + final lightJsonData = jsonDecode(lightJsonString) as Map; + final darkJsonData = jsonDecode(darkJsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../shared.dart'; +import 'primitive.dart'; + +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); + + // 3. Process light mode semantic tokens + buffer.writeln(''' + @override + AppFlowyThemeData light() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light);'''); + + lightJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + processSemanticTokenData(buffer, tokenData, tokenName); + }); + buffer.writeln(' );'); + }); + + buffer.writeln(); + buffer.writeln(''' + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + + buffer.writeln(); + + buffer.writeln(''' + @override + AppFlowyThemeData dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark);'''); + + darkJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + if (tokenData is Map) { + processSemanticTokenData(buffer, tokenData, tokenName); + } + }); + buffer.writeln(' );'); + }); + + buffer.writeln(); + + buffer.writeln(''' + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/semantic.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +void processSemanticTokenData( + StringBuffer buffer, + Map json, + final String currentTokenName, +) { + if (json + case { + r'$type': 'color', + r'$value': final String value, + }) { + final semanticTokenName = + currentTokenName.replaceAll('-', '_').toCamelCase(); + + final String colorValueOrPrimitiveToken; + if (value.isColor) { + colorValueOrPrimitiveToken = 'Color(0x${convertColor(value)})'; + } else { + final primitiveToken = value + .replaceAll(RegExp(r'\{|\}'), '') + .replaceAll(RegExp(r'\.|-'), '_') + .toCamelCase(); + colorValueOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; + } + + buffer.writeln(' $semanticTokenName: $colorValueOrPrimitiveToken,'); + } else { + json.forEach((key, value) { + if (value is Map) { + processSemanticTokenData( + buffer, + value, + '${currentTokenName}_$key', + ); + } + }); + } +} + +String convertColor(String hexColor) { + String color = hexColor.toUpperCase().replaceAll('#', ''); + if (color.length == 6) { + color = 'FF$color'; // Add missing alpha channel + } else if (color.length == 8) { + color = color.substring(6) + color.substring(0, 6); // Rearrange to ARGB + } + return color; +} + +extension on String { + String toCamelCase() { + return split('_').mapIndexed((index, part) { + if (index == 0) { + return part.toLowerCase(); + } else { + return part[0].toUpperCase() + part.substring(1).toLowerCase(); + } + }).join(); + } + + String toCapitalize() { + if (isEmpty) { + return this; + } + return '${this[0].toUpperCase()}${substring(1)}'; + } + + bool get isColor => + startsWith('#') || + (startsWith('0x') && length == 10) || + (startsWith('0xFF') && length == 12); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart index 9cd3a06313..4178edd294 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart @@ -90,6 +90,7 @@ class FlowyColorScheme { required this.scrollbarColor, required this.scrollbarHoverColor, required this.lightIconColor, + required this.toolbarHoverColor, }); final Color surface; @@ -154,6 +155,7 @@ class FlowyColorScheme { final Color scrollbarHoverColor; final Color lightIconColor; + final Color toolbarHoverColor; factory FlowyColorScheme.fromJson(Map json) => _$FlowyColorSchemeFromJson(json); diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart index 2aa455b404..8d49b8dfa1 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart @@ -86,6 +86,7 @@ class DandelionColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const DandelionColorScheme.dark() @@ -144,5 +145,6 @@ class DandelionColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: _lightShader6, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart index f829d3a67e..0e39de8fa8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart @@ -83,6 +83,7 @@ class DefaultColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const DefaultColorScheme.dark() @@ -141,5 +142,6 @@ class DefaultColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: ColorSchemeConstants.lightShader6, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart index 97ff5221de..590d26db3e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart @@ -82,6 +82,7 @@ class LavenderColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const LavenderColorScheme.dark() @@ -140,5 +141,6 @@ class LavenderColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: _lightShader6, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart index 5d9ff8b97c..3f39ae4c84 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart @@ -88,63 +88,64 @@ class LemonadeColorScheme extends FlowyColorScheme { scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const LemonadeColorScheme.dark() : super( - surface: const Color(0xff292929), - hover: const Color(0xff1f1f1f), - selector: _darkShader2, - red: const Color(0xfffb006d), - yellow: const Color(0xffffd667), - green: const Color(0xff66cf80), - shader1: _white, - shader2: _darkShader2, - shader3: const Color(0xff828282), - shader4: const Color(0xffbdbdbd), - shader5: _darkShader5, - shader6: _darkShader6, - shader7: _white, - bg1: const Color(0xFFD5A200), - bg2: _black, - bg3: _darkMain1, - bg4: const Color(0xff2c144b), - tint1: const Color(0x4d9327FF), - tint2: const Color(0x66FC0088), - tint3: const Color(0x4dFC00E2), - tint4: const Color(0x80BE5B00), - tint5: const Color(0x33F8EE00), - tint6: const Color(0x4d6DC300), - tint7: const Color(0x5900BD2A), - tint8: const Color(0x80008890), - tint9: const Color(0x4d0029FF), - main1: _darkMain1, - main2: _darkMain1, - shadow: _black, - sidebarBg: const Color(0xff232B38), - divider: _darkShader3, - topbarBg: _darkShader1, - icon: _darkShader5, - text: _darkShader5, - secondaryText: _darkShader5, - strongText: Colors.white, - input: _darkInput, - hint: _darkShader5, - primary: _darkMain1, - onPrimary: _darkShader1, - hoverBG1: _darkMain1, - hoverBG2: _darkMain1, - hoverBG3: _darkShader3, - hoverFG: _darkShader1, - questionBubbleBG: _darkShader3, - progressBarBGColor: _darkShader3, - toolbarColor: _darkInput, - toggleButtonBGColor: _darkShader1, - calendarWeekendBGColor: const Color(0xff121212), - gridRowCountColor: _darkMain1, - borderColor: ColorSchemeConstants.darkBorderColor, - scrollbarColor: const Color(0x40FFFFFF), - scrollbarHoverColor: const Color(0x80FFFFFF), - lightIconColor: const Color(0xFF8F959E), - ); + surface: const Color(0xff292929), + hover: const Color(0xff1f1f1f), + selector: _darkShader2, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: _white, + shader2: _darkShader2, + shader3: const Color(0xff828282), + shader4: const Color(0xffbdbdbd), + shader5: _darkShader5, + shader6: _darkShader6, + shader7: _white, + bg1: const Color(0xFFD5A200), + bg2: _black, + bg3: _darkMain1, + bg4: const Color(0xff2c144b), + tint1: const Color(0x4d9327FF), + tint2: const Color(0x66FC0088), + tint3: const Color(0x4dFC00E2), + tint4: const Color(0x80BE5B00), + tint5: const Color(0x33F8EE00), + tint6: const Color(0x4d6DC300), + tint7: const Color(0x5900BD2A), + tint8: const Color(0x80008890), + tint9: const Color(0x4d0029FF), + main1: _darkMain1, + main2: _darkMain1, + shadow: _black, + sidebarBg: const Color(0xff232B38), + divider: _darkShader3, + topbarBg: _darkShader1, + icon: _darkShader5, + text: _darkShader5, + secondaryText: _darkShader5, + strongText: Colors.white, + input: _darkInput, + hint: _darkShader5, + primary: _darkMain1, + onPrimary: _darkShader1, + hoverBG1: _darkMain1, + hoverBG2: _darkMain1, + hoverBG3: _darkShader3, + hoverFG: _darkShader1, + questionBubbleBG: _darkShader3, + progressBarBGColor: _darkShader3, + toolbarColor: _darkInput, + toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), + gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: _lightShader6); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 0b6ff4fb3f..6f37058f00 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -48,6 +48,8 @@ String languageFromLocale(Locale locale) { default: return locale.languageCode; } + case "mr": + return "मराठी"; case "he": return "עברית"; case "hu": diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart index a2bfd16b06..9ce1f0323d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -41,6 +41,7 @@ class AFThemeExtension extends ThemeExtension { required this.borderColor, required this.scrollbarColor, required this.scrollbarHoverColor, + required this.toolbarHoverColor, required this.lightIconColor, }); @@ -86,6 +87,7 @@ class AFThemeExtension extends ThemeExtension { final Color scrollbarColor; final Color scrollbarHoverColor; + final Color toolbarHoverColor; final Color lightIconColor; @override @@ -123,6 +125,7 @@ class AFThemeExtension extends ThemeExtension { Color? scrollbarColor, Color? scrollbarHoverColor, Color? lightIconColor, + Color? toolbarHoverColor, }) => AFThemeExtension( warning: warning ?? this.warning, @@ -159,6 +162,7 @@ class AFThemeExtension extends ThemeExtension { scrollbarColor: scrollbarColor ?? this.scrollbarColor, scrollbarHoverColor: scrollbarHoverColor ?? this.scrollbarHoverColor, lightIconColor: lightIconColor ?? this.lightIconColor, + toolbarHoverColor: toolbarHoverColor ?? this.toolbarHoverColor, ); @override @@ -215,6 +219,8 @@ class AFThemeExtension extends ThemeExtension { scrollbarHoverColor: Color.lerp(scrollbarHoverColor, other.scrollbarHoverColor, t)!, lightIconColor: Color.lerp(lightIconColor, other.lightIconColor, t)!, + toolbarHoverColor: + Color.lerp(toolbarHoverColor, other.toolbarHoverColor, t)!, ); } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart index 19ca90d78f..4f80f81e62 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart @@ -13,5 +13,12 @@ class ColorConverter implements JsonConverter { } @override - String toJson(Color color) => "0x${color.value.toRadixString(16)}"; + String toJson(Color color) { + final alpha = (color.a * 255).toInt().toRadixString(16).padLeft(2, '0'); + final red = (color.r * 255).toInt().toRadixString(16).padLeft(2, '0'); + final green = (color.g * 255).toInt().toRadixString(16).padLeft(2, '0'); + final blue = (color.b * 255).toInt().toRadixString(16).padLeft(2, '0'); + + return '0x$alpha$red$green$blue'.toLowerCase(); + } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml index cb5cbb9cee..9bf0245dc0 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -1,5 +1,5 @@ name: flowy_infra -description: A new Flutter package project. +description: AppFlowy Infra. version: 0.0.1 homepage: https://appflowy.io @@ -15,50 +15,14 @@ dependencies: path: ^1.8.2 time: ">=2.0.0" uuid: ">=2.2.2" - bloc: ^8.1.2 + bloc: ^9.0.0 freezed_annotation: ^2.1.0 file_picker: ^8.0.2 file: ^7.0.0 + analyzer: 6.11.0 dev_dependencies: build_runner: ^2.4.9 flutter_lints: ^3.0.1 freezed: ^2.4.7 json_serializable: ^6.5.4 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml index f2e3eb8749..4a8ad910cb 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml @@ -1,7 +1,7 @@ name: flowy_infra_ui_platform_interface description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=2.12.0 <3.0.0" @@ -17,5 +17,3 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 - -flutter: \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml index bbdac0d2e4..d4364a6400 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml @@ -1,7 +1,7 @@ name: flowy_infra_ui_web description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy publish_to: none environment: @@ -25,4 +25,4 @@ flutter: platforms: web: pluginClass: FlowyInfraUIPlugin - fileName: flowy_infra_ui_web.dart \ No newline at end of file + fileName: flowy_infra_ui_web.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 190d840a41..6a154d4d48 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -4,6 +4,23 @@ import 'package:flutter/material.dart'; export 'package:appflowy_popover/appflowy_popover.dart'; +class ShadowConstants { + ShadowConstants._(); + + static const List lightSmall = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 20, color: Color(0x1A1F2329)), + ]; + static const List lightMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x121F2225)), + ]; + static const List darkSmall = [ + BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x7A000000)), + ]; + static const List darkMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x7A000000)), + ]; +} + class AppFlowyPopover extends StatelessWidget { const AppFlowyPopover({ super.key, @@ -25,6 +42,7 @@ class AppFlowyPopover extends StatelessWidget { this.skipTraversal = false, this.decorationColor, this.borderRadius, + this.popoverDecoration, this.animationDuration = const Duration(), this.slideDistance = 5.0, this.beginScaleFactor = 0.9, @@ -56,6 +74,7 @@ class AppFlowyPopover extends StatelessWidget { final double endScaleFactor; final double beginOpacity; final double endOpacity; + final Decoration? popoverDecoration; /// The widget that will be used to trigger the popover. /// @@ -102,6 +121,7 @@ class AppFlowyPopover extends StatelessWidget { popupBuilder: (context) => _PopoverContainer( constraints: constraints, margin: margin, + decoration: popoverDecoration, decorationColor: decorationColor, borderRadius: borderRadius, child: popupBuilder(context), @@ -116,6 +136,7 @@ class _PopoverContainer extends StatelessWidget { const _PopoverContainer({ this.decorationColor, this.borderRadius, + this.decoration, required this.child, required this.margin, required this.constraints, @@ -126,6 +147,7 @@ class _PopoverContainer extends StatelessWidget { final EdgeInsets margin; final Color? decorationColor; final BorderRadius? borderRadius; + final Decoration? decoration; @override Widget build(BuildContext context) { @@ -133,10 +155,11 @@ class _PopoverContainer extends StatelessWidget { type: MaterialType.transparency, child: Container( padding: margin, - decoration: context.getPopoverDecoration( - color: decorationColor, - borderRadius: borderRadius, - ), + decoration: decoration ?? + context.getPopoverDecoration( + color: decorationColor, + borderRadius: borderRadius, + ), constraints: constraints, child: child, ), @@ -144,7 +167,7 @@ class _PopoverContainer extends StatelessWidget { } } -extension on BuildContext { +extension PopoverDecoration on BuildContext { /// The decoration of the popover. /// /// Don't customize the entire decoration of the popover, @@ -156,26 +179,9 @@ extension on BuildContext { final borderColor = Theme.of(this).brightness == Brightness.light ? ColorSchemeConstants.lightBorderColor : ColorSchemeConstants.darkBorderColor; - final shadows = [ - const BoxShadow( - color: Color(0x0A1F2329), - blurRadius: 24, - offset: Offset(0, 8), - spreadRadius: 8, - ), - const BoxShadow( - color: Color(0x0A1F2329), - blurRadius: 12, - offset: Offset(0, 6), - spreadRadius: 0, - ), - const BoxShadow( - color: Color(0x0F1F2329), - blurRadius: 8, - offset: Offset(0, 4), - spreadRadius: -8, - ) - ]; + final shadows = Theme.of(this).brightness == Brightness.light + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall; return ShapeDecoration( color: color ?? Theme.of(this).cardColor, shape: RoundedRectangleBorder( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart index 0d4bacde52..fb29bb0637 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; + import 'package:flutter/material.dart'; + import 'flowy_overlay.dart'; class PopoverLayoutDelegate extends SingleChildLayoutDelegate { @@ -133,8 +135,6 @@ class PopoverLayoutDelegate extends SingleChildLayoutDelegate { case AnchorDirection.custom: childConstraints = constraints.loosen(); break; - default: - throw UnimplementedError(); } return childConstraints; } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart index 87dd63b715..84e7bb8ebd 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; + import 'package:flutter/material.dart'; + import 'flowy_overlay.dart'; class OverlayLayoutDelegate extends SingleChildLayoutDelegate { @@ -133,8 +135,6 @@ class OverlayLayoutDelegate extends SingleChildLayoutDelegate { case AnchorDirection.custom: childConstraints = constraints.loosen(); break; - default: - throw UnimplementedError(); } return childConstraints; } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart index 2b61839ac3..d1964e0c83 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart @@ -128,7 +128,7 @@ class OverlayContainer extends StatelessWidget { padding: padding, decoration: FlowyDecoration.decoration( Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.shadow.withOpacity(0.15), + Theme.of(context).colorScheme.shadow.withValues(alpha: 0.15), ), constraints: constraints, child: child, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart index c227d8b8ef..61f92fd073 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart @@ -49,11 +49,12 @@ class PrimaryRoundedButton extends StatelessWidget { figmaLineHeight: figmaLineHeight, color: textColor ?? Theme.of(context).colorScheme.onPrimary, textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, ), margin: margin ?? const EdgeInsets.symmetric(horizontal: 14.0), backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, - hoverColor: - hoverColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.9), + hoverColor: hoverColor ?? + Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), radius: BorderRadius.circular(radius ?? 10.0), onTap: onTap, ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart index 76e9eefbc2..360578a4a6 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart @@ -15,7 +15,6 @@ class FlowyText extends StatelessWidget { final TextDecoration? decoration; final Color? decorationColor; final double? decorationThickness; - final bool selectable; final String? fontFamily; final List? fallbackFontFamily; final bool withTooltip; @@ -41,7 +40,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.fontFamily, this.fallbackFontFamily, // // https://api.flutter.dev/flutter/painting/TextStyle/height.html @@ -63,7 +61,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.fontFamily, this.fallbackFontFamily, this.lineHeight, @@ -86,7 +83,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.fontFamily, this.fallbackFontFamily, this.lineHeight, @@ -108,7 +104,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.fontFamily, this.fallbackFontFamily, this.lineHeight, @@ -130,7 +125,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.fontFamily, this.fallbackFontFamily, this.lineHeight, @@ -153,7 +147,6 @@ class FlowyText extends StatelessWidget { this.maxLines = 1, this.decoration, this.decorationColor, - this.selectable = false, this.lineHeight, this.withTooltip = false, this.strutStyle = const StrutStyle(forceStrutHeight: true), @@ -211,32 +204,21 @@ class FlowyText extends StatelessWidget { : null, ); - if (selectable) { - child = IntrinsicHeight( - child: SelectableText( - text, - maxLines: maxLines, - textAlign: textAlign, - style: textStyle, - ), - ); - } else { - child = Text( - text, - maxLines: maxLines, - textAlign: textAlign, - overflow: overflow ?? TextOverflow.clip, - style: textStyle, - strutStyle: !isEmoji || (isEmoji && optimizeEmojiAlign) - ? StrutStyle.fromTextStyle( - textStyle, - forceStrutHeight: true, - leadingDistribution: TextLeadingDistribution.even, - height: lineHeight, - ) - : null, - ); - } + child = Text( + text, + maxLines: maxLines, + textAlign: textAlign, + overflow: overflow ?? TextOverflow.clip, + style: textStyle, + strutStyle: !isEmoji || (isEmoji && optimizeEmojiAlign) + ? StrutStyle.fromTextStyle( + textStyle, + forceStrutHeight: true, + leadingDistribution: TextLeadingDistribution.even, + height: lineHeight, + ) + : null, + ); if (withTooltip) { child = FlowyTooltip( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart index bc391daf9e..95fd82363e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart @@ -1,11 +1,10 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flowy_infra/size.dart'; - class FlowyFormTextInput extends StatelessWidget { static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); @@ -68,7 +67,7 @@ class FlowyFormTextInput extends StatelessWidget { hintStyle: Theme.of(context) .textTheme .bodyMedium! - .copyWith(color: Theme.of(context).hintColor.withOpacity(0.7)), + .copyWith(color: Theme.of(context).hintColor.withValues(alpha: 0.7)), isDense: true, inputBorder: const ThinUnderlineBorder( borderSide: BorderSide(width: 5, color: Colors.red), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart new file mode 100644 index 0000000000..96a22a6f85 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart @@ -0,0 +1,43 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; + +class FlowyToolbarButton extends StatelessWidget { + final Widget child; + final VoidCallback? onPressed; + final EdgeInsets padding; + final String? tooltip; + + const FlowyToolbarButton({ + super.key, + this.onPressed, + this.tooltip, + this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + required this.child, + }); + + @override + Widget build(BuildContext context) { + final tooltipMessage = tooltip ?? ''; + + return FlowyTooltip( + message: tooltipMessage, + padding: EdgeInsets.zero, + child: RawMaterialButton( + clipBehavior: Clip.antiAlias, + constraints: const BoxConstraints(minWidth: 36, minHeight: 32), + hoverElevation: 0, + highlightElevation: 0, + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder(borderRadius: Corners.s6Border), + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + onPressed: onPressed, + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart index 8087c712fe..c81c81f356 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart @@ -121,7 +121,7 @@ class BaseStyledBtnState extends State { fillColor: Colors.transparent, hoverColor: widget.hoverColor ?? Colors.transparent, highlightColor: widget.highlightColor ?? Colors.transparent, - focusColor: widget.focusColor ?? Colors.grey.withOpacity(0.35), + focusColor: widget.focusColor ?? Colors.grey.withValues(alpha: 0.35), constraints: BoxConstraints( minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), onPressed: widget.onPressed, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart index e29f778d84..7048ed32ec 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart @@ -96,7 +96,7 @@ class Dialogs { {required Widget child}) async { return await Navigator.of(context).push( StyledDialogRoute( - barrier: DialogBarrier(color: Colors.black.withOpacity(0.4)), + barrier: DialogBarrier(color: Colors.black.withValues(alpha: 0.4)), pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { return SafeArea(child: child); 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 c4c3263d39..5b0b791c6c 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 @@ -10,6 +10,7 @@ class FlowyTooltip extends StatelessWidget { this.preferBelow, this.margin, this.verticalOffset, + this.padding, this.child, }); @@ -19,6 +20,7 @@ class FlowyTooltip extends StatelessWidget { final EdgeInsetsGeometry? margin; final Widget? child; final double? verticalOffset; + final EdgeInsets? padding; @override Widget build(BuildContext context) { @@ -29,10 +31,11 @@ class FlowyTooltip extends StatelessWidget { return Tooltip( margin: margin, verticalOffset: verticalOffset ?? 16.0, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), + padding: padding ?? + const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), decoration: BoxDecoration( color: context.tooltipBackgroundColor(), borderRadius: BorderRadius.circular(10.0), @@ -47,10 +50,77 @@ class FlowyTooltip extends StatelessWidget { } } +class ManualTooltip extends StatefulWidget { + const ManualTooltip({ + super.key, + this.message, + this.richMessage, + this.preferBelow, + this.margin, + this.verticalOffset, + this.padding, + this.showAutomaticlly = false, + this.child, + }); + + final String? message; + final InlineSpan? richMessage; + final bool? preferBelow; + final EdgeInsetsGeometry? margin; + final Widget? child; + final double? verticalOffset; + final EdgeInsets? padding; + final bool showAutomaticlly; + + @override + State createState() => _ManualTooltipState(); +} + +class _ManualTooltipState extends State { + final key = GlobalKey(); + + @override + void initState() { + if (widget.showAutomaticlly) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) key.currentState?.ensureTooltipVisible(); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Tooltip( + key: key, + margin: widget.margin, + verticalOffset: widget.verticalOffset ?? 16.0, + triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null, + padding: widget.padding ?? + const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: context.tooltipBackgroundColor(), + borderRadius: BorderRadius.circular(10.0), + ), + waitDuration: _tooltipWaitDuration, + message: widget.message, + textStyle: widget.message != null ? context.tooltipTextStyle() : null, + richMessage: widget.richMessage, + preferBelow: widget.preferBelow, + child: widget.child, + ); + } +} + extension FlowyToolTipExtension on BuildContext { double tooltipFontSize() => 14.0; + double tooltipHeight({double? fontSize}) => 20.0 / (fontSize ?? tooltipFontSize()); + Color tooltipFontColor() => Theme.of(this).brightness == Brightness.light ? Colors.white : Colors.black; @@ -66,7 +136,7 @@ extension FlowyToolTipExtension on BuildContext { } TextStyle? tooltipHintTextStyle({double? fontSize}) => tooltipTextStyle( - fontColor: tooltipFontColor().withOpacity(0.7), + fontColor: tooltipFontColor().withValues(alpha: 0.7), fontSize: fontSize, ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml index 62cb26d4e0..b5b5c22bc7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -31,6 +31,8 @@ dependencies: flowy_svg: path: ../flowy_svg + analyzer: 6.11.0 + dev_dependencies: build_runner: ^2.4.9 provider: ^6.0.5 diff --git a/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart index cbf114156d..e87bb3fa01 100644 --- a/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart +++ b/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart @@ -242,7 +242,13 @@ String varNameFor(File file, Options options) { return simplified; } -const sizeMap = {r'$16x': 's', r'$24x': 'm', r'$32x': 'lg', r'$40x': 'xl'}; +const sizeMap = { + r'$16x': 's', + r'$20x': 'm', + r'$24x': 'm', + r'$32x': 'lg', + r'$40x': 'xl' +}; /// cleans the path segment before rejoining the path into a variable name String clean(String segment) { diff --git a/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart index cba112dc2b..1f861156eb 100644 --- a/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart +++ b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +export 'package:flutter_svg/flutter_svg.dart'; + /// The class for FlowySvgData that the code generator will implement class FlowySvgData { /// The svg data @@ -80,7 +82,7 @@ class FlowySvg extends StatelessWidget { Widget build(BuildContext context) { Color? iconColor = color ?? Theme.of(context).iconTheme.color; if (opacity != null) { - iconColor = iconColor?.withOpacity(opacity!); + iconColor = iconColor?.withValues(alpha: opacity!); } final textScaleFactor = MediaQuery.textScalerOf(context).scale(1); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index b3b23666e3..c871a41f7e 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -5,18 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "67.0.0" - analyzer: + version: "76.0.0" + _macros: dependency: transitive + description: dart + source: sdk + version: "0.3.3" + analyzer: + dependency: "direct main" description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.11.0" animations: dependency: transitive description: @@ -25,6 +30,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.11" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" any_date: dependency: "direct main" description: @@ -37,10 +50,34 @@ packages: dependency: "direct main" description: name: app_links - sha256: "3ced568a5d9e309e99af71285666f1f3117bddd0bd5b3317979dccc1a40cada4" + sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "6.3.3" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" appflowy_backend: dependency: "direct main" description: @@ -52,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: "5517c8704c0dbeaeda5601e9baadb4cc2b29990d" - resolved-ref: "5517c8704c0dbeaeda5601e9baadb4cc2b29990d" + ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 + resolved-ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 url: "https://github.com/AppFlowy-IO/appflowy-board.git" source: git version: "0.1.2" @@ -61,17 +98,17 @@ packages: dependency: "direct main" description: path: "." - ref: e4648cc - resolved-ref: e4648ccbc2c1b9ffc5ceb3168b84a9ebdaa692de + ref: "680222f" + resolved-ref: "680222f503f90d07c08c99c42764f9b08fd0f46c" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git - version: "4.0.0" + version: "5.1.0" appflowy_editor_plugins: dependency: "direct main" description: path: "packages/appflowy_editor_plugins" - ref: ca8289099e40e0d6ad0605fbbe01fde3091538bb - resolved-ref: ca8289099e40e0d6ad0605fbbe01fde3091538bb + ref: "4efcff7" + resolved-ref: "4efcff720ed01dd4d0f5f88a9f1ff6f79f423caa" url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git" source: git version: "0.0.6" @@ -89,6 +126,13 @@ packages: relative: true source: path version: "0.0.1" + appflowy_ui: + dependency: "direct main" + description: + path: "packages/appflowy_ui" + relative: true + source: path + version: "1.0.0" archive: dependency: "direct main" description: @@ -121,22 +165,57 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.4" + auto_updater: + dependency: "direct main" + description: + path: "packages/auto_updater" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_macos: + dependency: "direct overridden" + description: + path: "packages/auto_updater_macos" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_platform_interface: + dependency: "direct overridden" + description: + path: "packages/auto_updater_platform_interface" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_windows: + dependency: transitive + description: + name: auto_updater_windows + sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" + url: "https://pub.dev" + source: hosted + version: "1.0.0" avatar_stack: dependency: "direct main" description: name: avatar_stack - sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49 + sha256: "354527ba139956fd6439e2c49199d8298d72afdaa6c4cd6f37f26b97faf21f7e" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "3.0.0" barcode: dependency: transitive description: name: barcode - sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" url: "https://pub.dev" source: hosted - version: "2.2.8" + version: "2.2.9" bidi: dependency: transitive description: @@ -189,18 +268,18 @@ packages: dependency: "direct main" description: name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.0.0" bloc_test: dependency: "direct dev" description: name: bloc_test - sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" url: "https://pub.dev" source: hosted - version: "9.1.7" + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -213,50 +292,50 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.3" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.14" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "8.0.0" built_collection: dependency: transitive description: @@ -342,18 +421,18 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" connectivity_plus: dependency: "direct main" description: @@ -390,10 +469,10 @@ packages: dependency: transitive description: name: cross_cache - sha256: "3879d1661f211e89d81ece419684df5111b5a611aa6200cd405e8332031765e9" + sha256: "80329477264c73f09945ee780ccdc84df9231f878dc7227d132d301e34ff310b" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" cross_file: dependency: "direct main" description: @@ -422,10 +501,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7" dbus: dependency: transitive description: @@ -446,10 +525,10 @@ packages: dependency: "direct main" description: name: desktop_drop - sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + sha256: "03abf1c0443afdd1d65cf8fa589a2f01c67a11da56bbb06f6ea1de79d5628e94" url: "https://pub.dev" source: hosted - version: "0.4.4" + version: "0.5.0" device_info_plus: dependency: "direct main" description: @@ -534,18 +613,18 @@ packages: dependency: "direct main" description: name: envied - sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 + sha256: "08a9012e5d93e1a816919a52e37c7b8367e73ebb8d52d1ca7dd6fcd875a2cd2c" url: "https://pub.dev" source: hosted - version: "0.5.4+1" + version: "1.0.1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" + sha256: "9a49ca9f3744069661c4f2c06993647699fae2773bca10b593fbb3228d081027" url: "https://pub.dev" source: hosted - version: "0.5.4+1" + version: "1.0.1" equatable: dependency: "direct main" description: @@ -554,6 +633,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + event_bus: + dependency: "direct main" + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" expandable: dependency: "direct main" description: @@ -566,10 +653,10 @@ packages: dependency: "direct main" description: name: extended_text_field - sha256: fb5c35460a54906a0ada2a88a968cdfc71d71aebbaf9022debb5d67f47748964 + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" url: "https://pub.dev" source: hosted - version: "15.0.1" + version: "16.0.2" extended_text_library: dependency: "direct main" description: @@ -603,13 +690,13 @@ packages: source: hosted version: "7.0.0" file_picker: - dependency: transitive + dependency: "direct overridden" description: name: file_picker - sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 + sha256: "16dc141db5a2ccc6520ebb6a2eb5945b1b09e95085c021d9f914f8ded7f1465c" url: "https://pub.dev" source: hosted - version: "8.1.7" + version: "8.1.4" file_selector_linux: dependency: transitive description: @@ -654,18 +741,18 @@ packages: dependency: "direct main" description: name: flex_color_picker - sha256: "12dc855ae8ef5491f529b1fc52c655f06dcdf4114f1f7fdecafa41eec2ec8d79" + sha256: c083b79f1c57eaeed9f464368be376951230b3cb1876323b784626152a86e480 url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.7.0" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme - sha256: "7639d2c86268eff84a909026eb169f008064af0fb3696a651b24b0fa24a40334" + sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0 url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.5.0" flowy_infra: dependency: "direct main" description: @@ -711,10 +798,10 @@ packages: dependency: "direct main" description: name: flutter_bloc - sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + sha256: "1046d719fbdf230330d3443187cc33cc11963d15c9089f6cc56faa42a4c5f0cc" url: "https://pub.dev" source: hosted - version: "8.1.6" + version: "9.1.0" flutter_cache_manager: dependency: "direct main" description: @@ -790,10 +877,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" flutter_localizations: dependency: transitive description: flutter @@ -811,10 +898,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" url: "https://pub.dev" source: hosted - version: "2.0.22" + version: "2.0.24" flutter_shaders: dependency: transitive description: @@ -840,21 +927,21 @@ packages: source: hosted version: "0.7.0" flutter_sticky_header: - dependency: transitive + dependency: "direct overridden" description: name: flutter_sticky_header - sha256: "017f398fbb45a589e01491861ca20eb6570a763fd9f3888165a978e11248c709" + sha256: "7f76d24d119424ca0c95c146b8627a457e8de8169b0d584f766c2c545db8f8be" url: "https://pub.dev" source: hosted - version: "0.6.5" + version: "0.7.0" flutter_svg: dependency: transitive description: name: flutter_svg - sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter @@ -864,10 +951,10 @@ packages: dependency: "direct main" description: name: flutter_tex - sha256: "816074d8a49dd2301704aaf481f9b052b935b8790018a88b69a60d003d5e89e4" + sha256: ef7896946052e150514a2afe10f6e33e4fe0e7e4fc51195b65da811cb33c59ab url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.0.13" flutter_web_plugins: dependency: transitive description: flutter @@ -885,10 +972,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.7" freezed_annotation: dependency: "direct main" description: @@ -914,10 +1001,10 @@ packages: dependency: "direct main" description: name: get_it - sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + sha256: f126a3e286b7f5b578bf436d5592968706c4c1de28a228b870ce375d9f743103 url: "https://pub.dev" source: hosted - version: "7.7.0" + version: "8.0.3" glob: dependency: transitive description: @@ -930,10 +1017,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "2fd11229f59e23e967b0775df8d5948a519cd7e1e8b6e849729e010587b46539" + sha256: "7c2d40b59890a929824f30d442e810116caf5088482629c894b9e4478c67472d" url: "https://pub.dev" source: hosted - version: "14.6.2" + version: "14.6.3" google_fonts: dependency: "direct main" description: @@ -1026,10 +1113,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" iconsax_flutter: dependency: transitive description: @@ -1058,10 +1145,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56" + sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c url: "https://pub.dev" source: hosted - version: "0.8.12+12" + version: "0.8.12+20" image_picker_for_web: dependency: transitive description: @@ -1074,10 +1161,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.12+1" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: @@ -1098,10 +1185,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -1175,10 +1262,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.9.0" keyboard_height_plugin: dependency: "direct main" description: @@ -1191,18 +1278,18 @@ packages: dependency: "direct main" description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -1239,10 +1326,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.1.1" loading_indicator: dependency: transitive description: @@ -1259,14 +1346,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.6" - logger: - dependency: transitive - description: - name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 - url: "https://pub.dev" - source: hosted - version: "2.5.0" logging: dependency: transitive description: @@ -1275,14 +1354,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" markdown: dependency: "direct main" description: name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "7.3.0" markdown_widget: dependency: "direct main" description: @@ -1303,34 +1390,34 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mime: dependency: "direct main" description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" mockito: dependency: transitive description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "5.4.5" mocktail: dependency: "direct dev" description: @@ -1407,10 +1494,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790" url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.1.3" package_info_plus_platform_interface: dependency: transitive description: @@ -1455,10 +1542,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" url: "https://pub.dev" source: hosted - version: "2.2.10" + version: "2.2.15" path_provider_foundation: dependency: transitive description: @@ -1503,10 +1590,10 @@ packages: dependency: transitive description: name: pdf - sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" url: "https://pub.dev" source: hosted - version: "3.11.1" + version: "3.11.3" percent_indicator: dependency: "direct main" description: @@ -1583,10 +1670,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: "direct dev" description: @@ -1639,10 +1726,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" qr: dependency: transitive description: @@ -1662,10 +1749,11 @@ packages: reorderable_tabbar: dependency: "direct main" description: - name: reorderable_tabbar - sha256: dd19d7b6f60f0dec4be02ba0a2c860f9acbe5a392cb8b5b8c1417cbfcbfe923f - url: "https://pub.dev" - source: hosted + path: "." + ref: "93c4977" + resolved-ref: "93c4977ffab68906694cdeaea262be6045543c94" + url: "https://github.com/LucasXu0/reorderable_tabbar" + source: git version: "1.0.6" reorderables: dependency: "direct main" @@ -1691,6 +1779,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + saver_gallery: + dependency: "direct main" + description: + name: saver_gallery + sha256: bf59475e50b73d666630bed7a5fdb621fed92d637f64e3c61ce81653ec6a833c + url: "https://pub.dev" + source: hosted + version: "4.0.1" scaled_app: dependency: "direct main" description: @@ -1703,10 +1799,42 @@ packages: dependency: transitive description: name: screen_retriever - sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" scroll_to_index: dependency: "direct main" description: @@ -1743,10 +1871,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "10.1.3" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: @@ -1759,18 +1887,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + sha256: bf808be89fe9dc467475e982c1db6c2faf3d2acf54d526cd5ec37d86c99dbd84 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_foundation: dependency: transitive description: @@ -1824,10 +1952,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -1848,10 +1976,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.1" simple_gesture_detector: dependency: transitive description: @@ -1872,7 +2000,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" sliver_tools: dependency: transitive description: @@ -1933,26 +2061,50 @@ packages: dependency: transitive description: name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" url: "https://pub.dev" source: hosted - version: "2.3.3+1" + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -1973,10 +2125,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" string_validator: dependency: "direct main" description: @@ -2021,10 +2173,10 @@ packages: dependency: "direct main" description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.3.0+3" tab_indicator_styler: dependency: transitive description: @@ -2041,6 +2193,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + talker: + dependency: "direct main" + description: + name: talker + sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_bloc_logger: + dependency: "direct main" + description: + name: talker_bloc_logger + sha256: "2214a5f6ef9ff33494dc6149321c270356962725cc8fc1a485d44b1d9b812ddd" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2 + url: "https://pub.dev" + source: hosted + version: "4.7.1" term_glyph: dependency: transitive description: @@ -2053,26 +2229,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.5" time: dependency: "direct main" description: @@ -2109,10 +2285,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" universal_html: dependency: transitive description: @@ -2140,10 +2316,11 @@ packages: unsplash_client: dependency: "direct main" description: - name: unsplash_client - sha256: "9827f4c1036b7a6ac8cb3f404ac179df7441eee69371d9b17f181817fe502fd7" - url: "https://pub.dev" - source: hosted + path: "." + ref: a8411fc + resolved-ref: a8411fcead178834d1f4572f64dc78b059ffa221 + url: "https://github.com/LucasXu0/unsplash_client.git" + source: git version: "2.2.0" url_launcher: dependency: "direct main" @@ -2157,10 +2334,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.3.9" + version: "6.3.14" url_launcher_ios: dependency: transitive description: @@ -2197,18 +2374,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" url_protocol: dependency: "direct main" description: @@ -2230,10 +2407,10 @@ packages: dependency: transitive description: name: value_layout_builder - sha256: "98202ec1807e94ac72725b7f0d15027afde513c55c69ff3f41bcfccb950831bc" + sha256: c02511ea91ca5c643b514a33a38fa52536f74aa939ec367d02938b5ede6807fa url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.4.0" vector_graphics: dependency: transitive description: @@ -2246,10 +2423,10 @@ packages: dependency: transitive description: name: vector_graphics_codec - sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.12" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: @@ -2266,6 +2443,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + version: + dependency: "direct main" + description: + name: version + sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" + url: "https://pub.dev" + source: hosted + version: "3.0.2" visibility_detector: dependency: transitive description: @@ -2278,10 +2463,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.3.0" watcher: dependency: transitive description: @@ -2298,22 +2483,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.1" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" webkit_inspection_protocol: dependency: transitive description: @@ -2326,18 +2519,18 @@ packages: dependency: transitive description: name: webview_flutter - sha256: "6869c8786d179f929144b4a1f86e09ac0eddfe475984951ea6c634774c16b522" + sha256: "889a0a678e7c793c308c68739996227c9661590605e70b1f6cf6b9a6634f7aec" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.10.0" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: ed021f27ae621bc97a6019fb601ab16331a3db4bf8afa305e9f6689bdb3edced + sha256: d1ee28f44894cbabb1d94cc42f9980297f689ff844d067ec50ff88d86e27d63f url: "https://pub.dev" source: hosted - version: "3.16.8" + version: "4.3.0" webview_flutter_platform_interface: dependency: transitive description: @@ -2350,26 +2543,26 @@ packages: dependency: transitive description: name: webview_flutter_plus - sha256: "57ec757eada4e23bfb015f5d5f84a45108cb2c29b1e77e23956768cd5e0c8468" + sha256: f883dfc94d03b1a2a17441c8e8a8e1941558ed3322f2b586cd06486114e18048 url: "https://pub.dev" source: hosted - version: "0.4.7" + version: "0.4.10" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: "9c62cc46fa4f2d41e10ab81014c1de470a6c6f26051a2de32111b2ee55287feb" + sha256: "4adc14ea9a770cc9e2c8f1ac734536bd40e82615bd0fa6b94be10982de656cc7" url: "https://pub.dev" source: hosted - version: "3.14.0" + version: "3.17.0" win32: dependency: transitive description: name: win32 - sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.5.4" + version: "5.10.0" win32_registry: dependency: transitive description: @@ -2382,10 +2575,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" url: "https://pub.dev" source: hosted - version: "0.3.9" + version: "0.4.3" xdg_directories: dependency: transitive description: @@ -2395,7 +2588,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 @@ -2411,5 +2604,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.6.2 <4.0.0" + flutter: ">=3.27.4" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 2ab3f1b7ef..e8042d6a57 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -4,35 +4,37 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an your data. The best open source alternative to Notion. publish_to: "none" -version: 0.8.2 +version: 0.8.9 environment: - flutter: ">=3.22.0" + flutter: ">=3.27.4" sdk: ">=3.3.0 <4.0.0" dependencies: any_date: ^1.0.4 - app_links: ^3.5.0 + app_links: ^6.3.3 appflowy_backend: path: packages/appflowy_backend appflowy_board: git: url: https://github.com/AppFlowy-IO/appflowy-board.git - ref: 5517c8704c0dbeaeda5601e9baadb4cc2b29990d + ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 appflowy_editor: appflowy_editor_plugins: appflowy_popover: path: packages/appflowy_popover appflowy_result: path: packages/appflowy_result - + appflowy_ui: + path: packages/appflowy_ui archive: ^3.4.10 auto_size_text_field: ^2.2.3 - avatar_stack: ^1.2.0 + auto_updater: ^1.0.0 + avatar_stack: ^3.0.0 # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 - bloc: ^8.1.2 + bloc: ^9.0.0 cached_network_image: ^3.3.0 calendar_view: git: @@ -44,15 +46,15 @@ dependencies: # Desktop Drop uses Cross File (XFile) data type defer_pointer: ^0.0.2 - desktop_drop: ^0.4.4 + desktop_drop: ^0.5.0 device_info_plus: diffutil_dart: ^4.0.1 dotted_border: ^2.0.0+3 easy_localization: ^3.0.2 - envied: ^0.5.2 + envied: ^1.0.1 equatable: ^2.0.5 expandable: ^5.0.1 - extended_text_field: ^15.0.0 + extended_text_field: ^16.0.2 extended_text_library: ^12.0.0 file: ^7.0.0 fixnum: ^1.1.0 @@ -66,10 +68,10 @@ dependencies: flutter: sdk: flutter flutter_animate: ^4.5.0 - flutter_bloc: ^8.1.3 + flutter_bloc: ^9.1.0 flutter_cache_manager: ^3.3.1 - flutter_chat_core: ^0.0.2 - flutter_chat_ui: 2.0.0-dev.1 + flutter_chat_core: 0.0.2 + flutter_chat_ui: ^2.0.0-dev.1 flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git @@ -81,7 +83,7 @@ dependencies: flutter_tex: ^4.0.9 fluttertoast: ^8.2.6 freezed_annotation: ^2.2.0 - get_it: ^7.6.0 + get_it: ^8.0.3 go_router: ^14.2.0 google_fonts: ^6.1.0 highlight: ^0.7.0 @@ -104,7 +106,7 @@ dependencies: local_notifier: ^0.1.5 markdown: markdown_widget: ^2.3.2+6 - mime: ^1.0.6 + mime: ^2.0.0 nanoid: ^1.0.0 numerus: ^2.1.2 @@ -113,7 +115,7 @@ dependencies: package_info_plus: ^8.0.2 path: ^1.8.3 path_provider: ^2.0.15 - percent_indicator: ^4.2.3 + percent_indicator: 4.2.3 permission_handler: ^11.3.1 protobuf: ^3.1.0 provider: ^6.0.5 @@ -134,6 +136,7 @@ dependencies: synchronized: ^3.1.0+1 table_calendar: ^3.0.9 time: ^2.1.3 + event_bus: ^2.0.1 toastification: ^2.0.0 universal_platform: ^1.1.0 @@ -142,13 +145,22 @@ dependencies: url_protocol: # Window Manager for MacOS and Linux - window_manager: ^0.3.9 + version: ^3.0.2 + xml: ^6.5.0 + window_manager: ^0.4.3 + saver_gallery: ^4.0.1 + talker_bloc_logger: ^4.7.1 + talker: ^4.7.1 + + analyzer: 6.11.0 dev_dependencies: - bloc_test: ^9.1.2 + # Introduce talker to log the bloc events, and only log the events in the development mode + + bloc_test: ^10.0.0 build_runner: ^2.4.9 - envied_generator: ^0.5.2 - flutter_lints: ^4.0.0 + envied_generator: ^1.0.1 + flutter_lints: ^5.0.0 flutter_test: sdk: flutter @@ -175,13 +187,13 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "e4648cc" + ref: "680222f" appflowy_editor_plugins: git: url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git path: "packages/appflowy_editor_plugins" - ref: "ca8289099e40e0d6ad0605fbbe01fde3091538bb" + ref: "4efcff7" sheet: git: @@ -197,14 +209,53 @@ dependency_overrides: commit: fbab857b1b1d209240a146d32f496379b9f62276 path: flutter_cache_manager + flutter_sticky_header: ^0.7.0 + + reorderable_tabbar: + git: + url: https://github.com/LucasXu0/reorderable_tabbar + ref: 93c4977 + # Don't upgrade file_picker until the issue is fixed + # https://github.com/miguelpruivo/flutter_file_picker/issues/1652 + file_picker: 8.1.4 + + auto_updater: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + auto_updater_macos: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater_macos + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + auto_updater_platform_interface: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater_platform_interface + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + unsplash_client: + git: + url: https://github.com/LucasXu0/unsplash_client.git + ref: a8411fc + + # auto_updater: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater + + # auto_updater_macos: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater_macos + + # auto_updater_platform_interface: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater_platform_interface + flutter: generate: true uses-material-design: true fonts: - - family: FlowyIconData - fonts: - - asset: assets/fonts/FlowyIconData.ttf - family: Poppins fonts: - asset: assets/google_fonts/Poppins/Poppins-ExtraLight.ttf @@ -230,6 +281,9 @@ flutter: - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf style: italic + # White-label font configuration will be added here + # BEGIN: WHITE_LABEL_FONT + # END: WHITE_LABEL_FONT # To add assets to your application, add an assets section, like this: assets: @@ -238,6 +292,7 @@ flutter: - assets/images/built_in_cover_images/ - assets/flowy_icons/ - assets/flowy_icons/16x/ + - assets/flowy_icons/20x/ - assets/flowy_icons/24x/ - assets/flowy_icons/32x/ - assets/flowy_icons/40x/ @@ -245,6 +300,7 @@ flutter: - assets/images/login/ - assets/translations/ - assets/icons/icons.json + - assets/fonts/ # The following assets will be excluded in release. # BEGIN: EXCLUDE_IN_RELEASE diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart new file mode 100644 index 0000000000..46b8118087 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -0,0 +1,424 @@ +import 'dart:async'; + +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../util.dart'; + +const _aiResponse = 'UPDATED:'; + +class _MockCompletionStream extends Mock implements CompletionStream {} + +class _MockAIRepository extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + final lines = text.split('\n'); + for (final line in lines) { + if (line.isNotEmpty) { + await processMessage('$_aiResponse $line\n\n'); + } + } + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + // only return 1 line. + await processMessage('Hello World'); + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + // return 10 lines + for (var i = 0; i < 10; i++) { + await processMessage('Hello World\n\n'); + } + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockErrorRepository extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + onError( + const AIError( + message: 'Error', + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + }), + ); + return ('mock_id', stream); + } +} + +void main() { + group('AIWriterCubit:', () { + const text1 = '1. Select text to style using the toolbar menu.'; + const text2 = '2. Discover more styling options in Aa.'; + const text3 = + '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + blocTest( + 'send request before the bloc is initialized', + build: () { + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final editorState = EditorState(document: document) + ..selection = selection; + return AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + }, + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), + expect: () => [ + isA() + .having((s) => s.markdownText, 'result', isEmpty), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + ], + ); + + blocTest( + 'exceed the ai response limit', + build: () { + const text1 = '1. Select text to style using the toolbar menu.'; + const text2 = '2. Discover more styling options in Aa.'; + const text3 = + '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final editorState = EditorState(document: document) + ..selection = selection; + return AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockErrorRepository(), + ); + }, + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), + expect: () => [ + isA() + .having((s) => s.markdownText, 'result', isEmpty), + isA().having( + (s) => s.error.code, + 'error code', + AIErrorCode.aiResponseLimitExceeded, + ), + ], + ); + + test('improve writing - the result contains the same number of paragraphs', + () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + await blocResponseFuture(); + expect( + editorState.document.root.children.length, + 3, + ); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + '$_aiResponse $text1', + ); + expect( + editorState.getNodeAtPath([1])!.delta!.toPlainText(), + '$_aiResponse $text2', + ); + expect( + editorState.getNodeAtPath([2])!.delta!.toPlainText(), + '$_aiResponse $text3', + ); + }); + + test('improve writing - discard', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.discard); + await blocResponseFuture(); + expect( + editorState.document.root.children.length, + 3, + ); + expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), text1); + expect(editorState.getNodeAtPath([1])!.delta!.toPlainText(), text2); + expect(editorState.getNodeAtPath([2])!.delta!.toPlainText(), text3); + }); + + test('improve writing - the result less than the original text', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepositoryLess(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 2); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + 'Hello World', + ); + }); + + test('improve writing - the result more than the original text', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepositoryMore(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 10); + for (var i = 0; i < 10; i++) { + expect( + editorState.getNodeAtPath([i])!.delta!.toPlainText(), + 'Hello World', + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart deleted file mode 100644 index 33e2b4c448..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart +++ /dev/null @@ -1,331 +0,0 @@ -import 'package:appflowy/ai/service/ai_client.dart'; -import 'package:appflowy/ai/service/appflowy_ai_service.dart'; -import 'package:appflowy/ai/service/error.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ask_ai_action_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../../util.dart'; - -const _aiResponse = 'UPDATED:'; - -class _MockAIRepository extends Mock implements AIRepository { - @override - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }) async { - await onStart(); - final lines = text.split('\n\n'); - for (var i = 0; i < lines.length; i++) { - await onProcess('$_aiResponse ${lines[i]}\n\n'); - } - await onEnd(); - } -} - -class _MockAIRepositoryLess extends Mock implements AIRepository { - @override - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }) async { - await onStart(); - // only return 1 line. - await onProcess('Hello World'); - await onEnd(); - } -} - -class _MockAIRepositoryMore extends Mock implements AIRepository { - @override - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }) async { - await onStart(); - // return 10 lines - for (var i = 0; i < 10; i++) { - await onProcess('Hello World\n\n'); - } - await onEnd(); - } -} - -class _MockErrorRepository extends Mock implements AIRepository { - @override - Future streamCompletion({ - String? objectId, - required String text, - required CompletionTypePB completionType, - required Future Function() onStart, - required Future Function(String text) onProcess, - required Future Function() onEnd, - required void Function(AIError error) onError, - }) async { - await onStart(); - onError( - const AIError( - message: 'Error', - code: AIErrorCode.aiResponseLimitExceeded, - ), - ); - } -} - -void main() { - group('AskAIActionBloc: ', () { - const text1 = '1. Select text to style using the toolbar menu.'; - const text2 = '2. Discover more styling options in Aa.'; - const text3 = - '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; - - blocTest( - 'send request before the bloc is initialized', - build: () { - final document = Document( - root: pageNode( - children: [ - paragraphNode(text: text1), - paragraphNode(text: text2), - paragraphNode(text: text3), - ], - ), - ); - final editorState = EditorState(document: document); - editorState.selection = Selection( - start: Position(path: [0]), - end: Position(path: [2], offset: text3.length), - ); - - final node = askAINode( - action: AskAIAction.makeItLonger, - content: [text1, text2, text3].join('\n'), - ); - return AskAIActionBloc( - objectId: "", - node: node, - editorState: editorState, - action: AskAIAction.makeItLonger, - enableLogging: false, - ); - }, - act: (bloc) { - bloc.add(AskAIEvent.initial(Future.value(_MockAIRepository()))); - bloc.add(const AskAIEvent.rewrite()); - }, - expect: () => [ - isA() - .having((s) => s.loading, 'loading', true) - .having((s) => s.result, 'result', isEmpty), - isA() - .having((s) => s.loading, 'loading', false) - .having((s) => s.result, 'result', isNotEmpty) - .having((s) => s.result, 'result', contains('UPDATED:')), - isA().having((s) => s.loading, 'loading', false), - ], - ); - - blocTest( - 'exceed the ai response limit', - build: () { - const text1 = '1. Select text to style using the toolbar menu.'; - const text2 = '2. Discover more styling options in Aa.'; - const text3 = - '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; - final document = Document( - root: pageNode( - children: [ - paragraphNode(text: text1), - paragraphNode(text: text2), - paragraphNode(text: text3), - ], - ), - ); - final editorState = EditorState(document: document); - editorState.selection = Selection( - start: Position(path: [0]), - end: Position(path: [2], offset: text3.length), - ); - - final node = askAINode( - action: AskAIAction.makeItLonger, - content: [text1, text2, text3].join('\n'), - ); - return AskAIActionBloc( - objectId: "", - node: node, - editorState: editorState, - action: AskAIAction.makeItLonger, - enableLogging: false, - ); - }, - act: (bloc) { - bloc.add(AskAIEvent.initial(Future.value(_MockErrorRepository()))); - bloc.add(const AskAIEvent.rewrite()); - }, - expect: () => [ - isA() - .having((s) => s.loading, 'loading', true) - .having((s) => s.result, 'result', isEmpty), - isA() - .having((s) => s.requestError, 'requestError', isNotNull) - .having( - (s) => s.requestError?.code, - 'requestError.code', - AIErrorCode.aiResponseLimitExceeded, - ), - ], - ); - - test('summary - the result contains the same number of paragraphs', - () async { - final document = Document( - root: pageNode( - children: [ - paragraphNode(text: text1), - paragraphNode(text: text2), - paragraphNode(text: text3), - ], - ), - ); - final editorState = EditorState(document: document); - editorState.selection = Selection( - start: Position(path: [0]), - end: Position(path: [2], offset: text3.length), - ); - - final node = askAINode( - action: AskAIAction.makeItLonger, - content: [text1, text2, text3].join('\n\n'), - ); - final bloc = AskAIActionBloc( - objectId: "", - node: node, - editorState: editorState, - action: AskAIAction.summarize, - enableLogging: false, - ); - bloc.add(AskAIEvent.initial(Future.value(_MockAIRepository()))); - await blocResponseFuture(); - bloc.add(const AskAIEvent.started()); - await blocResponseFuture(); - bloc.add(const AskAIEvent.replace()); - await blocResponseFuture(); - expect(editorState.document.root.children.length, 3); - expect( - editorState.getNodeAtPath([0])!.delta!.toPlainText(), - '$_aiResponse $text1', - ); - expect( - editorState.getNodeAtPath([1])!.delta!.toPlainText(), - '$_aiResponse $text2', - ); - expect( - editorState.getNodeAtPath([2])!.delta!.toPlainText(), - '$_aiResponse $text3', - ); - }); - - test('summary - the result less than the original text', () async { - final document = Document( - root: pageNode( - children: [ - paragraphNode(text: text1), - paragraphNode(text: text2), - paragraphNode(text: text3), - ], - ), - ); - final editorState = EditorState(document: document); - editorState.selection = Selection( - start: Position(path: [0]), - end: Position(path: [2], offset: text3.length), - ); - - final node = askAINode( - action: AskAIAction.makeItLonger, - content: [text1, text2, text3].join('\n'), - ); - final bloc = AskAIActionBloc( - objectId: "", - node: node, - editorState: editorState, - action: AskAIAction.summarize, - enableLogging: false, - ); - bloc.add(AskAIEvent.initial(Future.value(_MockAIRepositoryLess()))); - await blocResponseFuture(); - bloc.add(const AskAIEvent.started()); - await blocResponseFuture(); - bloc.add(const AskAIEvent.replace()); - await blocResponseFuture(); - expect(editorState.document.root.children.length, 1); - expect( - editorState.getNodeAtPath([0])!.delta!.toPlainText(), - 'Hello World', - ); - }); - - test('summary - the result more than the original text', () async { - final document = Document( - root: pageNode( - children: [ - paragraphNode(text: text1), - paragraphNode(text: text2), - paragraphNode(text: text3), - ], - ), - ); - final editorState = EditorState(document: document); - editorState.selection = Selection( - start: Position(path: [0]), - end: Position(path: [2], offset: text3.length), - ); - - final node = askAINode( - action: AskAIAction.makeItLonger, - content: [text1, text2, text3].join('\n'), - ); - final bloc = AskAIActionBloc( - objectId: "", - node: node, - editorState: editorState, - action: AskAIAction.summarize, - enableLogging: false, - ); - bloc.add(AskAIEvent.initial(Future.value(_MockAIRepositoryMore()))); - await blocResponseFuture(); - bloc.add(const AskAIEvent.started()); - await blocResponseFuture(); - bloc.add(const AskAIEvent.replace()); - await blocResponseFuture(); - expect(editorState.document.root.children.length, 10); - for (var i = 0; i < 10; i++) { - expect( - editorState.getNodeAtPath([i])!.delta!.toPlainText(), - 'Hello World', - ); - } - }); - }); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart index 29d98416b5..ece0c5e027 100644 --- a/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart @@ -34,11 +34,3 @@ class AppFlowyChatTest { }); } } - -Future boardResponseFuture() { - return Future.delayed(boardResponseDuration()); -} - -Duration boardResponseDuration({int milliseconds = 200}) { - return Duration(milliseconds: milliseconds); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart index d6d0351414..41865b7dd7 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart @@ -182,14 +182,14 @@ void main() { await blocResponseFuture(); assert(viewBloc.state.lastCreatedView!.name == gird); - var workspaceSetting = + var workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( (result) => result.fold( (l) => l, (r) => throw Exception(), ), ); - workspaceSetting.latestView.id == viewBloc.state.lastCreatedView!.id; + workspaceLatest.latestView.id == viewBloc.state.lastCreatedView!.id; // ignore: unused_local_variable final documentBloc = DocumentBloc(documentId: document.id) @@ -198,14 +198,13 @@ void main() { ); await blocResponseFuture(); - workspaceSetting = - await FolderEventGetCurrentWorkspaceSetting().send().then( - (result) => result.fold( - (l) => l, - (r) => throw Exception(), - ), - ); - workspaceSetting.latestView.id == document.id; + workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( + (result) => result.fold( + (l) => l, + (r) => throw Exception(), + ), + ); + workspaceLatest.latestView.id == document.id; }); test('create views', () async { diff --git a/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart index c5cb1c524d..21011df540 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart @@ -33,6 +33,12 @@ void main() { final node = editorState.document.root.children[0]; expect(node.delta!.toPlainText(), '⇒'); + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '=>'); + editorState.dispose(); }); @@ -56,6 +62,41 @@ void main() { final node = editorState.document.root.children[0]; expect(node.delta!.toPlainText(), '→'); + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '->'); + + editorState.dispose(); + }); + + test('turn -- into —', () async { + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: '-'), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await customFormatDoubleHyphenEmDash.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.delta!.toPlainText(), '—'); + + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '--'); + editorState.dispose(); }); }); diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart index ed60e53ae7..8b1b710f4e 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -29,7 +29,7 @@ void main() { // mock the delay of the text robot await Future.delayed(const Duration(milliseconds: 10)); } - await markdownTextRobot.stop(); + await markdownTextRobot.persist(); expect(editorState); } @@ -43,11 +43,10 @@ void main() { markdownTextRobot.start(); await markdownTextRobot.appendMarkdownText(_sample1); - await markdownTextRobot.stop(); + await markdownTextRobot.persist(); final nodes = editorState.document.root.children; - // 4 from the sample, 1 from the original empty paragraph node - expect(nodes.length, 5); + expect(nodes.length, 4); final n1 = nodes[0]; expect(n1.delta!.toPlainText(), 'The Curious Cat'); @@ -116,7 +115,7 @@ void main() { _liveRefreshSample2, expect: (editorState) { final nodes = editorState.document.root.children; - expect(nodes.length, 5); + expect(nodes.length, 4); final n1 = nodes[0]; expect(n1.type, HeadingBlockKeys.type); @@ -164,7 +163,7 @@ void main() { _liveRefreshSample3, expect: (editorState) { final nodes = editorState.document.root.children; - expect(nodes.length, 6); + expect(nodes.length, 5); final n1 = nodes[0]; expect(n1.type, HeadingBlockKeys.type); @@ -275,7 +274,7 @@ void main() { _liveRefreshSample4, expect: (editorState) { final nodes = editorState.document.root.children; - expect(nodes.length, 3); + expect(nodes.length, 2); final n1 = nodes[0]; expect(n1.type, ParagraphBlockKeys.type); @@ -295,6 +294,578 @@ void main() { ); }); }); + + group('markdown text robot - replace in same line:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1 + text2 + text3)), + ], + ), + ); + } + + // 1. create a document with a paragraph node + // 2. use the text robot to replace the selected content in the same line + // 3. check the document + test('the selection is in the middle of the text', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length, + ), + ); + + final markdownText = + '''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'World Wide Web'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' transformed the internet, making it accessible to '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'non-technical users'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' and opening the floodgates for global mass adoption.$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from start to middle', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [0], + offset: text1.length, + ), + ); + + final markdownText = + '''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'invention'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' of the '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'World Wide Web'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' by Tim Berners-Lee transformed how we access information.$text2$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from middle to end', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length + text2.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length + text3.length, + ), + ); + + final markdownText = + '''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 7); + + final d1 = afterDelta[0] as TextInsert; + expect( + d1.text, + text1 + text2, + ); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'Email'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect( + d3.text, + ' became widespread, and instant messaging services like ', + ); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'ICQ'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect(d5.text, ' and '); + expect(d5.attributes, null); + + final d6 = afterDelta[5] as TextInsert; + expect( + d6.text, + 'AOL Instant Messenger', + ); + expect(d6.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d7 = afterDelta[6] as TextInsert; + expect( + d7.text, + ' gained tremendous popularity, allowing for seamless real-time text communication across the globe.', + ); + expect(d7.attributes, null); + }); + + test('replace markdown text with selection from start to end', () async { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point.'''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption.'''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + final document = Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: text1.length), + ); + + final markdownText = '''1. $text1 + +2. $text1 + +3. $text1'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final nodes = editorState.document.root.children; + expect(nodes.length, 5); + + final d1 = nodes[0].delta!.toList()[0] as TextInsert; + expect(d1.text, text1); + expect(d1.attributes, null); + expect(nodes[0].type, NumberedListBlockKeys.type); + + final d2 = nodes[1].delta!.toList()[0] as TextInsert; + expect(d2.text, text1); + expect(d2.attributes, null); + expect(nodes[1].type, NumberedListBlockKeys.type); + + final d3 = nodes[2].delta!.toList()[0] as TextInsert; + expect(d3.text, text1); + expect(d3.attributes, null); + expect(nodes[2].type, NumberedListBlockKeys.type); + + final d4 = nodes[3].delta!.toList()[0] as TextInsert; + expect(d4.text, text2); + expect(d4.attributes, null); + + final d5 = nodes[4].delta!.toList()[0] as TextInsert; + expect(d5.text, text3); + expect(d5.attributes, null); + }); + }); + + group('markdown text robot - replace in multiple lines:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + } + + // 1. create a document with 3 paragraph nodes + // 2. use the text robot to replace the selected content in the multiple lines + // 3. check the document + test( + 'the selection starts with the first paragraph and ends with the middle of second paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [1], + offset: text2.length - + ', opening the floodgates for mass adoption. '.length, + ), + ); + + final markdownText = + '''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'introduction'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' of the World Wide Web in the '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'early 1990s'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' marked a significant turning point.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 3); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for mass adoption. ", + ); + expect(d3.attributes, null); + } + + { + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 1); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, text3); + expect(d1.attributes, null); + } + }); + + test( + 'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*. + +Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity + '''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The introduction of the World Wide Web in the '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'early 1990s'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' marked a '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'significant turning point'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' in technological history.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 5); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for ", + ); + expect(d3.attributes, null); + + final d4 = delta2[3] as TextInsert; + expect(d4.text, "unprecedented mass adoption"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta2[4] as TextInsert; + expect(d5.text, "."); + expect(d5.attributes, null); + } + + { + // third paragraph + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 7); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, "Email became "); + expect(d1.attributes, null); + + final d2 = delta3[1] as TextInsert; + expect(d2.text, "widely prevalent"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta3[2] as TextInsert; + expect(d3.text, ", and instant messaging services like "); + expect(d3.attributes, null); + + final d4 = delta3[3] as TextInsert; + expect(d4.text, "ICQ"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta3[4] as TextInsert; + expect(d5.text, " and "); + expect(d5.attributes, null); + + final d6 = delta3[5] as TextInsert; + expect(d6.text, "AOL Instant Messenger"); + expect(d6.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d7 = delta3[6] as TextInsert; + expect( + d7.text, + " gained tremendous popularity, allowing for real-time text communication.", + ); + expect(d7.attributes, null); + } + }); + + test( + 'the length of the returned response less than the length of the selected text', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 2); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, "The introduction of the World Wide Web in the "); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, "early 1990s"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, " marked a "); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, "significant turning point"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, " in technological history."); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 1); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, ", allowing for real-time text communication."); + expect(d1.attributes, null); + } + }); + }); } const _sample1 = '''# The Curious Cat diff --git a/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart index 387375c286..d2432557eb 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart @@ -1,7 +1,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide quoteNode, QuoteBlockKeys; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,11 +24,6 @@ void main() { void Function(EditorState editorState, Node node)? afterTurnInto, }) async { final editorState = EditorState(document: document); - final cubit = BlockActionOptionCubit( - editorState: editorState, - blockComponentBuilder: {}, - ); - final types = toType == null ? EditorOptionActionType.turnInto.supportTypes : [toType]; @@ -42,7 +38,12 @@ void main() { final node = editorState.getNodeAtPath([0])!; expect(node.type, originalType); - final result = await cubit.turnIntoBlock(type, node, level: level); + final result = await BlockActionOptionCubit.turnIntoBlock( + type, + node, + editorState, + level: level, + ); expect(result, true); final newNode = editorState.getNodeAtPath([0])!; expect(newNode.type, type); @@ -58,9 +59,10 @@ void main() { Selection.collapsed( Position(path: [0]), ); - await cubit.turnIntoBlock( + await BlockActionOptionCubit.turnIntoBlock( originalType, newNode, + editorState, ); expect(result, true); } @@ -163,8 +165,6 @@ void main() { for (final type in [ HeadingBlockKeys.type, - QuoteBlockKeys.type, - CalloutBlockKeys.type, ]) { test('from nested bulleted list to $type', () async { const text = 'bulleted list'; @@ -229,8 +229,6 @@ void main() { for (final type in [ HeadingBlockKeys.type, - QuoteBlockKeys.type, - CalloutBlockKeys.type, ]) { test('from nested numbered list to $type', () async { const text = 'numbered list'; @@ -295,8 +293,6 @@ void main() { for (final type in [ HeadingBlockKeys.type, - QuoteBlockKeys.type, - CalloutBlockKeys.type, ]) { // numbered list, bulleted list, todo list // before @@ -391,6 +387,8 @@ void main() { BulletedListBlockKeys.type, NumberedListBlockKeys.type, TodoListBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, ]) { // numbered list, bulleted list, todo list // before diff --git a/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart new file mode 100644 index 0000000000..5b6f88801a --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + test( + 'description', + () async { + final links = [ + 'https://www.baidu.com/', + 'https://appflowy.io/', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://github.com/', + 'https://www.figma.com/design/3K0ai4FhDOJ3Lts8G3KOVP/Page?node-id=7282-4007&p=f&t=rpfvEvh9K9J9WkIo-0', + 'https://www.figma.com/files/drafts', + 'https://www.youtube.com/watch?v=LyY5Rh9qBvA', + 'https://www.youtube.com/', + 'https://www.youtube.com/watch?v=a6GDT7', + 'http://www.test.com/', + 'https://www.baidu.com/s?wd=test&rsv_spt=1&rsv_iqid=0xb6a7840b00e5324a&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=22073068_7_oem_dg&rsv_dl=tb&rsv_enter=1&rsv_sug3=5&rsv_sug1=4&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&prefixsug=test&rsp=9&inputT=478&rsv_sug4=547', + 'https://www.google.com/', + 'https://www.google.com.hk/search?q=test&oq=test&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIHCAgQLhiABDIHCAkQABiABNIBCTE4MDJqMGoxNagCCLACAfEFAQs7K9PprSfxBQELOyvT6a0n&sourceid=chrome&ie=UTF-8', + 'www.baidu.com', + 'baidu.com', + 'com', + 'https://www.baidu.com', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://appflowy.com/app/c29fafc4-b7c0-4549-8702-71339b0fd9ea/59f36be8-9b2f-4d3e-b6a1-816c6c2043e5?blockId=GCY_T4', + ]; + + final parser = DefaultParser(); + int i = 1; + for (final link in links) { + final formatLink = LinkInfoParser.formatUrl(link); + final siteInfo = await parser + .parse(Uri.tryParse(formatLink) ?? Uri.parse(formatLink)); + if (siteInfo?.isEmpty() ?? true) { + debugPrint('$i : $formatLink ---- empty \n'); + } else { + debugPrint('$i : $formatLink ---- \n$siteInfo \n'); + } + i++; + } + }, + timeout: const Timeout(Duration(seconds: 120)), + ); +} diff --git a/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart index 70775612e2..707cc23d4f 100644 --- a/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart @@ -1,7 +1,10 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -17,21 +20,78 @@ void main() { ), ], ); - final markdown = customDocumentToMarkdown(document); + final markdown = await customDocumentToMarkdown(document); expect(markdown, '[file.txt](https://file.com)\n'); }); - test('link preview', () { + test('link preview', () async { final document = Document.blank() ..insert( [0], [linkPreviewNode(url: 'https://www.link_preview.com')], ); - final markdown = customDocumentToMarkdown(document); + final markdown = await customDocumentToMarkdown(document); expect( markdown, '[https://www.link_preview.com](https://www.link_preview.com)\n', ); }); + + test('multiple images', () async { + const png1 = 'https://www.appflowy.png', + png2 = 'https://www.appflowy2.png'; + final document = Document.blank() + ..insert( + [0], + [ + multiImageNode( + images: [ + ImageBlockData( + url: png1, + type: CustomImageType.external, + ), + ImageBlockData( + url: png2, + type: CustomImageType.external, + ), + ], + ), + ], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '![]($png1)\n![]($png2)', + ); + }); + + test('subpage block', () async { + const testSubpageId = 'testSubpageId'; + final subpageNode = pageMentionNode(testSubpageId); + final document = Document.blank() + ..insert( + [0], + [subpageNode], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '[]($testSubpageId)\n', + ); + }); + + test('date or reminder', () async { + final dateTime = DateTime.now(); + final document = Document.blank() + ..insert( + [0], + [dateMentionNode()], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '${DateFormat.yMMMd().format(dateTime)}\n', + ); + }); }); } diff --git a/frontend/appflowy_flutter/test/util.dart b/frontend/appflowy_flutter/test/util.dart index c4f2a21c64..3bb774411b 100644 --- a/frontend/appflowy_flutter/test/util.dart +++ b/frontend/appflowy_flutter/test/util.dart @@ -69,7 +69,10 @@ class AppFlowyUnitTest { } Future _initialServices() async { - workspaceService = WorkspaceService(workspaceId: currentWorkspace.id); + workspaceService = WorkspaceService( + workspaceId: currentWorkspace.id, + userId: userProfile.id, + ); } Future createWorkspace() async { diff --git a/frontend/appflowy_flutter/test/widget_test/test_material_app.dart b/frontend/appflowy_flutter/test/widget_test/test_material_app.dart index ecb06b97e2..2ebc61a8bc 100644 --- a/frontend/appflowy_flutter/test/widget_test/test_material_app.dart +++ b/frontend/appflowy_flutter/test/widget_test/test_material_app.dart @@ -62,6 +62,7 @@ class WidgetTestApp extends StatelessWidget { scrollbarColor: Colors.transparent, scrollbarHoverColor: Colors.transparent, lightIconColor: Colors.transparent, + toolbarHoverColor: Colors.transparent, ), ], ), diff --git a/frontend/appflowy_flutter/windows/runner/Runner.rc b/frontend/appflowy_flutter/windows/runner/Runner.rc index 77795cde10..3477dab755 100644 --- a/frontend/appflowy_flutter/windows/runner/Runner.rc +++ b/frontend/appflowy_flutter/windows/runner/Runner.rc @@ -119,3 +119,11 @@ END ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED + +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle +// + +// And verify signature using DSA public key: +DSAPub DSAPEM "../../dsa_pub.pem" \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg b/frontend/resources/flowy_icons/16x/ai_chat_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/16x/flowy_ai_chat_logo.svg rename to frontend/resources/flowy_icons/16x/ai_chat_logo.svg diff --git a/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg b/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg new file mode 100644 index 0000000000..2f7df6c78a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_improve_writing.svg b/frontend/resources/flowy_icons/16x/ai_improve_writing.svg new file mode 100644 index 0000000000..9cff9e9875 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_improve_writing.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_make_longer.svg b/frontend/resources/flowy_icons/16x/ai_make_longer.svg new file mode 100644 index 0000000000..9f61441f0f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_make_longer.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_make_shorter.svg b/frontend/resources/flowy_icons/16x/ai_make_shorter.svg new file mode 100644 index 0000000000..5f07c58fcc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_make_shorter.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_sparks.svg b/frontend/resources/flowy_icons/16x/ai_sparks.svg index dc23a6c287..a419454f8e 100644 --- a/frontend/resources/flowy_icons/16x/ai_sparks.svg +++ b/frontend/resources/flowy_icons/16x/ai_sparks.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/frontend/resources/flowy_icons/16x/ai_summarize.svg b/frontend/resources/flowy_icons/16x/ai_summarize.svg new file mode 100644 index 0000000000..761dae9dc0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_summarize.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_undo.svg b/frontend/resources/flowy_icons/16x/ai_try_again.svg similarity index 100% rename from frontend/resources/flowy_icons/16x/ai_undo.svg rename to frontend/resources/flowy_icons/16x/ai_try_again.svg diff --git a/frontend/resources/flowy_icons/16x/flowy_logo.svg b/frontend/resources/flowy_icons/16x/app_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/16x/flowy_logo.svg rename to frontend/resources/flowy_icons/16x/app_logo.svg diff --git a/frontend/resources/flowy_icons/24x/calendar_layout.svg b/frontend/resources/flowy_icons/16x/calendar_layout.svg similarity index 100% rename from frontend/resources/flowy_icons/24x/calendar_layout.svg rename to frontend/resources/flowy_icons/16x/calendar_layout.svg diff --git a/frontend/resources/flowy_icons/24x/close_filled.svg b/frontend/resources/flowy_icons/16x/close_filled.svg similarity index 100% rename from frontend/resources/flowy_icons/24x/close_filled.svg rename to frontend/resources/flowy_icons/16x/close_filled.svg diff --git a/frontend/resources/flowy_icons/16x/database_filter.svg b/frontend/resources/flowy_icons/16x/database_filter.svg new file mode 100644 index 0000000000..8ca4fa3e51 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/database_fullscreen.svg b/frontend/resources/flowy_icons/16x/database_fullscreen.svg new file mode 100644 index 0000000000..7e8794cbc5 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_fullscreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/database_layout.svg b/frontend/resources/flowy_icons/16x/database_layout.svg similarity index 100% rename from frontend/resources/flowy_icons/24x/database_layout.svg rename to frontend/resources/flowy_icons/16x/database_layout.svg diff --git a/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg b/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg new file mode 100644 index 0000000000..00fe13b7fc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/database_sort.svg b/frontend/resources/flowy_icons/16x/database_sort.svg new file mode 100644 index 0000000000..3df5682f3a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_sort.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/help_and_documentation.svg b/frontend/resources/flowy_icons/16x/help_and_documentation.svg new file mode 100644 index 0000000000..e4c68c2583 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/help_and_documentation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/lock_page.svg b/frontend/resources/flowy_icons/16x/lock_page.svg new file mode 100644 index 0000000000..b68b7ab42f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/lock_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/lock_page_fill.svg b/frontend/resources/flowy_icons/16x/lock_page_fill.svg new file mode 100644 index 0000000000..b2ed846e69 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/lock_page_fill.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg b/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg new file mode 100644 index 0000000000..3f23cbd709 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg new file mode 100644 index 0000000000..aa4dbff160 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg new file mode 100644 index 0000000000..6eb3aeab2b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg new file mode 100644 index 0000000000..b3b3e55452 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg new file mode 100644 index 0000000000..65434c3316 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg b/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg new file mode 100644 index 0000000000..aa071665c3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/unlock_page.svg b/frontend/resources/flowy_icons/16x/unlock_page.svg new file mode 100644 index 0000000000..38f60dedb8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/unlock_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/ai_explain.svg b/frontend/resources/flowy_icons/20x/ai_explain.svg new file mode 100644 index 0000000000..f490472688 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/ai_explain.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/anonymous_mode.svg b/frontend/resources/flowy_icons/20x/anonymous_mode.svg new file mode 100644 index 0000000000..bee519e54a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/anonymous_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/cloud_mode.svg b/frontend/resources/flowy_icons/20x/cloud_mode.svg new file mode 100644 index 0000000000..5aaf68e3db --- /dev/null +++ b/frontend/resources/flowy_icons/20x/cloud_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/embed_fullscreen.svg b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg new file mode 100644 index 0000000000..b8b197fb13 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/hide_password.svg b/frontend/resources/flowy_icons/20x/hide_password.svg new file mode 100644 index 0000000000..2ebd274866 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/hide_password.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/password_close.svg b/frontend/resources/flowy_icons/20x/password_close.svg new file mode 100644 index 0000000000..52a44e1a8e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/password_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/show_password.svg b/frontend/resources/flowy_icons/20x/show_password.svg new file mode 100644 index 0000000000..ac8d092b37 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/show_password.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/sign_in_settings.svg b/frontend/resources/flowy_icons/20x/sign_in_settings.svg new file mode 100644 index 0000000000..5d88d23086 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/sign_in_settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/slash_menu_image.svg b/frontend/resources/flowy_icons/20x/slash_menu_image.svg new file mode 100644 index 0000000000..f5b7917ad3 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/slash_menu_image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg b/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg new file mode 100644 index 0000000000..dd0390d2d5 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg b/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg new file mode 100644 index 0000000000..a8c8657135 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_alignment.svg b/frontend/resources/flowy_icons/20x/toolbar_alignment.svg new file mode 100644 index 0000000000..638ff3ece8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_alignment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg b/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg new file mode 100644 index 0000000000..e6ef664403 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg b/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg new file mode 100644 index 0000000000..2e39539ab0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_bold.svg b/frontend/resources/flowy_icons/20x/toolbar_bold.svg new file mode 100644 index 0000000000..a131c6aa3e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_check.svg b/frontend/resources/flowy_icons/20x/toolbar_check.svg new file mode 100644 index 0000000000..e59186292c --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg b/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg new file mode 100644 index 0000000000..c263b0c66b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg b/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg new file mode 100644 index 0000000000..bc17a7b05b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link.svg b/frontend/resources/flowy_icons/20x/toolbar_link.svg new file mode 100644 index 0000000000..8564d243d0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg new file mode 100644 index 0000000000..57cb67da9a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg new file mode 100644 index 0000000000..fc8765fa5b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg new file mode 100644 index 0000000000..e1061b914a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_more.svg b/frontend/resources/flowy_icons/20x/toolbar_more.svg new file mode 100644 index 0000000000..d156f313a1 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg new file mode 100644 index 0000000000..87c67115fb --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg new file mode 100644 index 0000000000..bcaebfe5d0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg new file mode 100644 index 0000000000..68069290ce --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_color.svg b/frontend/resources/flowy_icons/20x/toolbar_text_color.svg new file mode 100644 index 0000000000..e96b00ac35 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_color.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_format.svg b/frontend/resources/flowy_icons/20x/toolbar_text_format.svg new file mode 100644 index 0000000000..0f3cd07a01 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_format.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg b/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg new file mode 100644 index 0000000000..ab53a64b38 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_underline.svg b/frontend/resources/flowy_icons/20x/toolbar_underline.svg new file mode 100644 index 0000000000..ea467a45d6 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/turninto.svg b/frontend/resources/flowy_icons/20x/turninto.svg new file mode 100644 index 0000000000..598b870ec7 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/turninto.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_bulleted_list.svg b/frontend/resources/flowy_icons/20x/type_bulleted_list.svg new file mode 100644 index 0000000000..bc726f59ec --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_bulleted_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/20x/type_callout.svg b/frontend/resources/flowy_icons/20x/type_callout.svg new file mode 100644 index 0000000000..a933b4bbb3 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_callout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_font.svg b/frontend/resources/flowy_icons/20x/type_font.svg new file mode 100644 index 0000000000..d0b33b0277 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_font.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_formula.svg b/frontend/resources/flowy_icons/20x/type_formula.svg new file mode 100644 index 0000000000..316c225b79 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_formula.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h1.svg b/frontend/resources/flowy_icons/20x/type_h1.svg new file mode 100644 index 0000000000..a6a7f561cf --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h2.svg b/frontend/resources/flowy_icons/20x/type_h2.svg new file mode 100644 index 0000000000..9bba1b7d33 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h3.svg b/frontend/resources/flowy_icons/20x/type_h3.svg new file mode 100644 index 0000000000..3b231df67d --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_numbered_list.svg b/frontend/resources/flowy_icons/20x/type_numbered_list.svg new file mode 100644 index 0000000000..23046f9b34 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_numbered_list.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/type_page.svg b/frontend/resources/flowy_icons/20x/type_page.svg new file mode 100644 index 0000000000..405548fcf7 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_page.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_quote.svg b/frontend/resources/flowy_icons/20x/type_quote.svg new file mode 100644 index 0000000000..3564d92ff8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_strikethrough.svg b/frontend/resources/flowy_icons/20x/type_strikethrough.svg new file mode 100644 index 0000000000..dbf4e86116 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_strikethrough.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_text.svg b/frontend/resources/flowy_icons/20x/type_text.svg new file mode 100644 index 0000000000..40335aa89b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_text.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_todo.svg b/frontend/resources/flowy_icons/20x/type_todo.svg new file mode 100644 index 0000000000..3d4f38ae9f --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_todo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h1.svg b/frontend/resources/flowy_icons/20x/type_toggle_h1.svg new file mode 100644 index 0000000000..45cc7d3859 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h2.svg b/frontend/resources/flowy_icons/20x/type_toggle_h2.svg new file mode 100644 index 0000000000..3dce8523e8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h3.svg b/frontend/resources/flowy_icons/20x/type_toggle_h3.svg new file mode 100644 index 0000000000..e619a5f250 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_list.svg b/frontend/resources/flowy_icons/20x/type_toggle_list.svg new file mode 100644 index 0000000000..2cb1e83599 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/40x/flowy_logo.svg b/frontend/resources/flowy_icons/40x/app_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo.svg rename to frontend/resources/flowy_icons/40x/app_logo.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_text.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_text.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg diff --git a/frontend/resources/flowy_icons/40x/embed_error.svg b/frontend/resources/flowy_icons/40x/embed_error.svg new file mode 100644 index 0000000000..68196c7b7e --- /dev/null +++ b/frontend/resources/flowy_icons/40x/embed_error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 5e1cbf853c..e8ca8c4ceb 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -155,7 +155,8 @@ "charCountLabel": "عدد الأحرف: ", "createdAtLabel": "تم إنشاؤه: ", "syncedAtLabel": "تم المزامنة: ", - "saveAsNewPage": "حفظ الرسائل في الصفحة" + "saveAsNewPage": "حفظ الرسائل في الصفحة", + "saveAsNewPageDisabled": "لا توجد رسائل متاحة" }, "importPanel": { "textAndMarkdown": "نص و Markdown", @@ -165,6 +166,15 @@ "csv": "CSV", "database": "قاعدة البيانات" }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "اسحب وأفلِت ملفًا، وانقر فوقه ", + "placeholderUpload": "رفع", + "placeholderRight": "أو قم بلصق رابط الصورة.", + "dropToUpload": "إفلات ملف لتحميله", + "change": "تغير" + } + }, "disclosureAction": { "rename": "إعادة تسمية", "delete": "يمسح", @@ -178,7 +188,8 @@ "changeIcon": "تغيير الأيقونة", "collapseAllPages": "طي جميع الصفحات الفرعية", "movePageTo": "تحريك الصفحة إلى", - "move": "تحريك" + "move": "تحريك", + "lockPage": "إلغاء تأمين الصفحة" }, "blankPageTitle": "صفحة فارغة", "newPageText": "صفحة جديدة", @@ -218,6 +229,7 @@ "indexingFile": "الفهرسة {}", "generatingResponse": "توليد الاستجابة", "selectSources": "اختر المصادر", + "currentPage": "الصفحة الحالية", "sourcesLimitReached": "يمكنك فقط تحديد ما يصل إلى 3 مستندات من المستوى العلوي ومستنداتها الفرعية", "sourceUnsupported": "نحن لا ندعم الدردشة مع قواعد البيانات في الوقت الحالي", "regenerate": "حاول ثانية", @@ -244,12 +256,19 @@ "bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة", "tableWithImageDescription": "@:chat.changeFormat.table مع الصورة" }, + "switchModel": { + "label": "تبديل النموذج", + "localModel": "النموذج المحلي", + "cloudModel": "نموذج السحابة", + "autoModel": "آلي" + }, "selectBanner": { "saveButton": "أضف إلى...", "selectMessages": "حدد الرسائل", "nSelected": "{} تم التحديد", "allSelected": "جميعها محددة" - } + }, + "stopTooltip": "توقف عن التوليد" }, "trash": { "text": "المهملات", @@ -292,14 +311,16 @@ "questionBubble": { "shortcuts": "الاختصارات", "whatsNew": "ما هو الجديد؟", - "help": "المساعدة والدعم", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", "markdown": "Markdown", "debug": { "name": "معلومات التصحيح", "success": "تم نسخ معلومات التصحيح إلى الحافظة!", "fail": "تعذر نسخ معلومات التصحيح إلى الحافظة" }, - "feedback": "تعليق" + "feedback": "تعليق", + "help": "المساعدة والدعم" }, "menuAppHeader": { "moreButtonToolTip": "إزالة وإعادة تسمية والمزيد...", @@ -377,13 +398,14 @@ "storageLimitDialogTitle": "لقد نفدت مساحة التخزين المجانية لديك. قم بالترقية لإلغاء تأمين مساحة تخزين غير محدودة", "storageLimitDialogTitleIOS": "لقد نفدت مساحة التخزين المجانية.", "aiResponseLimitTitle": "لقد نفدت منك استجابات الذكاء الاصطناعي المجانية. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", - "aiResponseLimitTitleIOS": "لقد نفدت استجابات الذكاء الاصطناعي المجانية.", "aiResponseLimitDialogTitle": "تم الوصول إلى الحد الأقصى لاستجابات الذكاء الاصطناعي", "aiResponseLimit": "لقد نفدت استجابات الذكاء الاصطناعي المجانية.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max أو Pro Plan للحصول على المزيد من استجابات الذكاء الاصطناعي", "askOwnerToUpgradeToPro": "مساحة العمل الخاصة بك نفدت من مساحة التخزين المجانية. يرجى مطالبة مالك مساحة العمل الخاصة بك بالترقية إلى الخطة الاحترافية", "askOwnerToUpgradeToProIOS": "مساحة العمل الخاصة بك على وشك النفاد من مساحة التخزين المجانية.", "askOwnerToUpgradeToAIMax": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بترقية الخطة أو شراء إضافات الذكاء الاصطناعي", "askOwnerToUpgradeToAIMaxIOS": "مساحة العمل الخاصة بك تفتقر إلى الاستجابات المجانية للذكاء الاصطناعي.", + "purchaseAIMax": "لقد نفدت استجابات الصور بالذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بشراء AI Max", + "aiImageResponseLimit": "لقد نفدت استجابات الصور الخاصة بالذكاء الاصطناعي.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max للحصول على المزيد من استجابات صور AI", "purchaseStorageSpace": "شراء مساحة تخزين", "singleFileProPlanLimitationDescription": "لقد تجاوزت الحد الأقصى لحجم تحميل الملف المسموح به في الخطة المجانية. يرجى الترقية إلى الخطة الاحترافية لتحميل ملفات أكبر حجمًا", "purchaseAIResponse": "شراء", @@ -493,6 +515,8 @@ "settings": "إعدادات", "members": "الأعضاء", "trash": "سلة المحذوفات", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", "helpAndSupport": "المساعدة والدعم" }, "sites": { @@ -574,7 +598,9 @@ "title": "تسجيل الدخول إلى الحساب", "loginLabel": "تسجيل الدخول", "logoutLabel": "تسجيل الخروج" - } + }, + "isUpToDate": "تم تحديث @:appName !", + "officialVersion": "الإصدار {version} (الإصدار الرسمي)" }, "workspacePage": { "menuLabel": "مساحة العمل", @@ -750,6 +776,7 @@ "alignLeft": "محاذاة النص إلى اليسار", "alignCenter": "محاذاة النص إلى الوسط", "alignRight": "محاذاة النص إلى اليمين", + "insertInlineMathEquation": "إدراج معادلة رياضية مضمنة", "undo": "التراجع", "redo": "الإعادة", "convertToParagraph": "تحويل الكتلة إلى فقرة", @@ -840,11 +867,17 @@ "localAIStart": "بدأت الدردشة المحلية بالذكاء الاصطناعي...", "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", + "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", + "localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا", + "localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.", + "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", + "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", "restartLocalAI": "إعادة تشغيل الذكاء الاصطناعي المحلي", "disableLocalAITitle": "تعطيل الذكاء الاصطناعي المحلي", "disableLocalAIDescription": "هل تريد تعطيل الذكاء الاصطناعي المحلي؟", "localAIToggleTitle": "التبديل لتفعيل أو تعطيل الذكاء الاصطناعي المحلي", + "localAIToggleSubTitle": "قم بتشغيل نماذج الذكاء الاصطناعي المحلية الأكثر تقدمًا داخل AppFlowy للحصول على أقصى درجات الخصوصية والأمان", "offlineAIInstruction1": "اتبع", "offlineAIInstruction2": "تعليمات", "offlineAIInstruction3": "لتفعيل الذكاء الاصطناعي دون اتصال بالإنترنت.", @@ -853,7 +886,15 @@ "offlineAIDownload3": "إنه أولا", "activeOfflineAI": "نشط", "downloadOfflineAI": "التنزيل", - "openModelDirectory": "افتح المجلد" + "openModelDirectory": "افتح المجلد", + "laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.", + "ollamaNotReady": "خادم Ollama غير جاهز.", + "pleaseFollowThese": "اتبع هؤلاء", + "instructions": "التعليمات", + "installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", + "modelsMissing": "لم يتم العثور على النماذج المطلوبة.", + "downloadModel": "لتنزيلها.", + "startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي" } }, "planPage": { @@ -995,6 +1036,7 @@ "itemFour": "التعاون في الزمن الحقيقي", "itemFive": "تطبيق الجوال", "itemSix": "استجابات الذكاء الاصطناعي", + "itemSeven": "صور الذكاء الاصطناعي", "itemFileUpload": "رفع الملفات", "customNamespace": "مساحة اسم مخصصة", "tooltipSix": "تعني مدة الحياة أن عدد الاستجابات لا يتم إعادة ضبطه أبدًا", @@ -1009,6 +1051,7 @@ "itemFour": "نعم", "itemFive": "نعم", "itemSix": "10 مدى الحياة", + "itemSeven": "2 مدى الحياة", "itemFileUpload": "حتى 7 ميجا بايت", "intelligentSearch": "البحث الذكي" }, @@ -1019,6 +1062,7 @@ "itemFour": "نعم", "itemFive": "نعم", "itemSix": "غير محدود", + "itemSeven": "10 صور شهريا", "itemFileUpload": "غير محدود", "intelligentSearch": "البحث الذكي" }, @@ -1194,6 +1238,7 @@ "system": "التكيف مع النظام" }, "fontScaleFactor": "عامل مقياس الخط", + "displaySize": "حجم العرض", "documentSettings": { "cursorColor": "لون مؤشر المستند", "selectionColor": "لون اختيار المستند", @@ -1394,6 +1439,7 @@ "filterBy": "مصنف بواسطة...", "typeAValue": "اكتب قيمة ...", "layout": "تَخطِيط", + "compactMode": "الوضع المضغوط", "databaseLayout": "تَخطِيط", "viewList": { "zero": "0 مشاهدات", @@ -1630,8 +1676,7 @@ "url": { "launch": "فتح في المتصفح", "copy": "إنسخ الرابط", - "textFieldHint": "أدخل عنوان URL", - "copiedNotification": "تمت نسخها إلى الحافظة!" + "textFieldHint": "أدخل عنوان URL" }, "relation": { "relatedDatabasePlaceLabel": "قاعدة البيانات ذات الصلة", @@ -1702,6 +1747,14 @@ "selectADocumentToLinkTo": "حدد مستندًا للارتباط به" }, "name": { + "textStyle": "نمط النص", + "list": "قائمة", + "toggle": "تبديل", + "fileAndMedia": "الملفات والوسائط", + "simpleTable": "جدول بسيط", + "visuals": "المرئيات", + "document": "وثيقة", + "advanced": "متقدم", "text": "نص", "heading1": "العنوان 1", "heading2": "العنوان 2", @@ -1733,7 +1786,10 @@ "aiWriter": "كاتب الذكاء الاصطناعي", "dateOrReminder": "التاريخ أو التذكير", "photoGallery": "معرض الصور", - "file": "الملف" + "file": "الملف", + "twoColumns": "عمودين", + "threeColumns": "3 أعمدة", + "fourColumns": "4 أعمدة" }, "subPage": { "name": "المستند", @@ -1756,6 +1812,16 @@ "referencedGrid": "الشبكة المشار إليها", "referencedCalendar": "التقويم المشار إليه", "referencedDocument": "الوثيقة المشار إليها", + "aiWriter": { + "userQuestion": "اسأل الذكاء الاصطناعي عن أي شيء", + "continueWriting": "استمر في الكتابة", + "fixSpelling": "تصحيح الأخطاء الإملائية والنحوية", + "improveWriting": "تحسين الكتابة", + "summarize": "تلخيص", + "explain": "اشرح", + "makeShorter": "اجعلها أقصر", + "makeLonger": "اجعلها أطول" + }, "autoGeneratorMenuItemName": "كاتب AI", "autoGeneratorTitleName": "AI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", "autoGeneratorLearnMore": "يتعلم أكثر", @@ -2080,7 +2146,28 @@ "morePages": "المزيد من الصفحات" }, "toolbar": { - "resetToDefaultFont": "إعادة تعيين إلى الافتراضي" + "resetToDefaultFont": "إعادة تعيين إلى الافتراضي", + "textSize": "حجم النص", + "textColor": "لون النص", + "h1": "العنوان 1", + "h2": "العنوان 2", + "h3": "العنوان 3", + "alignLeft": "محاذاة إلى اليسار", + "alignRight": "محاذاة إلى اليمين", + "alignCenter": "محاذاة إلى الوسط", + "link": "وصلة", + "textAlign": "محاذاة النص", + "moreOptions": "المزيد من الخيارات", + "font": "الخط", + "inlineCode": "الكود المضمن", + "suggestions": "اقتراحات", + "turnInto": "تحول إلى", + "equation": "معادلة", + "insert": "إدراج", + "linkInputHint": "لصق الرابط أو البحث عن الصفحات", + "pageOrURL": "الصفحة أو عنوان URL", + "linkName": "اسم الرابط", + "linkNameHint": "اسم رابط الإدخال" }, "errorBlock": { "theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.", @@ -2536,6 +2623,7 @@ "accountLogin": "تسجيل الدخول إلى الحساب", "updateNameError": "فشل في تحديث الاسم", "updateIconError": "فشل في تحديث الأيقونة", + "aboutAppFlowy": "حول appName", "deleteAccount": { "title": "حذف الحساب", "subtitle": "احذف حسابك وجميع بياناتك بشكل دائم.", @@ -2544,12 +2632,12 @@ "dialogTitle": "حذف الحساب", "dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟", "dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.", - "confirmHint1": "من فضلك اكتب \"حذف حسابي\" للتأكيد.", + "confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.", "confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.", "confirmHint3": "حذف حسابي", "checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف", "failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم", - "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"حذف حسابي\"", + "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "تم حذف الحساب بنجاح" } }, @@ -2726,6 +2814,7 @@ "moreOptions": "المزيد من الخيارات", "collapse": "طي", "signInAgreement": "بالنقر فوق \"متابعة\" أعلاه، فإنك توافق على شروط استخدام AppFlowy", + "signInLocalAgreement": "من خلال النقر على \"البدء\" أعلاه، فإنك توافق على شروط وأحكام AppFlowy", "and": "و", "termOfUse": "شروط", "privacyPolicy": "سياسة الخصوصية", @@ -3105,6 +3194,50 @@ } }, "ai": { - "contentPolicyViolation": "فشل إنشاء الصورة بسبب محتوى حساس. يرجى إعادة صياغة إدخالك والمحاولة مرة أخرى" + "contentPolicyViolation": "فشل إنشاء الصورة بسبب محتوى حساس. يرجى إعادة صياغة إدخالك والمحاولة مرة أخرى", + "textLimitReachedDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة للذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", + "imageLimitReachedDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يُرجى الترقية إلى الخطة الاحترافية أو شراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", + "limitReachedAction": { + "textDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. للحصول على المزيد من الاستجابات، يرجى", + "imageDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يرجى", + "upgrade": "ترقية", + "toThe": "الى", + "proPlan": "الخطة الاحترافية", + "orPurchaseAn": "أو شراء", + "aiAddon": "مَرافِق الذكاء الاصطناعي" + }, + "editing": "تحرير", + "analyzing": "تحليل", + "continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ", + "continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!", + "more": "أكثر" + }, + "autoUpdate": { + "criticalUpdateTitle": "التحديث ضروري للمتابعة", + "criticalUpdateDescription": "لقد أجرينا تحسينات لتحسين تجربتك! يُرجى التحديث من {currentVersion} إلى {newVersion} لمواصلة استخدام التطبيق.", + "criticalUpdateButton": "تحديث", + "bannerUpdateTitle": "النسخة الجديدة متاحة!", + "bannerUpdateDescription": "احصل على أحدث الميزات والإصلاحات. انقر على \"تحديث\" للتثبيت الآن.", + "bannerUpdateButton": "تحديث", + "settingsUpdateTitle": "الإصدار الجديد ({newVersion}) متاح!", + "settingsUpdateDescription": "الإصدار الحالي: {currentVersion} (الإصدار الرسمي) → {newVersion}", + "settingsUpdateButton": "تحديث", + "settingsUpdateWhatsNew": "ما الجديد" + }, + "lockPage": { + "lockPage": "مقفل", + "reLockPage": "إعادة القفل", + "lockTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود. انقر لفتح القفل.", + "pageLockedToast": "الصفحة مقفلة. التعديل معطل حتى يفتحها أحد.", + "lockedOperationTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود." + }, + "suggestion": { + "accept": "يقبل", + "keep": "يحفظ", + "discard": "تجاهل", + "close": "يغلق", + "tryAgain": "حاول ثانية", + "rewrite": "إعادة كتابة", + "insertBelow": "أدخل أدناه" } } diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index f388cf9bd5..8d98cb5cbc 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -133,14 +133,14 @@ "questionBubble": { "shortcuts": "Dreceres", "whatsNew": "Què hi ha de nou?", - "help": "Ajuda i Suport", "markdown": "Reducció", "debug": { "name": "Informació de depuració", "success": "S'ha copiat la informació de depuració!", "fail": "No es pot copiar la informació de depuració" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Ajuda i Suport" }, "menuAppHeader": { "moreButtonToolTip": "Suprimeix, canvia el nom i més...", diff --git a/frontend/resources/translations/ckb-KU.json b/frontend/resources/translations/ckb-KU.json index 4acb7a1765..acfc571536 100644 --- a/frontend/resources/translations/ckb-KU.json +++ b/frontend/resources/translations/ckb-KU.json @@ -170,14 +170,14 @@ "questionBubble": { "shortcuts": "کورتە ڕێگاکان", "whatsNew": "نوێترین", - "help": "پشتیوانی و یارمەتی", "markdown": "Markdown", "debug": { "name": "زانیاری دیباگ", "success": "زانیارییەکانی دیباگ کۆپی کراون بۆ کلیپبۆرد!", "fail": "ناتوانرێت زانیارییەکانی دیباگ کۆپی بکات بۆ کلیپبۆرد" }, - "feedback": "فیدباک" + "feedback": "فیدباک", + "help": "پشتیوانی و یارمەتی" }, "menuAppHeader": { "moreButtonToolTip": "سڕینەوە، گۆڕینی ناو، و زۆر شتی تر...", diff --git a/frontend/resources/translations/cs-CZ.json b/frontend/resources/translations/cs-CZ.json index 07e5a01bea..28750dd542 100644 --- a/frontend/resources/translations/cs-CZ.json +++ b/frontend/resources/translations/cs-CZ.json @@ -134,14 +134,14 @@ "questionBubble": { "shortcuts": "Klávesové zkratky", "whatsNew": "Co je nového?", - "help": "Pomoc a podpora", "markdown": "Markdown", "debug": { "name": "Debug informace", "success": "Debug informace zkopírovány do schránky!", "fail": "Nepodařilo se zkopáí" }, - "feedback": "Zpětná vazba" + "feedback": "Zpětná vazba", + "help": "Pomoc a podpora" }, "menuAppHeader": { "moreButtonToolTip": "Smazat, přejmenovat, a další...", diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index cf22086286..65a7fbea05 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -252,14 +252,14 @@ "questionBubble": { "shortcuts": "Tastenkürzel", "whatsNew": "Was gibt es Neues?", - "help": "Hilfe & Support", "markdown": "Markdown", "debug": { "name": "Debug-Informationen", "success": "Debug-Informationen in die Zwischenablage kopiert!", "fail": "Debug-Informationen konnten nicht in die Zwischenablage kopiert werden" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Hilfe & Support" }, "menuAppHeader": { "moreButtonToolTip": "Entfernen, umbenennen und mehr...", @@ -338,7 +338,6 @@ "storageLimitDialogTitle": "Dein freier Speicherplatz ist aufgebraucht. Upgrade deinen Plan, um unbegrenzten Speicherplatz freizuschalten.", "storageLimitDialogTitleIOS": "Ihr freier Speicherplatz ist aufgebraucht.", "aiResponseLimitTitle": "Du hast keine kostenlosen KI-Antworten mehr. Upgrade auf den Pro-Plan oder kaufe ein KI-Add-on, um unbegrenzte Antworten freizuschalten", - "aiResponseLimitTitleIOS": "Ihre kostenlosen KI-Antworten sind aufgebraucht.", "aiResponseLimitDialogTitle": "Limit für KI-Antworten erreicht", "aiResponseLimit": "Du hast keine kostenlosen KI-Antworten mehr zur Verfügung.\n\nGehe zu Einstellungen -> Plan -> Klicke auf KI Max oder Pro Plan, um mehr KI-Antworten zu erhalten", "askOwnerToUpgradeToPro": "Dein Arbeitsbereich hat nicht mehr genügend freien Speicherplatz. Bitte den Eigentümer deines Arbeitsbereichs, auf den Pro-Plan hochzustufen.", @@ -980,13 +979,13 @@ "itemFour": "Gäste", "itemFive": "Speicher", "itemSix": "Zusammenarbeit in Echtzeit", + "itemSeven": "Mobile App", "itemFileUpload": "Datei-Uploads", "customNamespace": "Benutzerdefinierter Namensraum", "tooltipSix": "Lebenslang bedeutet, dass die Anzahl der Antworten nie zurückgesetzt wird", "intelligentSearch": "Intelligente Suche", "tooltipSeven": "Ermöglicht dir, einen Teil der URL für deinen Arbeitsbereich anzupassen", "customNamespaceTooltip": "Benutzerdefinierte veröffentlichte Seiten-URL", - "itemSeven": "Mobile App", "tooltipThree": "Gäste haben nur Leserechte für die speziell freigegebenen Inhalte", "tooltipFour": "Gäste werden als ein Sitzplatz abgerechnet", "itemEight": "AI-Antworten", @@ -999,9 +998,9 @@ "itemFour": "0", "itemFive": "5 GB", "itemSix": "10 Lebenszeiten", + "itemSeven": "ja", "itemFileUpload": "Bis zu 7 MB", "intelligentSearch": "Intelligente Suche", - "itemSeven": "ja", "itemEight": "1.000 lebenslang" }, "proLabels": { @@ -1011,9 +1010,9 @@ "itemFour": "10 Gäste werden als ein Sitzplatz berechnet", "itemFive": "unbegrenzt", "itemSix": "ja", + "itemSeven": "ja", "itemFileUpload": "Unbegrenzt", "intelligentSearch": "Intelligente Suche", - "itemSeven": "ja", "itemEight": "10.000 monatlich" }, "paymentSuccess": { @@ -1626,8 +1625,7 @@ "url": { "launch": "Im Browser öffnen", "copy": "Webadresse kopieren", - "textFieldHint": "Gebe eine URL ein", - "copiedNotification": "In die Zwischenablage kopiert!" + "textFieldHint": "Gebe eine URL ein" }, "relation": { "relatedDatabasePlaceLabel": "Verwandte Datenbank", @@ -2518,11 +2516,11 @@ "dialogTitle": "Benutzerkonto löschen", "dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?", "dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.", - "confirmHint1": "Geben Sie zur Bestätigung bitte „MEIN KONTO LÖSCHEN“ ein.", + "confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.", "confirmHint3": "MEIN KONTO LÖSCHEN", "checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen", "failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.", - "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „MEIN KONTO LÖSCHEN“ überein.", + "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.", "deleteAccountSuccess": "Konto erfolgreich gelöscht" } }, diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json index 34f43b87ac..a329a8998c 100644 --- a/frontend/resources/translations/el-GR.json +++ b/frontend/resources/translations/el-GR.json @@ -724,8 +724,7 @@ "url": { "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", "copy": "Copy link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", @@ -1403,4 +1402,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 09d2cf4e05..30e8c476ae 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -36,7 +36,9 @@ "loginButtonText": "Login", "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", - "anonymous": "Anonymous", + "continueWithLocalModel": "Continue with local model", + "switchToAppFlowyCloud": "AppFlowy Cloud", + "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", "forgotPassword": "Forgot Password?", @@ -47,7 +49,7 @@ "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", - "or": "OR", + "or": "or", "signInWithGoogle": "Continue with Google", "signInWithGithub": "Continue with GitHub", "signInWithDiscord": "Continue with Discord", @@ -68,7 +70,22 @@ "logIn": "Log in", "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", - "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes." + "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", + "tokenHasExpiredOrInvalid": "The code has expired or is invalid. Please try again.", + "signingIn": "Signing in...", + "checkYourEmail": "Check your email", + "temporaryVerificationLinkSent": "A temporary verification link has been sent.\nPlease check your inbox at", + "temporaryVerificationCodeSent": "A temporary verification code has been sent.\nPlease check your inbox at", + "continueToSignIn": "Continue to sign in", + "backToLogin": "Back to login", + "enterCode": "Enter code", + "enterCodeManually": "Enter code manually", + "continueWithEmail": "Continue with email", + "enterPassword": "Enter password", + "loginAs": "Login as", + "invalidVerificationCode": "Please enter a valid verification code", + "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later.", + "invalidLoginCredentials": "Your password is incorrect, please try again" }, "workspace": { "chooseWorkspace": "Choose your workspace", @@ -151,7 +168,8 @@ "charCountLabel": "Character count: ", "createdAtLabel": "Created: ", "syncedAtLabel": "Synced: ", - "saveAsNewPage": "Add messages to page" + "saveAsNewPage": "Add messages to page", + "saveAsNewPageDisabled": "No messages available" }, "importPanel": { "textAndMarkdown": "Text & Markdown", @@ -161,6 +179,15 @@ "csv": "CSV", "database": "Database" }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "Drag & drop a file, click to ", + "placeholderUpload": "Upload", + "placeholderRight": ", or paste an image link.", + "dropToUpload": "Drop a file to upload", + "change": "Change" + } + }, "disclosureAction": { "rename": "Rename", "delete": "Delete", @@ -174,7 +201,8 @@ "changeIcon": "Change icon", "collapseAllPages": "Collapse all subpages", "movePageTo": "Move page to", - "move": "Move" + "move": "Move", + "lockPage": "Lock page" }, "blankPageTitle": "Blank page", "newPageText": "New page", @@ -204,7 +232,7 @@ "indexFileSuccess": "Indexing file successfully", "inputActionNoPages": "No page results", "referenceSource": { - "zero": "0 sources gefunden", + "zero": "0 sources found", "one": "{count} source found", "other": "{count} sources found" }, @@ -214,6 +242,7 @@ "indexingFile": "Indexing {}", "generatingResponse": "Generating response", "selectSources": "Select Sources", + "currentPage": "Current page", "sourcesLimitReached": "You can only select up to 3 top-level documents and its children", "sourceUnsupported": "We don't support chatting with databases at this time", "regenerate": "Try again", @@ -234,18 +263,25 @@ "number": "Numbered list", "table": "Table", "blankDescription": "Format response", - "defaultDescription": "Auto mode", + "defaultDescription": "Auto response format", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", "tableWithImageDescription": "@:chat.changeFormat.table with image" }, + "switchModel": { + "label": "Switch model", + "localModel": "Local Model", + "cloudModel": "Cloud Model", + "autoModel": "Auto" + }, "selectBanner": { "saveButton": "Add to …", "selectMessages": "Select messages", "nSelected": "{} selected", "allSelected": "All selected" - } + }, + "stopTooltip": "Stop generating" }, "trash": { "text": "Trash", @@ -288,7 +324,8 @@ "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", - "help": "Help & Support", + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", @@ -320,8 +357,7 @@ "header": "Header", "highlight": "Highlight", "color": "Color", - "addLink": "Add Link", - "link": "Link" + "addLink": "Add Link" }, "tooltip": { "lightMode": "Switch to Light mode", @@ -373,7 +409,6 @@ "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage", "storageLimitDialogTitleIOS": "You have run out of free storage.", "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", - "aiResponseLimitTitleIOS": "You have run out of free AI responses.", "aiResponseLimitDialogTitle": "AI Responses limit reached", "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", @@ -490,7 +525,8 @@ "settings": "Settings", "members": "Members", "trash": "Trash", - "helpAndSupport": "Help & Support" + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get Support" }, "sites": { "title": "Sites", @@ -555,7 +591,7 @@ } }, "accountPage": { - "menuLabel": "My account", + "menuLabel": "Account & App", "title": "My account", "general": { "title": "Account name & profile image", @@ -571,7 +607,9 @@ "title": "Account login", "loginLabel": "Log in", "logoutLabel": "Log out" - } + }, + "isUpToDate": "@:appName is up to date!", + "officialVersion": "Version {version} (Official build)" }, "workspacePage": { "menuLabel": "Workspace", @@ -607,7 +645,8 @@ "theme": { "title": "Theme", "description": "Select a preset theme, or upload your own custom theme.", - "uploadCustomThemeTooltip": "Upload a custom theme" + "uploadCustomThemeTooltip": "Upload a custom theme", + "failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName" }, "workspaceFont": { "title": "Workspace font", @@ -747,6 +786,7 @@ "alignLeft": "Align text left", "alignCenter": "Align text center", "alignRight": "Align text right", + "insertInlineMathEquation": "Insert inline math eqaution", "undo": "Undo", "redo": "Redo", "convertToParagraph": "Convert block to paragraph", @@ -823,7 +863,7 @@ "menuLabel": "AI Settings", "keys": { "enableAISearchTitle": "AI Search", - "aiSettingsDescription": "Choose your preferred model to power AppFlowy AI. Now includes GPT 4-o, Claude 3,5, Llama 3.1, and Mistral 7B", + "aiSettingsDescription": "Choose your preferred model to power AppFlowy AI. Now includes GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet, and models available in Ollama", "loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up", "llmModel": "Language Model", "llmModelType": "Language Model Type", @@ -834,14 +874,20 @@ "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", - "localAIStart": "Local AI Chat is starting...", + "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", - "failToLoadLocalAI": "Failed to start local AI", - "restartLocalAI": "Restart Local AI", + "localAIRunning": "Local AI is running", + "localAINotReadyRetryLater": "Local AI is initializing, please retry later", + "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", + "localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device", + "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", + "failToLoadLocalAI": "Failed to start local AI.", + "restartLocalAI": "Restart", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", - "localAIToggleTitle": "Toggle to enable or disable local AI", + "localAIToggleTitle": "AppFlowy Local AI (LAI)", + "localAIToggleSubTitle": "Run the most advanced local AI models within AppFlowy for ultimate privacy and security", "offlineAIInstruction1": "Follow the", "offlineAIInstruction2": "instruction", "offlineAIInstruction3": "to enable offline AI.", @@ -850,7 +896,14 @@ "offlineAIDownload3": "it first", "activeOfflineAI": "Active", "downloadOfflineAI": "Download", - "openModelDirectory": "Open folder" + "openModelDirectory": "Open folder", + "laiNotReady": "The Local AI app was not installed correctly.", + "ollamaNotReady": "The Ollama server is not ready.", + "pleaseFollowThese": "Please follow these", + "instructions": "instructions", + "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", + "modelsMissing": "Cannot find the required models: ", + "downloadModel": "to download them." } }, "planPage": { @@ -901,7 +954,6 @@ "description": "Unlimited AI responses powered by advanced AI models, and 50 AI images per month", "price": "{}", "priceInfo": "Per user per month billed annually" - }, "aiOnDevice": { "title": "AI On-device for Mac", @@ -993,6 +1045,7 @@ "itemFour": "Real-time collaboration", "itemFive": "Mobile app", "itemSix": "AI Responses", + "itemSeven": "AI Images", "itemFileUpload": "File uploads", "customNamespace": "Custom namespace", "tooltipSix": "Lifetime means the number of responses never reset", @@ -1007,6 +1060,7 @@ "itemFour": "yes", "itemFive": "yes", "itemSix": "10 lifetime", + "itemSeven": "2 lifetime", "itemFileUpload": "Up to 7 MB", "intelligentSearch": "Intelligent search" }, @@ -1017,6 +1071,7 @@ "itemFour": "yes", "itemFive": "yes", "itemSix": "Unlimited", + "itemSeven": "10 images per month", "itemFileUpload": "Unlimited", "intelligentSearch": "Intelligent search" }, @@ -1377,6 +1432,7 @@ "filterBy": "Filter by", "typeAValue": "Type a value...", "layout": "Layout", + "compactMode": "Compact mode", "databaseLayout": "Layout", "viewList": { "zero": "0 views", @@ -1610,8 +1666,7 @@ "url": { "launch": "Open link in browser", "copy": "Copy link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", @@ -1682,6 +1737,14 @@ "selectADocumentToLinkTo": "Select a Document to link to" }, "name": { + "textStyle": "Text Style", + "list": "List", + "toggle": "Toggle", + "fileAndMedia": "File & Media", + "simpleTable": "Simple Table", + "visuals": "Visuals", + "document": "Document", + "advanced": "Advanced", "text": "Text", "heading1": "Heading 1", "heading2": "Heading 2", @@ -1710,10 +1773,13 @@ "toggleHeading2": "Toggle heading 2", "toggleHeading3": "Toggle heading 3", "emoji": "Emoji", - "aiWriter": "AI Writer", + "aiWriter": "Ask AI Anything", "dateOrReminder": "Date or Reminder", "photoGallery": "Photo Gallery", - "file": "File" + "file": "File", + "twoColumns": "2 Columns", + "threeColumns": "3 Columns", + "fourColumns": "4 Columns" }, "subPage": { "name": "Document", @@ -1736,6 +1802,16 @@ "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", + "aiWriter": { + "userQuestion": "Ask AI anything", + "continueWriting": "Continue writing", + "fixSpelling": "Fix spelling & grammar", + "improveWriting": "Improve writing", + "summarize": "Summarize", + "explain": "Explain", + "makeShorter": "Make shorter", + "makeLonger": "Make longer" + }, "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", @@ -1754,7 +1830,7 @@ "smartEditCouldNotFetchKey": "Could not fetch AI key", "smartEditDisabled": "Connect AI in Settings", "appflowyAIEditDisabled": "Sign in to enable AI features", - "discardResponse": "Do you want to discard the AI responses?", + "discardResponse": "Are you sure you want to discard the AI response?", "createInlineMathEquation": "Create equation", "fonts": "Fonts", "insertDate": "Insert date", @@ -1950,7 +2026,28 @@ "failedDuplicateFindView": "Failed to duplicate page - original view not found" } }, - "cannotMoveToItsChildren": "Cannot move to its children" + "cannotMoveToItsChildren": "Cannot move to its children", + "linkPreview": { + "typeSelection": { + "pasteAs": "Paste as", + "mention": "Mention", + "URL": "URL", + "bookmark": "Bookmark", + "embed": "Embed" + }, + "linkPreviewMenu": { + "toMetion": "Convert to Mention", + "toUrl": "Convert to URL", + "toEmbed": "Convert to Embed", + "toBookmark": "Convert to Bookmark", + "copyLink": "Copy Link", + "replace": "Replace", + "reload": "Reload", + "removeLink": "Remove Link", + "pasteHint": "Paste in https://...", + "unableToDisplay": "unable to display" + } + } }, "outlineBlock": { "placeholder": "Table of Contents" @@ -1998,8 +2095,8 @@ "searchForAnImage": "Search for an image", "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "saveImageToGallery": "Save image", - "failedToAddImageToGallery": "Failed to add image to gallery", - "successToAddImageToGallery": "Image added to gallery successfully", + "failedToAddImageToGallery": "Failed to save image", + "successToAddImageToGallery": "Saved image to Photos", "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", @@ -2058,7 +2155,28 @@ "morePages": "more pages" }, "toolbar": { - "resetToDefaultFont": "Reset to default" + "resetToDefaultFont": "Reset to default", + "textSize": "Text size", + "textColor": "Text color", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "alignLeft": "Align left", + "alignRight": "Align right", + "alignCenter": "Align center", + "link": "Link", + "textAlign": "Text align", + "moreOptions": "More options", + "font": "Font", + "inlineCode": "Inline code", + "suggestions": "Suggestions", + "turnInto": "Turn into", + "equation": "Equation", + "insert": "Insert", + "linkInputHint": "Paste link or search pages", + "pageOrURL": "Page or URL", + "linkName": "Link Name", + "linkNameHint": "Input link name" }, "errorBlock": { "theBlockIsNotSupported": "Unable to parse the block content", @@ -2193,7 +2311,7 @@ }, "message": { "copy": { - "success": "Copied!", + "success": "Copied to clipboard", "fail": "Unable to copy" } }, @@ -2360,9 +2478,9 @@ "link": "Link", "numberedList": "Numbered list", "numberedListShortForm": "Numbered", - "toggleHeading1ShortForm": "Toggle h1", - "toggleHeading2ShortForm": "Toggle h2", - "toggleHeading3ShortForm": "Toggle h3", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", "quote": "Quote", "strikethrough": "Strikethrough", "text": "Text", @@ -2423,6 +2541,7 @@ "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", + "convertTo": "Convert to", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", @@ -2502,7 +2621,7 @@ "noLogFiles": "There're no log files", "newSettings": { "myAccount": { - "title": "My account", + "title": "Account & App", "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", @@ -2512,6 +2631,7 @@ "accountLogin": "Account Login", "updateNameError": "Failed to update name", "updateIconError": "Failed to update icon", + "aboutAppFlowy": "About @:appName", "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", @@ -2520,14 +2640,41 @@ "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", "dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", - "confirmHint1": "Please type \"DELETE MY ACCOUNT\" to confirm.", + "confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.", "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "You must check the box to confirm deletion", "failedToGetCurrentUser": "Failed to get current user email", - "confirmTextValidationFailed": "Your confirmation text does not match \"DELETE MY ACCOUNT\"", + "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Account deleted successfully" - } + }, + "password": { + "title": "Password", + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "setupPassword": "Setup password", + "error": { + "newPasswordIsRequired": "New password is required", + "confirmPasswordIsRequired": "Confirm password is required", + "passwordsDoNotMatch": "Passwords do not match", + "newPasswordIsSameAsCurrent": "New password is same as current password" + }, + "toast": { + "passwordUpdatedSuccessfully": "Password updated successfully", + "passwordUpdatedFailed": "Failed to update password", + "passwordSetupSuccessfully": "Password setup successfully", + "passwordSetupFailed": "Failed to setup password" + }, + "hint": { + "enterYourCurrentPassword": "Enter your current password", + "enterYourNewPassword": "Enter your new password", + "confirmYourNewPassword": "Confirm your new password" + } + }, + "myAccount": "My Account", + "myProfile": "My Profile" }, "workplace": { "name": "Workplace", @@ -2580,6 +2727,11 @@ "commandPalette": { "placeholder": "Search or ask a question...", "bestMatches": "Best matches", + "aiOverview": "AI overview", + "aiOverviewSource": "Reference sources", + "aiOverviewMoreDetails": "More details", + "pagePreview": "Content preview", + "clickToOpenPage": "Click to open page", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", @@ -2701,7 +2853,8 @@ "continueWithApple": "Continue with Apple ", "moreOptions": "More options", "collapse": "Collapse", - "signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's", + "signInAgreement": "By clicking \"Continue\" above, you agreed to \nAppFlowy's ", + "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to \nAppFlowy's ", "and": "and", "termOfUse": "Terms", "privacyPolicy": "Privacy Policy", @@ -3081,6 +3234,50 @@ } }, "ai": { - "contentPolicyViolation": "Image generation failed due to sensitive content. Please rephrase your input and try again" + "contentPolicyViolation": "Image generation failed due to sensitive content. Please rephrase your input and try again", + "textLimitReachedDescription": "Your workspace has run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "imageLimitReachedDescription": "You've used up your free AI image quota. Please upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "limitReachedAction": { + "textDescription": "Your workspace has run out of free AI responses. To get more responses, please", + "imageDescription": "You've used up your free AI image quota. Please", + "upgrade": "upgrade", + "toThe": "to the", + "proPlan": "Pro Plan", + "orPurchaseAn": "or purchase an", + "aiAddon": "AI add-on" + }, + "editing": "Editing", + "analyzing": "Analyzing", + "continueWritingEmptyDocumentTitle": "Continue writing error", + "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!", + "more": "More" + }, + "autoUpdate": { + "criticalUpdateTitle": "Update required to continue", + "criticalUpdateDescription": "We've made improvements to enhance your experience! Please update from {currentVersion} to {newVersion} to keep using the app.", + "criticalUpdateButton": "Update", + "bannerUpdateTitle": "New Version Available!", + "bannerUpdateDescription": "Get the latest features and fixes. Click \"Update\" to install now", + "bannerUpdateButton": "Update", + "settingsUpdateTitle": "New Version ({newVersion}) Available!", + "settingsUpdateDescription": "Current version: {currentVersion} (Official build) → {newVersion}", + "settingsUpdateButton": "Update", + "settingsUpdateWhatsNew": "What's new" + }, + "lockPage": { + "lockPage": "Locked", + "reLockPage": "Re-lock", + "lockTooltip": "Page locked to prevent accidental editing. Click to unlock.", + "pageLockedToast": "Page locked. Editing is disabled until someone unlocks it.", + "lockedOperationTooltip": "Page locked to prevent accidental editing." + }, + "suggestion": { + "accept": "Accept", + "keep": "Keep", + "discard": "Discard", + "close": "Close", + "tryAgain": "Try again", + "rewrite": "Rewrite", + "insertBelow": "Insert below" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index 9ecbff9c1d..5f947ea015 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -36,6 +36,7 @@ "loginButtonText": "Ingresar", "loginStartWithAnonymous": "Comience una sesión anónima", "continueAnonymousUser": "Continuar con una sesión anónima", + "anonymous": "Anónimo", "buttonText": "Ingresar", "signingInText": "Iniciando sesión...", "forgotPassword": "¿Olvidó su contraseña?", @@ -50,6 +51,8 @@ "signInWithGoogle": "Iniciar sesión con Google", "signInWithGithub": "Iniciar sesión con Github", "signInWithDiscord": "Iniciar sesión con Discord", + "signInWithApple": "Continuar con Apple", + "continueAnotherWay": "Continuar por otro camino", "signUpWithGoogle": "Registrarse con Google", "signUpWithGithub": "Registrarse con Github", "signUpWithDiscord": "Registrarse con Discord", @@ -68,8 +71,12 @@ }, "workspace": { "chooseWorkspace": "Elige tu espacio de trabajo", + "defaultName": "Mi espacio de trabajo", "create": "Crear espacio de trabajo", + "new": "Nuevo espacio de trabajo", + "learnMore": "Más información", "reset": "Restablecer espacio de trabajo", + "renameWorkspace": "Cambiar el nombre del espacio de trabajo", "resetWorkspacePrompt": "Al restablecer el espacio de trabajo se eliminarán todas las páginas y datos que contiene. ¿Está seguro de que desea restablecer el espacio de trabajo? Alternativamente, puede comunicarse con el equipo de soporte para restaurar el espacio de trabajo.", "hint": "Espacio de trabajo", "notFoundError": "Espacio de trabajo no encontrado", @@ -105,7 +112,9 @@ "html": "HTML", "clipboard": "Copiar al portapapeles", "csv": "CSV", - "copyLink": "Copiar enlace" + "copyLink": "Copiar enlace", + "publish": "Publicar", + "publishTab": "Publicar" }, "moreAction": { "small": "pequeño", @@ -118,7 +127,9 @@ "charCount": "Número de caracteres : {}", "createdAt": "Creado: {}", "deleteView": "Borrar", - "duplicateView": "Duplicar" + "duplicateView": "Duplicar", + "createdAtLabel": "Creado: ", + "syncedAtLabel": "Sincronizado: " }, "importPanel": { "textAndMarkdown": "Texto y Markdown", @@ -136,7 +147,8 @@ "openNewTab": "Abrir en una pestaña nueva", "moveTo": "Mover a", "addToFavorites": "Añadira los favoritos", - "copyLink": "Copiar Enlace" + "copyLink": "Copiar Enlace", + "move": "Mover" }, "blankPageTitle": "Página en blanco", "newPageText": "Nueva página", @@ -149,16 +161,31 @@ "relatedQuestion": "Relacionado", "serverUnavailable": "Servicio temporalmente no disponible. Por favor, inténtelo de nuevo más tarde.", "aiServerUnavailable": "🌈 ¡Uh-oh! 🌈. Un unicornio se comió nuestra respuesta. ¡Por favor, intenta de nuevo!", + "retry": "Rever", "clickToRetry": "Haga clic para volver a intentarlo", "regenerateAnswer": "Regenerar", "question1": "Cómo utilizar Kanban para gestionar tareas", "question2": "Explica el método GTD", "question3": "¿Por qué usar Rust?", - "aiMistakePrompt": "La IA puede cometer errores. Consulta información importante." + "aiMistakePrompt": "La IA puede cometer errores. Consulta información importante.", + "referenceSource": { + "one": "Se encontró {count} fuente", + "other": "Se encontraron {count} fuentes" + }, + "regenerate": "Intentar otra vez", + "addToNewPage": "Crear nueva página", + "changeFormat": { + "textOnly": "Texto", + "text": "Párrafo" + }, + "selectBanner": { + "saveButton": "Añadir …" + } }, "trash": { "text": "Papelera", "restoreAll": "Recuperar todo", + "restore": "Restaurar", "deleteAll": "Eliminar todo", "pageHeader": { "fileName": "Nombre de archivo", @@ -191,20 +218,21 @@ "questionBubble": { "shortcuts": "Atajos", "whatsNew": "¿Qué hay de nuevo?", - "help": "Ayuda y Soporte", "markdown": "Reducción", "debug": { "name": "Información de depuración", "success": "¡Información copiada!", "fail": "No fue posible copiar la información" }, - "feedback": "Comentario" + "feedback": "Comentario", + "help": "Ayuda y Soporte" }, "menuAppHeader": { "moreButtonToolTip": "Eliminar, renombrar y más...", "addPageTooltip": "Inserta una página", "defaultNewPageName": "Sin Título", - "renameDialog": "Renombrar" + "renameDialog": "Renombrar", + "pageNameSuffix": "Copiar" }, "noPagesInside": "No hay páginas dentro", "toolbar": { @@ -260,7 +288,10 @@ "emptyRecent": "Sin documentos recientes", "favoriteSpace": "Favoritos", "RecentSpace": "Reciente", - "Spaces": "Espacios" + "Spaces": "Espacios", + "aiImageResponseLimit": "Se ha quedado sin respuestas de imágenes de IA.\n\nVaya a Configuración -> Plan -> Haga clic en AI Max para obtener más respuestas de imágenes de IA", + "purchaseStorageSpace": "Comprar espacio de almacenamiento", + "purchaseAIResponse": "Compra " }, "notifications": { "export": { @@ -294,6 +325,7 @@ "upload": "Subir", "edit": "Editar", "delete": "Borrar", + "copy": "Copiar", "duplicate": "Duplicar", "putback": "Volver", "update": "Actualizar", @@ -305,6 +337,7 @@ "helpCenter": "Centro de ayuda", "add": "Añadir", "yes": "Si", + "no": "No", "clear": "Limpiar", "remove": "Eliminar", "dontRemove": "no quitar", @@ -319,7 +352,12 @@ "signInDiscord": "Iniciar sesión con discordia", "more": "Más", "create": "Crear", - "close": "Cerca" + "close": "Cerca", + "next": "Próximo", + "previous": "Anterior", + "submit": "Entregar", + "download": "Descargar", + "backToHome": "Volver a Inicio" }, "label": { "welcome": "¡Bienvenido!", @@ -343,6 +381,9 @@ }, "settings": { "title": "Ajustes", + "popupMenuItem": { + "settings": "Ajustes" + }, "accountPage": { "menuLabel": "Mi cuenta", "title": "Mi cuenta", @@ -825,8 +866,7 @@ "url": { "launch": "Abrir en el navegador", "copy": "Copiar URL", - "textFieldHint": "Introduce una URL", - "copiedNotification": "¡Copiado al portapapeles!" + "textFieldHint": "Introduce una URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de datos relacionada", diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index be987e7a53..2e52231f7c 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -99,14 +99,14 @@ "questionBubble": { "shortcuts": "Lasterbideak", "whatsNew": "Ze berri?", - "help": "Laguntza", "markdown": "Markdown", "debug": { "name": "Debug informazioa", "success": "Debug informazioa kopiatu da!", "fail": "Ezin izan da debug informazioa kopiatu" }, - "feedback": "Iritzia" + "feedback": "Iritzia", + "help": "Laguntza" }, "menuAppHeader": { "addPageTooltip": "Gehitu orri bat", diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index 21c8505c88..cc93c17d64 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "من", "welcomeText": "به @:appName خوش آمدید", + "welcomeTo": "خوش آمدید به", "githubStarText": "به گیت‌هاب ما ستاره دهید", "subscribeNewsletterText": "اشتراک در خبرنامه", "letsGoButtonText": "شروع کنید", "title": "عنوان", "youCanAlso": "همچنین می‌توانید", "and": "و", + "failedToOpenUrl": "خطا در بازکردن نشانی وب: {}", "blockActions": { "addBelowTooltip": "برای افزودن در زیر کلیک کنید", "addAboveCmd": "Alt+click", @@ -32,19 +34,47 @@ "signIn": { "loginTitle": "ورود به @:appName", "loginButtonText": "ورود", + "loginStartWithAnonymous": "ادامه دادن با یک جلسه ناشناس", "continueAnonymousUser": "ادامه دادن به صورت کاربر مهمان", + "anonymous": "ناشناس", "buttonText": "ورود", + "signingInText": "در حال ورود...", "forgotPassword": "رمز عبور را فراموش کرده اید؟", "emailHint": "ایمیل", "passwordHint": "رمز عبور", "dontHaveAnAccount": "آیا حساب کاربری ندارید؟", + "createAccount": "ساخت حساب کاربری", "repeatPasswordEmptyError": "تکرار رمز عبور نمی‌تواند خالی باشد", "unmatchedPasswordError": "تکرار رمز عبور مشابه رمز عبور نیست", + "syncPromptMessage": "همگام سازی داده ها ممکن است کمی طول بکشد. لطفا این صفحه را نبندید", + "or": "یا", + "signInWithGoogle": "ادامه دادن با گوگل", + "signInWithGithub": "ادامه دادن با گیتهاب", + "signInWithDiscord": "ادامه دادن با دیسکورد", + "signInWithApple": "ادامه دادن با اپل", + "continueAnotherWay": "ادامه دادن از طریق دیگر", + "signUpWithGoogle": "ثبت نام با گوگل", + "signUpWithGithub": "ثبت نام با گیتهاب", + "signUpWithDiscord": "ثبت نام با دیسکورد", "signInWith": "ثبت نام با:", + "signInWithEmail": "ادامه دادن با ایمیل", + "signInWithMagicLink": "ادامه", + "pleaseInputYourEmail": "لطفا آدرس ایمیل خود را وارد کنید", + "settings": "تنظیمات", + "invalidEmail": "لطفا یک آدرس ایمیل معتبر وارد کنید", + "alreadyHaveAnAccount": "حساب کاربری دارید؟", + "logIn": "ورود", + "generalError": "مشکلی پیش آمد. لطفاً بعداً دوباره امتحان کنید", "loginAsGuestButtonText": "شروع کنید" }, "workspace": { + "chooseWorkspace": "فضای کار خود را انتخاب کنید", + "defaultName": "فضای کار من", "create": "ایجاد فضای کار", + "new": "فضای کار جدید", + "learnMore": "بیشتر بدانید", + "renameWorkspace": "حذف فضای کار", + "workspaceNameCannotBeEmpty": "اسم فضای کار نمی‌تواند خالی باشد", "hint": "فضای کار", "notFoundError": "فضای کاری پیدا نشد" }, @@ -109,14 +139,14 @@ "questionBubble": { "shortcuts": "میانبرها", "whatsNew": "تازه‌ترین‌ها", - "help": "پشتیبانی و مستندات", "markdown": "Markdown", "debug": { "name": "اطلاعات اشکال‌زدایی", "success": "طلاعات اشکال زدایی در کلیپ بورد کپی شد!", "fail": "نمی توان اطلاعات اشکال زدایی را در کلیپ بورد کپی کرد" }, - "feedback": "بازخورد" + "feedback": "بازخورد", + "help": "پشتیبانی و مستندات" }, "menuAppHeader": { "moreButtonToolTip": "حذف، تغییر نام، و موارد دیگر...", diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index 7f8cdbd6a3..589d2dfe18 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -196,14 +196,14 @@ "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support Technique", "markdown": "Réduction", "debug": { "name": "Infos du système", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support Technique" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 326d2d044f..989e21f349 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -269,14 +269,14 @@ "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support", "markdown": "Rédaction", "debug": { "name": "Informations de Débogage", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", @@ -354,7 +354,6 @@ "storageLimitDialogTitle": "Vous n'avez plus d'espace de stockage gratuit. Effectuez une mise à niveau pour débloquer un espace de stockage illimité", "storageLimitDialogTitleIOS": "Vous n'avez plus d'espace de stockage gratuit.", "aiResponseLimitTitle": "Vous n'avez plus de réponses d'IA gratuites. Passez au plan Pro ou achetez un module complémentaire d'IA pour débloquer des réponses illimitées", - "aiResponseLimitTitleIOS": "Vous n'avez plus de réponses IA gratuites.", "aiResponseLimitDialogTitle": "La limite des réponses de l'IA a été atteinte", "aiResponseLimit": "Vous n'avez plus de réponses IA gratuites.\n\nAccédez à Paramètres -> Plans -> Cliquez sur AI Max ou Pro Plan pour obtenir plus de réponses AI", "askOwnerToUpgradeToPro": "Votre espace de stockage gratuit est presque plein. Demandez au propriétaire de votre espace de travail de passer au plan Pro", @@ -1613,8 +1612,7 @@ "url": { "launch": "Ouvrir dans le navigateur", "copy": "Copier l'URL", - "textFieldHint": "Entrez une URL", - "copiedNotification": "Copié dans le presse-papier!" + "textFieldHint": "Entrez une URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de données associée", @@ -2520,12 +2518,12 @@ "dialogTitle": "Supprimer le compte", "dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?", "dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.", - "confirmHint1": "Veuillez taper « SUPPRIMER MON COMPTE » pour confirmer.", + "confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.", "confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.", "confirmHint3": "SUPPRIMER MON COMPTE", "checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression", "failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel", - "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « SUPPRIMER MON COMPTE »", + "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »", "deleteAccountSuccess": "Compte supprimé avec succès" } }, diff --git a/frontend/resources/translations/ga-IE.json b/frontend/resources/translations/ga-IE.json new file mode 100644 index 0000000000..1520e46fea --- /dev/null +++ b/frontend/resources/translations/ga-IE.json @@ -0,0 +1,119 @@ +{ + "appName": "Appflowy", + "defaultUsername": "Liom", + "welcomeText": "Fáilte go @:appName", + "welcomeTo": "Fáilte chuig", + "githubStarText": "Réalta ar GitHub", + "subscribeNewsletterText": "Liostáil le Nuachtlitir", + "letsGoButtonText": "Tús Tapa", + "title": "Teideal", + "youCanAlso": "Is féidir leat freisin", + "and": "agus", + "failedToOpenUrl": "Theip ar oscailt an url: {}", + "blockActions": { + "addBelowTooltip": "Cliceáil chun cur leis thíos", + "addAboveCmd": "Alt+cliceáil", + "addAboveMacCmd": "Option+cliceáil", + "addAboveTooltip": "a chur thuas", + "dragTooltip": "Tarraing chun bogadh", + "openMenuTooltip": "Cliceáil chun an roghchlár a oscailt" + }, + "signUp": { + "buttonText": "Cláraigh", + "title": "Cláraigh le @:appName", + "getStartedText": "Faigh Tosaigh", + "emptyPasswordError": "Ní féidir le pasfhocal a bheith folamh", + "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", + "unmatchedPasswordError": "Ní ionann pasfhocal athdhéanta agus pasfhocal", + "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", + "emailHint": "Ríomhphost", + "passwordHint": "Pasfhocal", + "repeatPasswordHint": "Déan pasfhocal arís", + "signUpWith": "Cláraigh le:" + }, + "signIn": { + "loginTitle": "Logáil isteach ar @:appName", + "loginButtonText": "Logáil isteach", + "loginStartWithAnonymous": "Lean ar aghaidh le seisiún gan ainm", + "continueAnonymousUser": "Lean ar aghaidh le seisiún gan ainm", + "anonymous": "Gan ainm", + "buttonText": "Sínigh Isteach", + "signingInText": "Ag síniú isteach...", + "forgotPassword": "Pasfhocal Dearmadta?", + "emailHint": "Ríomhphost", + "passwordHint": "Pasfhocal", + "dontHaveAnAccount": "Nach bhfuil cuntas agat?", + "createAccount": "Cruthaigh cuntas", + "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", + "unmatchedPasswordError": "Ní hionann pasfhocal athdhéanta agus pasfhocal", + "syncPromptMessage": "Seans go dtógfaidh sé tamall na sonraí a shioncronú. Ná dún an leathanach seo, le do thoil", + "or": "NÓ", + "signInWithGoogle": "Lean ar aghaidh le Google", + "signInWithGithub": "Lean ar aghaidh le GitHub", + "signInWithDiscord": "Lean ar aghaidh le Discord", + "signInWithApple": "Lean ar aghaidh le Apple", + "continueAnotherWay": "Lean ar aghaidh ar bhealach eile", + "signUpWithGoogle": "Cláraigh le Google", + "signUpWithGithub": "Cláraigh le Github", + "signUpWithDiscord": "Cláraigh le Discord", + "signInWith": "Lean ar aghaidh le:", + "signInWithEmail": "Lean ar aghaidh le Ríomhphost", + "signInWithMagicLink": "Lean ort", + "signUpWithMagicLink": "Cláraigh le Magic Link", + "pleaseInputYourEmail": "Cuir isteach do sheoladh ríomhphoist", + "settings": "Socruithe", + "magicLinkSent": "Magic Link seolta!", + "invalidEmail": "Cuir isteach seoladh ríomhphoist bailí", + "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", + "logIn": "Logáil isteach", + "generalError": "Chuaigh rud éigin mícheart. Bain triail eile as ar ball", + "limitRateError": "Ar chúiseanna slándála, ní féidir leat nasc draíochta a iarraidh ach gach 60 soicind" + }, + "workspace": { + "chooseWorkspace": "Roghnaigh do spás oibre", + "defaultName": "Mo Spás Oibre", + "create": "Cruthaigh spás oibre", + "new": "Spás oibre nua", + "importFromNotion": "Iompórtáil ó Notion", + "learnMore": "Foghlaim níos mó", + "reset": "Athshocraigh spás oibre", + "renameWorkspace": "Athainmnigh spás oibre", + "workspaceNameCannotBeEmpty": "Ní féidir leis an ainm spás oibre a bheith folamh", + "hint": "spás oibre", + "notFoundError": "Spás oibre gan aimsiú", + "errorActions": { + "reportIssue": "Tuairiscigh saincheist", + "reportIssueOnGithub": "Tuairiscigh ceist faoi Github", + "exportLogFiles": "Easpórtáil comhaid logáil", + "reachOut": "Bhaint amach le Discord" + }, + "menuTitle": "Spásanna oibre", + "createSuccess": "Cruthaíodh spás oibre go rathúil", + "leaveCurrentWorkspace": "Fág spás óibre" + }, + "shareAction": { + "buttonText": "Comhroinn", + "workInProgress": "Ag teacht go luath", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "Cóipeáil chuig an ngearrthaisce", + "csv": "CSV", + "copyLink": "Cóipeáil nasc", + "publishToTheWeb": "Foilsigh don Ghréasán", + "publishToTheWebHint": "Cruthaigh suíomh Gréasáin le AppFlowy", + "publish": "Foilsigh", + "unPublish": "Dífhoilsiú", + "visitSite": "Tabhair cuairt ar an suíomh", + "publishTab": "Foilsigh", + "shareTab": "Comhroinn" + }, + "moreAction": { + "small": "beag", + "medium": "meánach", + "large": "mór", + "fontSize": "Clómhéid", + "import": "Iompórtáil", + "createdAt": "Cruthaithe: {}", + "deleteView": "Scrios" + } +} diff --git a/frontend/resources/translations/he.json b/frontend/resources/translations/he.json index d47c33c6da..6c40f88947 100644 --- a/frontend/resources/translations/he.json +++ b/frontend/resources/translations/he.json @@ -206,14 +206,14 @@ "questionBubble": { "shortcuts": "מקשי קיצור", "whatsNew": "מה חדש?", - "help": "עזרה ותמיכה", "markdown": "Markdown", "debug": { "name": "פרטי ניפוי שגיאות", "success": "פרטי ניפוי השגיאות הועתקו ללוח הגזירים!", "fail": "לא ניתן להעתיק את פרטי ניפוי השגיאות ללוח הגזירים" }, - "feedback": "משוב" + "feedback": "משוב", + "help": "עזרה ותמיכה" }, "menuAppHeader": { "moreButtonToolTip": "הסרה, שינוי שם ועוד…", @@ -1243,8 +1243,7 @@ "url": { "launch": "פתיחת קישור בדפדפן", "copy": "העתקת קישור ללוח הגזירים", - "textFieldHint": "נא למלא כתובת", - "copiedNotification": "הועתק ללוח הגזירים!" + "textFieldHint": "נא למלא כתובת" }, "relation": { "relatedDatabasePlaceLabel": "מסד נתונים קשור", diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 40f05cccc6..1c10e40da4 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -103,14 +103,14 @@ "questionBubble": { "shortcuts": "Parancsikonok", "whatsNew": "Újdonságok", - "help": "Segítség & Támogatás", "markdown": "Markdown", "debug": { "name": "Debug Információ", "success": "Debug információ a vágólapra másolva", "fail": "A Debug információ nem másolható a vágólapra" }, - "feedback": "Visszacsatolás" + "feedback": "Visszacsatolás", + "help": "Segítség & Támogatás" }, "menuAppHeader": { "addPageTooltip": "Belső oldal hozzáadása", diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index ede3571878..b900929966 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -160,14 +160,14 @@ "questionBubble": { "shortcuts": "Pintasan", "whatsNew": "Apa yang baru?", - "help": "Bantuan & Dukungan", "markdown": "Penurunan harga", "debug": { "name": "Info debug", "success": "Info debug disalin ke papan klip!", "fail": "Tidak dapat menyalin info debug ke papan klip" }, - "feedback": "Masukan" + "feedback": "Masukan", + "help": "Bantuan & Dukungan" }, "menuAppHeader": { "moreButtonToolTip": "Menghapus, merubah nama, dan banyak lagi...", @@ -206,13 +206,13 @@ "addBlockBelow": "Tambahkan blok di bawah ini" }, "sideBar": { - "closeSidebar": "Close sidebar", - "openSidebar": "Open sidebar", + "closeSidebar": "Tutup sidebar", + "openSidebar": "Buka sidebar", "personal": "Pribadi", "favorites": "Favorit", - "clickToHidePersonal": "Klik untuk menutup seksi pribadi", - "clickToHideFavorites": "Klik untuk menutup seksi favorit", - "addAPage": "Tambah sebuah page" + "clickToHidePersonal": "Klik untuk menutup Pribadi", + "clickToHideFavorites": "Klik untuk menutup Favorit", + "addAPage": "Tambah halaman baru" }, "notifications": { "export": { @@ -305,28 +305,28 @@ "appearance": { "resetSetting": "Mengatur ulang pengaturan ini", "fontFamily": { - "label": "Keluarga Fon", - "search": "Mencari" + "label": "Jenis Font", + "search": "Cari" }, "themeMode": { - "label": "Theme Mode", - "light": "Mode Terang", - "dark": "Mode Gelap", - "system": "Adapt to System" + "label": "Tema", + "light": "Terang", + "dark": "Gelap", + "system": "Sesuai Sistem" }, "layoutDirection": { - "label": "Arah Layout", - "hint": "Mengontrol aliran konten pada layar Anda, dari kiri ke kanan atau kanan ke kiri.", - "ltr": "LTR", - "rtl": "RTL" + "label": "Arah Tampilan", + "hint": "Mengatur arah tampilan konten, apakah dari kiri ke kanan atau kanan ke kiri.", + "ltr": "Kiri ke Kanan", + "rtl": "Kanan ke Kiri" }, "textDirection": { - "label": "Arah teks default", - "hint": "Menentukan apakah teks harus dimulai dari kiri atau kanan sebagai default.", - "ltr": "LTR", - "rtl": "RTL", - "auto": "AUTO", - "fallback": "Sama seperti arah layout" + "label": "Arah Teks Bawaan", + "hint": "Atur arah teks bawaan, apakah dari kiri ke kanan atau kanan ke kiri.", + "ltr": "Kiri ke Kanan", + "rtl": "Kanan ke Kiri", + "auto": "Otomatis", + "fallback": "Sesuai Arah Tampilan" }, "themeUpload": { "button": "Mengunggah", diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index d6877ecd59..7fb463da20 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -221,14 +221,14 @@ "questionBubble": { "shortcuts": "Scorciatoie", "whatsNew": "Cosa c'è di nuovo?", - "help": "Aiuto & Supporto", "markdown": "Markdown", "debug": { "name": "Informazioni di debug", "success": "Informazioni di debug copiate negli appunti!", "fail": "Impossibile copiare le informazioni di debug negli appunti" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Aiuto & Supporto" }, "menuAppHeader": { "moreButtonToolTip": "Rimuovi, rinomina e altro...", diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index cf33c91e88..ebe679ad84 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -263,14 +263,14 @@ "questionBubble": { "shortcuts": "ショートカット", "whatsNew": "新着情報", - "help": "ヘルプ & サポート", "markdown": "Markdown", "debug": { "name": "デバッグ情報", "success": "デバッグ情報をクリップボードにコピーしました!", "fail": "デバッグ情報をクリップボードにコピーできませんでした" }, - "feedback": "フィードバック" + "feedback": "フィードバック", + "help": "ヘルプ & サポート" }, "menuAppHeader": { "moreButtonToolTip": "削除、名前の変更、その他...", @@ -346,9 +346,7 @@ "upgradeToPro": "Proプランにアップグレード", "upgradeToAIMax": "無制限のAIを解放", "storageLimitDialogTitle": "無料のストレージが不足しています。無制限のストレージを解放するにはアップグレードしてください", - "storageLimitDialogTitleIOS": "無料のストレージが不足しています。", "aiResponseLimitTitle": "無料のAIレスポンスが不足しています。Proプランにアップグレードするか、AIアドオンを購入して無制限のレスポンスを解放してください", - "aiResponseLimitTitleIOS": "無料のAIレスポンスが不足しています。", "aiResponseLimitDialogTitle": "AIレスポンスの制限に達しました", "aiResponseLimit": "無料のAIレスポンスが不足しています。\n\n設定 -> プラン -> AI MaxまたはProプランをクリックして、さらにAIレスポンスを取得してください", "askOwnerToUpgradeToPro": "ワークスペースの無料ストレージが不足しています。ワークスペースのオーナーにProプランへのアップグレードを依頼してください", @@ -1579,8 +1577,7 @@ "url": { "launch": "リンクをブラウザで開く", "copy": "リンクをクリップボードにコピー", - "textFieldHint": "URLを入力", - "copiedNotification": "クリップボードにコピーされました!" + "textFieldHint": "URLを入力" }, "relation": { "relatedDatabasePlaceLabel": "関連データベース", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index ef6c1cc67e..1246b65f30 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -1,62 +1,117 @@ { "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "@:appName 에 오신것을 환영합니다", - "githubStarText": "Star on GitHub", + "defaultUsername": "나", + "welcomeText": "@:appName에 오신 것을 환영합니다", + "welcomeTo": "환영합니다", + "githubStarText": "GitHub에서 별표", "subscribeNewsletterText": "뉴스레터 구독", - "letsGoButtonText": "Let's Go", + "letsGoButtonText": "빠른 시작", "title": "제목", - "youCanAlso": "당신은 또한 수", + "youCanAlso": "또한 할 수 있습니다", "and": "그리고", + "failedToOpenUrl": "URL을 열지 못했습니다: {}", "blockActions": { "addBelowTooltip": "아래에 추가하려면 클릭", "addAboveCmd": "Alt+클릭", "addAboveMacCmd": "Option+클릭", - "addAboveTooltip": "위에 추가" + "addAboveTooltip": "위에 추가하려면", + "dragTooltip": "이동하려면 드래그", + "openMenuTooltip": "메뉴를 열려면 클릭" }, "signUp": { - "buttonText": "회원가입", - "title": "@:appName 에 회원가입", + "buttonText": "가입하기", + "title": "@:appName에 가입하기", "getStartedText": "시작하기", - "emptyPasswordError": "비밀번호는 공백일 수 없습니다", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다", + "emptyPasswordError": "비밀번호는 비워둘 수 없습니다", + "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", + "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", "alreadyHaveAnAccount": "이미 계정이 있으신가요?", "emailHint": "이메일", "passwordHint": "비밀번호", - "repeatPasswordHint": "비밀번호 재입력" + "repeatPasswordHint": "비밀번호 확인", + "signUpWith": "다음으로 가입:" }, "signIn": { - "loginTitle": "@:appName 에 로그인", + "loginTitle": "@:appName에 로그인", "loginButtonText": "로그인", + "loginStartWithAnonymous": "익명 세션으로 계속", + "continueAnonymousUser": "익명 세션으로 계속", + "anonymous": "익명", "buttonText": "로그인", + "signingInText": "로그인 중...", "forgotPassword": "비밀번호를 잊으셨나요?", "emailHint": "이메일", "passwordHint": "비밀번호", "dontHaveAnAccount": "계정이 없으신가요?", - "createAccount": "계정 생성", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다", + "createAccount": "계정 만들기", + "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", + "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", + "syncPromptMessage": "데이터 동기화에는 시간이 걸릴 수 있습니다. 이 페이지를 닫지 마세요", "or": "또는", + "signInWithGoogle": "Google로 계속", + "signInWithGithub": "GitHub로 계속", + "signInWithDiscord": "Discord로 계속", + "signInWithApple": "Apple로 계속", + "continueAnotherWay": "다른 방법으로 계속", + "signUpWithGoogle": "Google로 가입", + "signUpWithGithub": "GitHub로 가입", + "signUpWithDiscord": "Discord로 가입", + "signInWith": "다음으로 계속:", + "signInWithEmail": "이메일로 계속", "signInWithMagicLink": "계속", + "signUpWithMagicLink": "Magic Link로 가입", "pleaseInputYourEmail": "이메일 주소를 입력하세요", "settings": "설정", + "magicLinkSent": "Magic Link가 전송되었습니다!", "invalidEmail": "유효한 이메일 주소를 입력하세요", - "alreadyHaveAnAccount": "이미 계정이 있나요?", + "alreadyHaveAnAccount": "이미 계정이 있으신가요?", "logIn": "로그인", - "generalError": "오류가 발생했습니다. 나중에 다시 시도하세요", - "loginAsGuestButtonText": "시작하다" + "generalError": "문제가 발생했습니다. 나중에 다시 시도하세요", + "limitRateError": "보안상의 이유로, 매 60초마다 한 번씩만 Magic Link를 요청할 수 있습니다", + "magicLinkSentDescription": "Magic Link가 이메일로 전송되었습니다. 링크를 클릭하여 로그인을 완료하세요. 링크는 5분 후에 만료됩니다." }, "workspace": { - "defaultName": "내 워크스페이스", - "create": "워크스페이스 생성", - "hint": "워크스페이스", - "notFoundError": "워크스페이스를 찾을 수 없습니다" + "chooseWorkspace": "작업 공간 선택", + "defaultName": "내 작업 공간", + "create": "작업 공간 생성", + "new": "새 작업 공간", + "importFromNotion": "Notion에서 가져오기", + "learnMore": "자세히 알아보기", + "reset": "작업 공간 재설정", + "renameWorkspace": "작업 공간 이름 변경", + "workspaceNameCannotBeEmpty": "작업 공간 이름은 비워둘 수 없습니다", + "resetWorkspacePrompt": "작업 공간을 재설정하면 모든 페이지와 데이터가 삭제됩니다. 작업 공간을 재설정하시겠습니까? 또는 지원 팀에 문의하여 작업 공간을 복원할 수 있습니다", + "hint": "작업 공간", + "notFoundError": "작업 공간을 찾을 수 없습니다", + "failedToLoad": "문제가 발생했습니다! 작업 공간을 로드하지 못했습니다. @:appName의 모든 열린 인스턴스를 닫고 다시 시도하세요.", + "errorActions": { + "reportIssue": "문제 보고", + "reportIssueOnGithub": "GitHub에서 문제 보고", + "exportLogFiles": "로그 파일 내보내기", + "reachOut": "Discord에서 문의" + }, + "menuTitle": "작업 공간", + "deleteWorkspaceHintText": "작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다.", + "createSuccess": "작업 공간이 성공적으로 생성되었습니다", + "createFailed": "작업 공간 생성 실패", + "createLimitExceeded": "계정에 허용된 최대 작업 공간 수에 도달했습니다. 추가 작업 공간이 필요하면 GitHub에 요청하세요", + "deleteSuccess": "작업 공간이 성공적으로 삭제되었습니다", + "deleteFailed": "작업 공간 삭제 실패", + "openSuccess": "작업 공간이 성공적으로 열렸습니다", + "openFailed": "작업 공간 열기 실패", + "renameSuccess": "작업 공간 이름이 성공적으로 변경되었습니다", + "renameFailed": "작업 공간 이름 변경 실패", + "updateIconSuccess": "작업 공간 아이콘이 성공적으로 업데이트되었습니다", + "updateIconFailed": "작업 공간 아이콘 업데이트 실패", + "cannotDeleteTheOnlyWorkspace": "유일한 작업 공간을 삭제할 수 없습니다", + "fetchWorkspacesFailed": "작업 공간을 가져오지 못했습니다", + "leaveCurrentWorkspace": "작업 공간 나가기", + "leaveCurrentWorkspacePrompt": "현재 작업 공간을 나가시겠습니까?" }, "shareAction": { "buttonText": "공유", - "workInProgress": "Coming soon", - "markdown": "마크다운", + "workInProgress": "곧 출시 예정", + "markdown": "Markdown", "html": "HTML", "clipboard": "클립보드에 복사", "csv": "CSV", @@ -66,352 +121,1405 @@ "publish": "게시", "unPublish": "게시 취소", "visitSite": "사이트 방문", - "exportAsTab": "내보내기", + "exportAsTab": "다음으로 내보내기", "publishTab": "게시", "shareTab": "공유", "publishOnAppFlowy": "AppFlowy에 게시", "shareTabTitle": "협업 초대", "shareTabDescription": "누구와도 쉽게 협업할 수 있습니다", - "copyLinkSuccess": "클립보드에 링크 복사", + "copyLinkSuccess": "링크가 클립보드에 복사되었습니다", "copyShareLink": "공유 링크 복사", - "copyLinkFailed": "클립보드에 링크를 복사하는 데 실패했습니다", - "copyLinkToBlockSuccess": "블록 링크를 클립보드에 복사했습니다", - "copyLinkToBlockFailed": "블록 링크를 클립보드에 복사하는 데 실패했습니다", + "copyLinkFailed": "링크를 클립보드에 복사하지 못했습니다", + "copyLinkToBlockSuccess": "블록 링크가 클립보드에 복사되었습니다", + "copyLinkToBlockFailed": "블록 링크를 클립보드에 복사하지 못했습니다", "manageAllSites": "모든 사이트 관리", "updatePathName": "경로 이름 업데이트" }, "moreAction": { - "small": "작은", + "small": "작게", "medium": "중간", - "large": "크기가 큰", + "large": "크게", "fontSize": "글꼴 크기", "import": "가져오기", - "moreOptions": "추가 옵션" + "moreOptions": "더 많은 옵션", + "wordCount": "단어 수: {}", + "charCount": "문자 수: {}", + "createdAt": "생성일: {}", + "deleteView": "삭제", + "duplicateView": "복제", + "wordCountLabel": "단어 수: ", + "charCountLabel": "문자 수: ", + "createdAtLabel": "생성일: ", + "syncedAtLabel": "동기화됨: ", + "saveAsNewPage": "페이지에 메시지 추가", + "saveAsNewPageDisabled": "사용 가능한 메시지가 없습니다" }, "importPanel": { - "textAndMarkdown": "텍스트 및 마크다운", - "documentFromV010": "v0.1.0의 문서", - "databaseFromV010": "v0.1.0의 데이터베이스", + "textAndMarkdown": "텍스트 & Markdown", + "documentFromV010": "v0.1.0에서 문서 가져오기", + "databaseFromV010": "v0.1.0에서 데이터베이스 가져오기", + "notionZip": "Notion 내보낸 Zip 파일", "csv": "CSV", - "database": "데이터 베이스" + "database": "데이터베이스" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "파일을 드래그 앤 드롭하거나 클릭하여 ", + "placeholderUpload": "업로드", + "placeholderRight": "하거나 이미지 링크를 붙여넣으세요.", + "dropToUpload": "업로드할 파일을 드롭하세요", + "change": "변경" + } }, "disclosureAction": { - "rename": "이름변경", + "rename": "이름 변경", "delete": "삭제", "duplicate": "복제", - "openNewTab": "새 탭에서 열기" + "unfavorite": "즐겨찾기에서 제거", + "favorite": "즐겨찾기에 추가", + "openNewTab": "새 탭에서 열기", + "moveTo": "이동", + "addToFavorites": "즐겨찾기에 추가", + "copyLink": "링크 복사", + "changeIcon": "아이콘 변경", + "collapseAllPages": "모든 하위 페이지 접기", + "movePageTo": "페이지 이동", + "move": "이동", + "lockPage": "페이지 잠금" }, "blankPageTitle": "빈 페이지", - "newPageText": "새로운 페이지", + "newPageText": "새 페이지", + "newDocumentText": "새 문서", + "newGridText": "새 그리드", + "newCalendarText": "새 캘린더", + "newBoardText": "새 보드", "chat": { - "newChat": "AI 대화" + "newChat": "AI 채팅", + "inputMessageHint": "@:appName AI에게 물어보세요", + "inputLocalAIMessageHint": "@:appName 로컬 AI에게 물어보세요", + "unsupportedCloudPrompt": "이 기능은 @:appName Cloud를 사용할 때만 사용할 수 있습니다", + "relatedQuestion": "추천 질문", + "serverUnavailable": "연결이 끊어졌습니다. 인터넷을 확인하고", + "aiServerUnavailable": "AI 서비스가 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요.", + "retry": "다시 시도", + "clickToRetry": "다시 시도하려면 클릭", + "regenerateAnswer": "다시 생성", + "question1": "Kanban을 사용하여 작업 관리하는 방법", + "question2": "GTD 방법 설명", + "question3": "Rust를 사용하는 이유", + "question4": "내 주방에 있는 재료로 요리법 만들기", + "question5": "내 페이지에 대한 일러스트레이션 만들기", + "question6": "다가오는 주의 할 일 목록 작성", + "aiMistakePrompt": "AI는 실수를 할 수 있습니다. 중요한 정보를 확인하세요.", + "chatWithFilePrompt": "파일과 채팅하시겠습니까?", + "indexFileSuccess": "파일 색인화 성공", + "inputActionNoPages": "페이지 결과 없음", + "referenceSource": { + "zero": "0개의 출처 발견", + "one": "{count}개의 출처 발견", + "other": "{count}개의 출처 발견" + }, + "clickToMention": "페이지 언급", + "uploadFile": "PDF, 텍스트 또는 마크다운 파일 첨부", + "questionDetail": "안녕하세요 {}! 오늘 어떻게 도와드릴까요?", + "indexingFile": "{} 색인화 중", + "generatingResponse": "응답 생성 중", + "selectSources": "출처 선택", + "currentPage": "현재 페이지", + "sourcesLimitReached": "최대 3개의 최상위 문서와 그 하위 문서만 선택할 수 있습니다", + "sourceUnsupported": "현재 데이터베이스와의 채팅을 지원하지 않습니다", + "regenerate": "다시 시도", + "addToPageButton": "페이지에 메시지 추가", + "addToPageTitle": "메시지 추가...", + "addToNewPage": "새 페이지 만들기", + "addToNewPageName": "\"{}\"에서 추출한 메시지", + "addToNewPageSuccessToast": "메시지가 추가되었습니다", + "openPagePreviewFailedToast": "페이지를 열지 못했습니다", + "changeFormat": { + "actionButton": "형식 변경", + "confirmButton": "이 형식으로 다시 생성", + "textOnly": "텍스트", + "imageOnly": "이미지 전용", + "textAndImage": "텍스트와 이미지", + "text": "단락", + "bullet": "글머리 기호 목록", + "number": "번호 매기기 목록", + "table": "표", + "blankDescription": "응답 형식", + "defaultDescription": "자동 모드", + "textWithImageDescription": "@:chat.changeFormat.text와 이미지", + "numberWithImageDescription": "@:chat.changeFormat.number와 이미지", + "bulletWithImageDescription": "@:chat.changeFormat.bullet와 이미지", + "tableWithImageDescription": "@:chat.changeFormat.table와 이미지" + }, + "selectBanner": { + "saveButton": "추가 ...", + "selectMessages": "메시지 선택", + "nSelected": "{}개 선택됨", + "allSelected": "모두 선택됨" + }, + "stopTooltip": "생성 중지" }, "trash": { "text": "휴지통", - "restoreAll": "모두 복구", + "restoreAll": "모두 복원", + "restore": "복원", "deleteAll": "모두 삭제", "pageHeader": { "fileName": "파일 이름", - "lastModified": "수정날짜", - "created": "생성날짜" + "lastModified": "마지막 수정", + "created": "생성됨" }, "confirmDeleteAll": { - "title": "휴지통의 모든 페이지를 삭제하시겠습니까?", - "caption": "이 작업은 취소할 수 없습니다." + "title": "휴지통의 모든 페이지", + "caption": "휴지통의 모든 항목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "confirmRestoreAll": { - "title": "휴지통의 모든 페이지를 복원하시겠습니까?", - "caption": "이 작업은 취소할 수 없습니다." - } + "title": "휴지통의 모든 페이지 복원", + "caption": "이 작업은 되돌릴 수 없습니다." + }, + "restorePage": { + "title": "복원: {}", + "caption": "이 페이지를 복원하시겠습니까?" + }, + "mobile": { + "actions": "휴지통 작업", + "empty": "휴지통에 페이지나 공간이 없습니다", + "emptyDescription": "필요 없는 항목을 휴지통으로 이동하세요.", + "isDeleted": "삭제됨", + "isRestored": "복원됨" + }, + "confirmDeleteTitle": "이 페이지를 영구적으로 삭제하시겠습니까?" }, "deletePagePrompt": { - "text": "현재 페이지는 휴지통에 있습니다", - "restore": "페이지 복구", - "deletePermanent": "영구 삭제" + "text": "이 페이지는 휴지통에 있습니다", + "restore": "페이지 복원", + "deletePermanent": "영구적으로 삭제", + "deletePermanentDescription": "이 페이지를 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "dialogCreatePageNameHint": "페이지 이름", "questionBubble": { - "shortcuts": "바로 가기", - "whatsNew": "새로운 소식", - "help": "도움 및 지원", - "markdown": "가격 인하", + "shortcuts": "단축키", + "whatsNew": "새로운 기능", + "markdown": "Markdown", "debug": { "name": "디버그 정보", - "success": "디버그 정보를 클립보드로 복사했습니다.", - "fail": "디버그 정보를 클립보드로 복사할 수 없습니다." + "success": "디버그 정보를 클립보드에 복사했습니다!", + "fail": "디버그 정보를 클립보드에 복사할 수 없습니다" }, - "feedback": "피드백" + "feedback": "피드백", + "help": "도움말 및 지원" }, "menuAppHeader": { - "addPageTooltip": "하위에 페이지 추가", - "defaultNewPageName": "제목없음", - "renameDialog": "이름변경" + "moreButtonToolTip": "제거, 이름 변경 등...", + "addPageTooltip": "빠르게 페이지 추가", + "defaultNewPageName": "제목 없음", + "renameDialog": "이름 변경", + "pageNameSuffix": "복사본" }, + "noPagesInside": "내부에 페이지가 없습니다", "toolbar": { - "undo": "실행취소", - "redo": "재실행", + "undo": "실행 취소", + "redo": "다시 실행", "bold": "굵게", "italic": "기울임꼴", "underline": "밑줄", "strike": "취소선", "numList": "번호 매기기 목록", "bulletList": "글머리 기호 목록", - "checkList": "작업 목록", + "checkList": "체크리스트", "inlineCode": "인라인 코드", - "quote": "인용구 블록", + "quote": "인용 블록", "header": "헤더", - "highlight": "하이라이트", + "highlight": "강조", "color": "색상", - "addLink": "링크 추가", - "link": "링크" + "addLink": "링크 추가" }, "tooltip": { - "lightMode": "라이트 모드로 변경", - "darkMode": "다크 모드로 변경", + "lightMode": "라이트 모드로 전환", + "darkMode": "다크 모드로 전환", "openAsPage": "페이지로 열기", - "addNewRow": "열 추가", - "openMenu": "메뉴를 여시려면 클릭하세요", - "dragRow": "행을 재정렬하려면 길게 누르세요.", + "addNewRow": "새 행 추가", + "openMenu": "메뉴 열기", + "dragRow": "행 순서 변경", "viewDataBase": "데이터베이스 보기", - "referencePage": "이 {name}은(는) 참조됩니다", - "addBlockBelow": "아래에 블록 추가" + "referencePage": "이 {name}이 참조됨", + "addBlockBelow": "아래에 블록 추가", + "aiGenerate": "생성" }, "sideBar": { "closeSidebar": "사이드바 닫기", - "openSidebar": "사이드바 열기" + "openSidebar": "사이드바 열기", + "expandSidebar": "전체 페이지로 확장", + "personal": "개인", + "private": "비공개", + "workspace": "작업 공간", + "favorites": "즐겨찾기", + "clickToHidePrivate": "비공개 공간 숨기기\n여기에서 만든 페이지는 본인만 볼 수 있습니다", + "clickToHideWorkspace": "작업 공간 숨기기\n여기에서 만든 페이지는 모든 멤버가 볼 수 있습니다", + "clickToHidePersonal": "개인 공간 숨기기", + "clickToHideFavorites": "즐겨찾기 공간 숨기기", + "addAPage": "새 페이지 추가", + "addAPageToPrivate": "비공개 공간에 페이지 추가", + "addAPageToWorkspace": "작업 공간에 페이지 추가", + "recent": "최근", + "today": "오늘", + "thisWeek": "이번 주", + "others": "이전 즐겨찾기", + "earlier": "이전", + "justNow": "방금", + "minutesAgo": "{count}분 전", + "lastViewed": "마지막으로 본", + "favoriteAt": "즐겨찾기한", + "emptyRecent": "최근 페이지 없음", + "emptyRecentDescription": "페이지를 보면 여기에서 쉽게 찾을 수 있습니다.", + "emptyFavorite": "즐겨찾기 페이지 없음", + "emptyFavoriteDescription": "페이지를 즐겨찾기에 추가하면 여기에서 빠르게 접근할 수 있습니다!", + "removePageFromRecent": "최근 항목에서 이 페이지를 제거하시겠습니까?", + "removeSuccess": "성공적으로 제거되었습니다", + "favoriteSpace": "즐겨찾기", + "RecentSpace": "최근", + "Spaces": "공간", + "upgradeToPro": "Pro로 업그레이드", + "upgradeToAIMax": "무제한 AI 잠금 해제", + "storageLimitDialogTitle": "무료 저장 공간이 부족합니다. 무제한 저장 공간을 잠금 해제하려면 업그레이드하세요", + "storageLimitDialogTitleIOS": "무료 저장 공간이 부족합니다.", + "aiResponseLimitTitle": "무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "aiResponseLimitDialogTitle": "AI 응답 한도에 도달했습니다", + "aiResponseLimit": "무료 AI 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max 또는 Pro 플랜을 클릭하여 더 많은 AI 응답을 받으세요", + "askOwnerToUpgradeToPro": "작업 공간의 무료 저장 공간이 부족합니다. 작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요", + "askOwnerToUpgradeToProIOS": "작업 공간의 무료 저장 공간이 부족합니다.", + "askOwnerToUpgradeToAIMax": "작업 공간의 무료 AI 응답이 부족합니다. 작업 공간 소유자에게 플랜을 업그레이드하거나 AI 애드온을 구매하도록 요청하세요", + "askOwnerToUpgradeToAIMaxIOS": "작업 공간의 무료 AI 응답이 부족합니다.", + "purchaseAIMax": "작업 공간의 AI 이미지 응답이 부족합니다. 작업 공간 소유자에게 AI Max를 구매하도록 요청하세요", + "aiImageResponseLimit": "AI 이미지 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max를 클릭하여 더 많은 AI 이미지 응답을 받으세요", + "purchaseStorageSpace": "저장 공간 구매", + "singleFileProPlanLimitationDescription": "무료 플랜에서 허용되는 최대 파일 업로드 크기를 초과했습니다. 더 큰 파일을 업로드하려면 Pro 플랜으로 업그레이드하세요", + "purchaseAIResponse": "구매 ", + "askOwnerToUpgradeToLocalAI": "작업 공간 소유자에게 AI On-device를 활성화하도록 요청하세요", + "upgradeToAILocal": "최고의 프라이버시를 위해 로컬 모델을 장치에서 실행", + "upgradeToAILocalDesc": "PDF와 채팅하고, 글쓰기를 개선하고, 로컬 AI를 사용하여 테이블을 자동으로 채우세요" }, "notifications": { "export": { - "markdown": "마크다운으로 노트를 내보냄", + "markdown": "노트를 Markdown으로 내보냈습니다", "path": "Documents/flowy" } }, "contactsPage": { "title": "연락처", - "whatsHappening": "이번주에는 무슨 일이 있나요?", + "whatsHappening": "이번 주에 무슨 일이 있나요?", "addContact": "연락처 추가", - "editContact": "연락처 편집" + "editContact": "연락처 수정" }, "button": { "ok": "확인", + "confirm": "확인", "done": "완료", "cancel": "취소", "signIn": "로그인", "signOut": "로그아웃", "complete": "완료", "save": "저장", - "generate": "생성하다", + "generate": "생성", "esc": "ESC", - "keep": "유지하다", - "tryAgain": "다시 시도하십시오", - "discard": "버리다", - "replace": "바꾸다", + "keep": "유지", + "tryAgain": "다시 시도", + "discard": "버리기", + "replace": "교체", "insertBelow": "아래에 삽입", + "insertAbove": "위에 삽입", "upload": "업로드", - "edit": "편집하다", + "edit": "편집", "delete": "삭제", - "duplicate": "복제하다", - "putback": "다시 집어 넣어", - "share": "공유" + "copy": "복사", + "duplicate": "복제", + "putback": "되돌리기", + "update": "업데이트", + "share": "공유", + "removeFromFavorites": "즐겨찾기에서 제거", + "removeFromRecent": "최근 항목에서 제거", + "addToFavorites": "즐겨찾기에 추가", + "favoriteSuccessfully": "즐겨찾기에 성공적으로 추가되었습니다", + "unfavoriteSuccessfully": "즐겨찾기에서 성공적으로 제거되었습니다", + "duplicateSuccessfully": "성공적으로 복제되었습니다", + "rename": "이름 변경", + "helpCenter": "도움말 센터", + "add": "추가", + "yes": "예", + "no": "아니요", + "clear": "지우기", + "remove": "제거", + "dontRemove": "제거하지 않음", + "copyLink": "링크 복사", + "align": "정렬", + "login": "로그인", + "logout": "로그아웃", + "deleteAccount": "계정 삭제", + "back": "뒤로", + "signInGoogle": "Google로 계속", + "signInGithub": "GitHub로 계속", + "signInDiscord": "Discord로 계속", + "more": "더 보기", + "create": "생성", + "close": "닫기", + "next": "다음", + "previous": "이전", + "submit": "제출", + "download": "다운로드", + "backToHome": "홈으로 돌아가기", + "viewing": "보기", + "editing": "편집 중", + "gotIt": "알겠습니다", + "retry": "다시 시도", + "uploadFailed": "업로드 실패.", + "copyLinkOriginal": "원본 링크 복사" }, "label": { "welcome": "환영합니다!", "firstName": "이름", "middleName": "중간 이름", "lastName": "성", - "stepX": "{X} 단계" + "stepX": "단계 {X}" }, "oAuth": { "err": { - "failedTitle": "계정에 연결을 할 수 없습니다.", - "failedMsg": "브라우저에서 회원가입이 완료되었는지 확인해주세요." + "failedTitle": "계정에 연결할 수 없습니다.", + "failedMsg": "브라우저에서 로그인 프로세스를 완료했는지 확인하세요." }, "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "구글 연락처를 가져오기 위해서 웹브라우저로 앱을 승인 해야 합니다.", - "instruction2": "아이콘을 클릭 또는 텍스트를 선택해서 이 코드를 클립보드로 복사하세요:", - "instruction3": "웹브라우저로 다음 링크로 가셔서 위 코드를 입력해주세요:", - "instruction4": "가입 완료 후 아래 버튼을 눌러주세요:" + "title": "GOOGLE 로그인", + "instruction1": "Google 연락처를 가져오려면 웹 브라우저를 사용하여 이 애플리케이션을 인증해야 합니다.", + "instruction2": "아이콘을 클릭하거나 텍스트를 선택하여 이 코드를 클립보드에 복사하세요:", + "instruction3": "웹 브라우저에서 다음 링크로 이동하고 위의 코드를 입력하세요:", + "instruction4": "가입을 완료했으면 아래 버튼을 누르세요:" } }, "settings": { "title": "설정", - "accountPage": { - "general": { - "title": "사용자 이름 & 프로필 사진", - "changeProfilePicture": "프로필 사진 변경" + "popupMenuItem": { + "settings": "설정", + "members": "멤버", + "trash": "휴지통", + "helpAndSupport": "도움말 및 지원" + }, + "sites": { + "title": "사이트", + "namespaceTitle": "네임스페이스", + "namespaceDescription": "네임스페이스 및 홈페이지 관리", + "namespaceHeader": "네임스페이스", + "homepageHeader": "홈페이지", + "updateNamespace": "네임스페이스 업데이트", + "removeHomepage": "홈페이지 제거", + "selectHomePage": "페이지 선택", + "clearHomePage": "이 네임스페이스의 홈페이지를 지웁니다", + "customUrl": "맞춤 URL", + "namespace": { + "description": "이 변경 사항은 이 네임스페이스에 라이브로 게시된 모든 페이지에 적용됩니다", + "tooltip": "부적절한 네임스페이스를 제거할 권리를 보유합니다", + "updateExistingNamespace": "기존 네임스페이스 업데이트", + "upgradeToPro": "Pro 플랜으로 업그레이드하여 홈페이지 설정", + "redirectToPayment": "결제 페이지로 리디렉션 중...", + "onlyWorkspaceOwnerCanSetHomePage": "작업 공간 소유자만 홈페이지를 설정할 수 있습니다", + "pleaseAskOwnerToSetHomePage": "작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요" + }, + "publishedPage": { + "title": "모든 게시된 페이지", + "description": "게시된 페이지 관리", + "page": "페이지", + "pathName": "경로 이름", + "date": "게시 날짜", + "emptyHinText": "이 작업 공간에 게시된 페이지가 없습니다", + "noPublishedPages": "게시된 페이지 없음", + "settings": "게시 설정", + "clickToOpenPageInApp": "앱에서 페이지 열기", + "clickToOpenPageInBrowser": "브라우저에서 페이지 열기" + }, + "error": { + "failedToGeneratePaymentLink": "Pro 플랜 결제 링크 생성 실패", + "failedToUpdateNamespace": "네임스페이스 업데이트 실패", + "proPlanLimitation": "네임스페이스를 업데이트하려면 Pro 플랜으로 업그레이드해야 합니다", + "namespaceAlreadyInUse": "네임스페이스가 이미 사용 중입니다. 다른 네임스페이스를 시도하세요", + "invalidNamespace": "잘못된 네임스페이스입니다. 다른 네임스페이스를 시도하세요", + "namespaceLengthAtLeast2Characters": "네임스페이스는 최소 2자 이상이어야 합니다", + "onlyWorkspaceOwnerCanUpdateNamespace": "작업 공간 소유자만 네임스페이스를 업데이트할 수 있습니다", + "onlyWorkspaceOwnerCanRemoveHomepage": "작업 공간 소유자만 홈페이지를 제거할 수 있습니다", + "setHomepageFailed": "홈페이지 설정 실패", + "namespaceTooLong": "네임스페이스가 너무 깁니다. 다른 네임스페이스를 시도하세요", + "namespaceTooShort": "네임스페이스가 너무 짧습니다. 다른 네임스페이스를 시도하세요", + "namespaceIsReserved": "네임스페이스가 예약되어 있습니다. 다른 네임스페이스를 시도하세요", + "updatePathNameFailed": "경로 이름 업데이트 실패", + "removeHomePageFailed": "홈페이지 제거 실패", + "publishNameContainsInvalidCharacters": "경로 이름에 잘못된 문자가 포함되어 있습니다. 다른 이름을 시도하세요", + "publishNameTooShort": "경로 이름이 너무 짧습니다. 다른 이름을 시도하세요", + "publishNameTooLong": "경로 이름이 너무 깁니다. 다른 이름을 시도하세요", + "publishNameAlreadyInUse": "경로 이름이 이미 사용 중입니다. 다른 이름을 시도하세요", + "namespaceContainsInvalidCharacters": "네임스페이스에 잘못된 문자가 포함되어 있습니다. 다른 네임스페이스를 시도하세요", + "publishPermissionDenied": "작업 공간 소유자 또는 페이지 게시자만 게시 설정을 관리할 수 있습니다", + "publishNameCannotBeEmpty": "경로 이름은 비워둘 수 없습니다. 다른 이름을 시도하세요" + }, + "success": { + "namespaceUpdated": "네임스페이스가 성공적으로 업데이트되었습니다", + "setHomepageSuccess": "홈페이지가 성공적으로 설정되었습니다", + "updatePathNameSuccess": "경로 이름이 성공적으로 업데이트되었습니다", + "removeHomePageSuccess": "홈페이지가 성공적으로 제거되었습니다" } }, + "accountPage": { + "menuLabel": "계정 및 앱", + "title": "내 계정", + "general": { + "title": "계정 이름 및 프로필 이미지", + "changeProfilePicture": "프로필 사진 변경" + }, + "email": { + "title": "이메일", + "actions": { + "change": "이메일 변경" + } + }, + "login": { + "title": "계정 로그인", + "loginLabel": "로그인", + "logoutLabel": "로그아웃" + }, + "isUpToDate": "@:appName이 최신 상태입니다!", + "officialVersion": "버전 {version} (공식 빌드)" + }, + "workspacePage": { + "menuLabel": "작업 공간", + "title": "작업 공간", + "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜/시간 형식 및 언어를 사용자 정의합니다.", + "workspaceName": { + "title": "작업 공간 이름" + }, + "workspaceIcon": { + "title": "작업 공간 아이콘", + "description": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다." + }, + "appearance": { + "title": "외관", + "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", + "options": { + "system": "자동", + "light": "라이트", + "dark": "다크" + } + }, + "resetCursorColor": { + "title": "문서 커서 색상 재설정", + "description": "커서 색상을 재설정하시겠습니까?" + }, + "resetSelectionColor": { + "title": "문서 선택 색상 재설정", + "description": "선택 색상을 재설정하시겠습니까?" + }, + "resetWidth": { + "resetSuccess": "문서 너비가 성공적으로 재설정되었습니다" + }, + "theme": { + "title": "테마", + "description": "미리 설정된 테마를 선택하거나 사용자 정의 테마를 업로드하세요.", + "uploadCustomThemeTooltip": "사용자 정의 테마 업로드" + }, + "workspaceFont": { + "title": "작업 공간 글꼴", + "noFontHint": "글꼴을 찾을 수 없습니다. 다른 용어를 시도하세요." + }, + "textDirection": { + "title": "텍스트 방향", + "leftToRight": "왼쪽에서 오른쪽으로", + "rightToLeft": "오른쪽에서 왼쪽으로", + "auto": "자동", + "enableRTLItems": "RTL 도구 모음 항목 활성화" + }, + "layoutDirection": { + "title": "레이아웃 방향", + "leftToRight": "왼쪽에서 오른쪽으로", + "rightToLeft": "오른쪽에서 왼쪽으로" + }, + "dateTime": { + "title": "날짜 및 시간", + "example": "{}에 {} ({})", + "24HourTime": "24시간 형식", + "dateFormat": { + "label": "날짜 형식", + "local": "로컬", + "us": "미국", + "iso": "ISO", + "friendly": "친숙한", + "dmy": "일/월/년" + } + }, + "language": { + "title": "언어" + }, + "deleteWorkspacePrompt": { + "title": "작업 공간 삭제", + "content": "이 작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다." + }, + "leaveWorkspacePrompt": { + "title": "작업 공간 나가기", + "content": "이 작업 공간을 나가시겠습니까? 모든 페이지와 데이터에 대한 액세스를 잃게 됩니다.", + "success": "작업 공간을 성공적으로 나갔습니다.", + "fail": "작업 공간 나가기 실패." + }, + "manageWorkspace": { + "title": "작업 공간 관리", + "leaveWorkspace": "작업 공간 나가기", + "deleteWorkspace": "작업 공간 삭제" + } + }, + "manageDataPage": { + "menuLabel": "데이터 관리", + "title": "데이터 관리", + "description": "로컬 저장소 데이터를 관리하거나 기존 데이터를 @:appName에 가져옵니다.", + "dataStorage": { + "title": "파일 저장 위치", + "tooltip": "파일이 저장되는 위치", + "actions": { + "change": "경로 변경", + "open": "폴더 열기", + "openTooltip": "현재 데이터 폴더 위치 열기", + "copy": "경로 복사", + "copiedHint": "경로가 복사되었습니다!", + "resetTooltip": "기본 위치로 재설정" + }, + "resetDialog": { + "title": "확실합니까?", + "description": "경로를 기본 데이터 위치로 재설정해도 데이터가 삭제되지 않습니다. 현재 데이터를 다시 가져오려면 현재 위치의 경로를 먼저 복사해야 합니다." + } + }, + "importData": { + "title": "데이터 가져오기", + "tooltip": "@:appName 백업/데이터 폴더에서 데이터 가져오기", + "description": "외부 @:appName 데이터 폴더에서 데이터 복사", + "action": "파일 찾아보기" + }, + "encryption": { + "title": "암호화", + "tooltip": "데이터 저장 및 암호화 방법 관리", + "descriptionNoEncryption": "암호화를 켜면 모든 데이터가 암호화됩니다. 이 작업은 되돌릴 수 없습니다.", + "descriptionEncrypted": "데이터가 암호화되었습니다.", + "action": "데이터 암호화", + "dialog": { + "title": "모든 데이터를 암호화하시겠습니까?", + "description": "모든 데이터를 암호화하면 데이터가 안전하고 보호됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?" + } + }, + "cache": { + "title": "캐시 지우기", + "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", + "dialog": { + "title": "캐시 지우기", + "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", + "successHint": "캐시가 지워졌습니다!" + } + }, + "data": { + "fixYourData": "데이터 수정", + "fixButton": "수정", + "fixYourDataDescription": "데이터에 문제가 있는 경우 여기에서 수정할 수 있습니다." + } + }, + "shortcutsPage": { + "menuLabel": "단축키", + "title": "단축키", + "editBindingHint": "새 바인딩 입력", + "searchHint": "검색", + "actions": { + "resetDefault": "기본값으로 재설정" + }, + "errorPage": { + "message": "단축키를 로드하지 못했습니다: {}", + "howToFix": "다시 시도해 보세요. 문제가 계속되면 GitHub에 문의하세요." + }, + "resetDialog": { + "title": "단축키 재설정", + "description": "모든 키 바인딩을 기본값으로 재설정합니다. 나중에 되돌릴 수 없습니다. 계속하시겠습니까?", + "buttonLabel": "재설정" + }, + "conflictDialog": { + "title": "{}가 이미 사용 중입니다", + "descriptionPrefix": "이 키 바인딩은 현재 ", + "descriptionSuffix": "에서 사용 중입니다. 이 키 바인딩을 교체하면 {}에서 제거됩니다.", + "confirmLabel": "계속" + }, + "editTooltip": "키 바인딩 편집을 시작하려면 누르세요", + "keybindings": { + "toggleToDoList": "할 일 목록 전환", + "insertNewParagraphInCodeblock": "새 단락 삽입", + "pasteInCodeblock": "코드 블록에 붙여넣기", + "selectAllCodeblock": "모두 선택", + "indentLineCodeblock": "줄 시작에 두 칸 삽입", + "outdentLineCodeblock": "줄 시작에서 두 칸 삭제", + "twoSpacesCursorCodeblock": "커서 위치에 두 칸 삽입", + "copy": "선택 항목 복사", + "paste": "내용 붙여넣기", + "cut": "선택 항목 잘라내기", + "alignLeft": "텍스트 왼쪽 정렬", + "alignCenter": "텍스트 가운데 정렬", + "alignRight": "텍스트 오른쪽 정렬", + "insertInlineMathEquation": "인라인 수학 방정식 삽입", + "undo": "실행 취소", + "redo": "다시 실행", + "convertToParagraph": "블록을 단락으로 변환", + "backspace": "삭제", + "deleteLeftWord": "왼쪽 단어 삭제", + "deleteLeftSentence": "왼쪽 문장 삭제", + "delete": "오른쪽 문자 삭제", + "deleteMacOS": "왼쪽 문자 삭제", + "deleteRightWord": "오른쪽 단어 삭제", + "moveCursorLeft": "커서를 왼쪽으로 이동", + "moveCursorBeginning": "커서를 시작으로 이동", + "moveCursorLeftWord": "커서를 왼쪽 단어로 이동", + "moveCursorLeftSelect": "선택하고 커서를 왼쪽으로 이동", + "moveCursorBeginSelect": "선택하고 커서를 시작으로 이동", + "moveCursorLeftWordSelect": "선택하고 커서를 왼쪽 단어로 이동", + "moveCursorRight": "커서를 오른쪽으로 이동", + "moveCursorEnd": "커서를 끝으로 이동", + "moveCursorRightWord": "커서를 오른쪽 단어로 이동", + "moveCursorRightSelect": "선택하고 커서를 오른쪽으로 이동", + "moveCursorEndSelect": "선택하고 커서를 끝으로 이동", + "moveCursorRightWordSelect": "선택하고 커서를 오른쪽 단어로 이동", + "moveCursorUp": "커서를 위로 이동", + "moveCursorTopSelect": "선택하고 커서를 위로 이동", + "moveCursorTop": "커서를 위로 이동", + "moveCursorUpSelect": "선택하고 커서를 위로 이동", + "moveCursorBottomSelect": "선택하고 커서를 아래로 이동", + "moveCursorBottom": "커서를 아래로 이동", + "moveCursorDown": "커서를 아래로 이동", + "moveCursorDownSelect": "선택하고 커서를 아래로 이동", + "home": "맨 위로 스크롤", + "end": "맨 아래로 스크롤", + "toggleBold": "굵게 전환", + "toggleItalic": "기울임꼴 전환", + "toggleUnderline": "밑줄 전환", + "toggleStrikethrough": "취소선 전환", + "toggleCode": "인라인 코드 전환", + "toggleHighlight": "강조 전환", + "showLinkMenu": "링크 메뉴 표시", + "openInlineLink": "인라인 링크 열기", + "openLinks": "선택한 모든 링크 열기", + "indent": "들여쓰기", + "outdent": "내어쓰기", + "exit": "편집 종료", + "pageUp": "한 페이지 위로 스크롤", + "pageDown": "한 페이지 아래로 스크롤", + "selectAll": "모두 선택", + "pasteWithoutFormatting": "서식 없이 붙여넣기", + "showEmojiPicker": "이모지 선택기 표시", + "enterInTableCell": "테이블에 줄 바꿈 추가", + "leftInTableCell": "테이블에서 왼쪽 셀로 이동", + "rightInTableCell": "테이블에서 오른쪽 셀로 이동", + "upInTableCell": "테이블에서 위쪽 셀로 이동", + "downInTableCell": "테이블에서 아래쪽 셀로 이동", + "tabInTableCell": "테이블에서 다음 사용 가능한 셀로 이동", + "shiftTabInTableCell": "테이블에서 이전 사용 가능한 셀로 이동", + "backSpaceInTableCell": "셀의 시작 부분에서 멈춤" + }, + "commands": { + "codeBlockNewParagraph": "코드 블록 옆에 새 단락 삽입", + "codeBlockIndentLines": "코드 블록에서 줄 시작에 두 칸 삽입", + "codeBlockOutdentLines": "코드 블록에서 줄 시작에서 두 칸 삭제", + "codeBlockAddTwoSpaces": "코드 블록에서 커서 위치에 두 칸 삽입", + "codeBlockSelectAll": "코드 블록 내의 모든 내용 선택", + "codeBlockPasteText": "코드 블록에 텍스트 붙여넣기", + "textAlignLeft": "텍스트를 왼쪽으로 정렬", + "textAlignCenter": "텍스트를 가운데로 정렬", + "textAlignRight": "텍스트를 오른쪽으로 정렬" + }, + "couldNotLoadErrorMsg": "단축키를 로드할 수 없습니다. 다시 시도하세요", + "couldNotSaveErrorMsg": "단축키를 저장할 수 없습니다. 다시 시도하세요" + }, "aiPage": { + "title": "AI 설정", + "menuLabel": "AI 설정", "keys": { - "localAILoaded": "로컬 AI 모델이 성공적으로 추가되어 사용할 준비가 되었습니다.", - "localAIStart": "로컬 AI 대화 시작중...", - "localAILoading": "로컬 AI 대화 모델 로딩중...", - "localAIStopped": "로컬 AI가 중단되었습니다", + "enableAISearchTitle": "AI 검색", + "aiSettingsDescription": "선호하는 모델을 선택하여 AppFlowy AI를 구동하세요. 이제 GPT 4-o, Claude 3,5, Llama 3.1 및 Mistral 7B를 포함합니다", + "loginToEnableAIFeature": "AI 기능은 @:appName Cloud에 로그인한 후에만 활성화됩니다. @:appName 계정이 없는 경우 '내 계정'에서 가입하세요", + "llmModel": "언어 모델", + "llmModelType": "언어 모델 유형", + "downloadLLMPrompt": "{} 다운로드", + "downloadAppFlowyOfflineAI": "AI 오프라인 패키지를 다운로드하면 AI가 장치에서 실행됩니다. 계속하시겠습니까?", + "downloadLLMPromptDetail": "{} 로컬 모델을 다운로드하면 최대 {}의 저장 공간이 필요합니다. 계속하시겠습니까?", + "downloadBigFilePrompt": "다운로드 완료까지 약 10분이 소요될 수 있습니다", + "downloadAIModelButton": "다운로드", + "downloadingModel": "다운로드 중", + "localAILoaded": "로컬 AI 모델이 성공적으로 추가되어 사용할 준비가 되었습니다", + "localAIStart": "로컬 AI가 시작 중입니다. 느리다면 껐다가 다시 켜보세요", + "localAILoading": "로컬 AI 채팅 모델이 로드 중입니다...", + "localAIStopped": "로컬 AI가 중지되었습니다", + "localAIRunning": "로컬 AI가 실행 중입니다", + "localAIInitializing": "로컬 AI가 로드 중이며 장치에 따라 몇 분이 소요될 수 있습니다", + "localAINotReadyTextFieldPrompt": "로컬 AI가 로드되는 동안 편집할 수 없습니다", "failToLoadLocalAI": "로컬 AI를 시작하지 못했습니다", - "restartLocalAI": "로컬 AI 재시작", - "disableLocalAIDescription": "로컬 AI를 비활성화하시겠습니까?" + "restartLocalAI": "로컬 AI 다시 시작", + "disableLocalAITitle": "로컬 AI 비활성화", + "disableLocalAIDescription": "로컬 AI를 비활성화하시겠습니까?", + "localAIToggleTitle": "로컬 AI를 활성화 또는 비활성화하려면 전환", + "offlineAIInstruction1": "다음을 따르세요", + "offlineAIInstruction2": "지침", + "offlineAIInstruction3": "오프라인 AI를 활성화하려면", + "offlineAIDownload1": "AppFlowy AI를 다운로드하지 않은 경우 먼저", + "offlineAIDownload2": "다운로드", + "offlineAIDownload3": "하세요", + "activeOfflineAI": "활성화됨", + "downloadOfflineAI": "다운로드", + "openModelDirectory": "폴더 열기", + "pleaseFollowThese": "지침", + "instructions": "이 지침을 따르세요", + "installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요", + "startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다" } }, "planPage": { + "menuLabel": "플랜", + "title": "가격 플랜", "planUsage": { - "aiOnDeviceToggle": "최고의 개인 정보 보호를 위한 로컬 AI" + "title": "플랜 사용 요약", + "storageLabel": "저장 공간", + "storageUsage": "{} / {} GB", + "unlimitedStorageLabel": "무제한 저장 공간", + "collaboratorsLabel": "멤버", + "collaboratorsUsage": "{} / {}", + "aiResponseLabel": "AI 응답", + "aiResponseUsage": "{} / {}", + "unlimitedAILabel": "무제한 응답", + "proBadge": "Pro", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "Mac용 AI On-device", + "memberProToggle": "더 많은 멤버 및 무제한 AI", + "aiMaxToggle": "무제한 AI 및 고급 모델 액세스", + "aiOnDeviceToggle": "최고의 프라이버시를 위한 로컬 AI", + "aiCredit": { + "title": "@:appName AI 크레딧 추가", + "price": "{}", + "priceDescription": "1,000 크레딧당", + "purchase": "AI 구매", + "info": "작업 공간당 1,000개의 AI 크레딧을 추가하여 더 스마트하고 빠른 결과를 위해 AI를 워크플로에 원활하게 통합하세요:", + "infoItemOne": "데이터베이스당 최대 10,000개의 응답", + "infoItemTwo": "작업 공간당 최대 1,000개의 응답" + }, + "currentPlan": { + "bannerLabel": "현재 플랜", + "freeTitle": "무료", + "proTitle": "Pro", + "teamTitle": "팀", + "freeInfo": "모든 것을 정리하기 위한 최대 2명의 개인용", + "proInfo": "최대 10명의 소규모 팀을 위한 완벽한 솔루션.", + "teamInfo": "모든 생산적이고 잘 조직된 팀을 위한 완벽한 솔루션.", + "upgrade": "플랜 변경", + "canceledInfo": "플랜이 취소되었습니다. {}에 무료 플랜으로 다운그레이드됩니다." + }, + "addons": { + "title": "애드온", + "addLabel": "추가", + "activeLabel": "추가됨", + "aiMax": { + "title": "AI Max", + "description": "무제한 AI 응답 및 고급 AI 모델로 구동되는 50개의 AI 이미지를 매월 제공합니다", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별" + }, + "aiOnDevice": { + "title": "Mac용 AI On-device", + "description": "장치에서 Mistral 7B, LLAMA 3 및 기타 로컬 모델 실행", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별", + "recommend": "M1 이상 권장" + } + }, + "deal": { + "bannerLabel": "새해 할인!", + "title": "팀을 성장시키세요!", + "info": "Pro 및 팀 플랜을 업그레이드하고 10% 할인 혜택을 받으세요! @:appName AI를 포함한 강력한 새로운 기능으로 작업 공간 생산성을 높이세요.", + "viewPlans": "플랜 보기" + } + } + }, + "billingPage": { + "menuLabel": "청구", + "title": "청구", + "plan": { + "title": "플랜", + "freeLabel": "무료", + "proLabel": "Pro", + "planButtonLabel": "플랜 변경", + "billingPeriod": "청구 기간", + "periodButtonLabel": "기간 수정" + }, + "paymentDetails": { + "title": "결제 세부 정보", + "methodLabel": "결제 방법", + "methodButtonLabel": "방법 수정" + }, + "addons": { + "title": "애드온", + "addLabel": "추가", + "removeLabel": "제거", + "renewLabel": "갱신", + "aiMax": { + "label": "AI Max", + "description": "무제한 AI 및 고급 모델 잠금 해제", + "activeDescription": "다음 청구서가 {}에 만료됩니다", + "canceledDescription": "AI Max는 {}까지 사용할 수 있습니다" + }, + "aiOnDevice": { + "label": "Mac용 AI On-device", + "description": "장치에서 무제한 AI 잠금 해제", + "activeDescription": "다음 청구서가 {}에 만료됩니다", + "canceledDescription": "Mac용 AI On-device는 {}까지 사용할 수 있습니다" + }, + "removeDialog": { + "title": "{} 제거", + "description": "{plan}을 제거하시겠습니까? {plan}의 기능과 혜택에 대한 액세스를 즉시 잃게 됩니다." + } + }, + "currentPeriodBadge": "현재", + "changePeriod": "기간 변경", + "planPeriod": "{} 기간", + "monthlyInterval": "월별", + "monthlyPriceInfo": "월별 청구되는 좌석당", + "annualInterval": "연간", + "annualPriceInfo": "연간 청구되는 좌석당" + }, + "comparePlanDialog": { + "title": "플랜 비교 및 선택", + "planFeatures": "플랜\n기능", + "current": "현재", + "actions": { + "upgrade": "업그레이드", + "downgrade": "다운그레이드", + "current": "현재" + }, + "freePlan": { + "title": "무료", + "description": "모든 것을 정리하기 위한 최대 2명의 개인용", + "price": "{}", + "priceInfo": "영원히 무료" + }, + "proPlan": { + "title": "Pro", + "description": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별\n\n{} 월별 청구" + }, + "planLabels": { + "itemOne": "작업 공간", + "itemTwo": "멤버", + "itemThree": "저장 공간", + "itemFour": "실시간 협업", + "itemFive": "모바일 앱", + "itemSix": "AI 응답", + "itemSeven": "AI 이미지", + "itemFileUpload": "파일 업로드", + "customNamespace": "맞춤 네임스페이스", + "tooltipSix": "평생 동안 응답 수는 재설정되지 않습니다", + "intelligentSearch": "지능형 검색", + "tooltipSeven": "작업 공간의 URL 일부를 사용자 정의할 수 있습니다", + "customNamespaceTooltip": "맞춤 게시 사이트 URL" + }, + "freeLabels": { + "itemOne": "작업 공간당 청구", + "itemTwo": "최대 2명", + "itemThree": "5 GB", + "itemFour": "예", + "itemFive": "예", + "itemSix": "평생 10회", + "itemSeven": "평생 2회", + "itemFileUpload": "최대 7 MB", + "intelligentSearch": "지능형 검색" + }, + "proLabels": { + "itemOne": "작업 공간당 청구", + "itemTwo": "최대 10명", + "itemThree": "무제한", + "itemFour": "예", + "itemFive": "예", + "itemSix": "무제한", + "itemSeven": "월별 10개 이미지", + "itemFileUpload": "무제한", + "intelligentSearch": "지능형 검색" + }, + "paymentSuccess": { + "title": "이제 {} 플랜을 사용 중입니다!", + "description": "결제가 성공적으로 처리되었으며 플랜이 @:appName {}로 업그레이드되었습니다. 플랜 세부 정보를 플랜 페이지에서 확인할 수 있습니다" + }, + "downgradeDialog": { + "title": "플랜을 다운그레이드하시겠습니까?", + "description": "플랜을 다운그레이드하면 무료 플랜으로 돌아갑니다. 멤버는 이 작업 공간에 대한 액세스를 잃을 수 있으며 저장 공간 제한을 충족하기 위해 공간을 확보해야 할 수 있습니다.", + "downgradeLabel": "플랜 다운그레이드" } }, "cancelSurveyDialog": { + "title": "떠나셔서 아쉽습니다", + "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", + "commonOther": "기타", + "otherHint": "여기에 답변을 작성하세요", + "questionOne": { + "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", + "answerOne": "비용이 너무 높음", + "answerTwo": "기능이 기대에 미치지 못함", + "answerThree": "더 나은 대안을 찾음", + "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", + "answerFive": "서비스 문제 또는 기술적 어려움" + }, + "questionTwo": { + "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", + "answerOne": "매우 가능성이 높음", + "answerTwo": "어느 정도 가능성이 있음", + "answerThree": "잘 모르겠음", + "answerFour": "가능성이 낮음", + "answerFive": "매우 가능성이 낮음" + }, "questionThree": { - "answerFour": "로컬 AI 모델에 대한 액세스" + "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", + "answerOne": "다중 사용자 협업", + "answerTwo": "더 긴 시간 버전 기록", + "answerThree": "무제한 AI 응답", + "answerFour": "로컬 AI 모델 액세스" + }, + "questionFour": { + "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", + "answerOne": "훌륭함", + "answerTwo": "좋음", + "answerThree": "보통", + "answerFour": "평균 이하", + "answerFive": "불만족" } }, + "common": { + "uploadingFile": "파일 업로드 중입니다. 앱을 종료하지 마세요", + "uploadNotionSuccess": "Notion zip 파일이 성공적으로 업로드되었습니다. 가져오기가 완료되면 확인 이메일을 받게 됩니다", + "reset": "재설정" + }, "menu": { - "appearance": "화면", + "appearance": "외관", "language": "언어", "user": "사용자", "files": "파일", - "open": "설정 열기" + "notifications": "알림", + "open": "설정 열기", + "logout": "로그아웃", + "logoutPrompt": "로그아웃하시겠습니까?", + "selfEncryptionLogoutPrompt": "로그아웃하시겠습니까? 암호화 비밀을 복사했는지 확인하세요", + "syncSetting": "동기화 설정", + "cloudSettings": "클라우드 설정", + "enableSync": "동기화 활성화", + "enableSyncLog": "동기화 로그 활성화", + "enableSyncLogWarning": "동기화 문제를 진단하는 데 도움을 주셔서 감사합니다. 이 작업은 문서 편집 내용을 로컬 파일에 기록합니다. 활성화 후 앱을 종료하고 다시 열어야 합니다", + "enableEncrypt": "데이터 암호화", + "cloudURL": "기본 URL", + "webURL": "웹 URL", + "invalidCloudURLScheme": "잘못된 스키마", + "cloudServerType": "클라우드 서버", + "cloudServerTypeTip": "클라우드 서버를 변경한 후 현재 계정에서 로그아웃될 수 있습니다", + "cloudLocal": "로컬", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName Cloud 셀프 호스팅", + "appFlowyCloudUrlCanNotBeEmpty": "클라우드 URL은 비워둘 수 없습니다", + "clickToCopy": "클립보드에 복사", + "selfHostStart": "서버가 없는 경우", + "selfHostContent": "문서", + "selfHostEnd": "를 참조하여 셀프 호스팅 서버를 설정하는 방법을 확인하세요", + "pleaseInputValidURL": "유효한 URL을 입력하세요", + "changeUrl": "셀프 호스팅 URL을 {}로 변경", + "cloudURLHint": "서버의 기본 URL을 입력하세요", + "webURLHint": "웹 서버의 기본 URL을 입력하세요", + "cloudWSURL": "웹소켓 URL", + "cloudWSURLHint": "서버의 웹소켓 주소를 입력하세요", + "restartApp": "재시작", + "restartAppTip": "변경 사항을 적용하려면 애플리케이션을 재시작하세요. 현재 계정에서 로그아웃될 수 있습니다.", + "changeServerTip": "서버를 변경한 후 변경 사항을 적용하려면 재시작 버튼을 클릭해야 합니다", + "enableEncryptPrompt": "이 비밀로 데이터를 보호하려면 암호화를 활성화하세요. 안전하게 보관하세요. 활성화 후에는 비활성화할 수 없습니다. 비밀을 잃어버리면 데이터를 복구할 수 없습니다. 복사하려면 클릭하세요", + "inputEncryptPrompt": "암호화 비밀을 입력하세요", + "clickToCopySecret": "비밀을 복사하려면 클릭", + "configServerSetting": "서버 설정 구성", + "configServerGuide": "`빠른 시작`을 선택한 후 `설정`으로 이동하여 \"클라우드 설정\"을 구성하세요.", + "inputTextFieldHint": "비밀", + "historicalUserList": "사용자 로그인 기록", + "historicalUserListTooltip": "이 목록에는 익명 계정이 표시됩니다. 계정을 클릭하여 세부 정보를 확인할 수 있습니다. 익명 계정은 '시작하기' 버튼을 클릭하여 생성됩니다", + "openHistoricalUser": "익명 계정을 열려면 클릭", + "customPathPrompt": "Google Drive와 같은 클라우드 동기화 폴더에 @:appName 데이터 폴더를 저장하면 위험이 발생할 수 있습니다. 이 폴더 내의 데이터베이스에 여러 위치에서 동시에 액세스하거나 수정하면 동기화 충돌 및 데이터 손상이 발생할 수 있습니다", + "importAppFlowyData": "외부 @:appName 폴더에서 데이터 가져오기", + "importingAppFlowyDataTip": "데이터 가져오는 중입니다. 앱을 종료하지 마세요", + "importAppFlowyDataDescription": "외부 @:appName 데이터 폴더에서 데이터를 복사하여 현재 AppFlowy 데이터 폴더에 가져옵니다", + "importSuccess": "성공적으로 @:appName 데이터 폴더를 가져왔습니다", + "importFailed": "@:appName 데이터 폴더 가져오기 실패", + "importGuide": "자세한 내용은 참조 문서를 확인하세요" + }, + "notifications": { + "enableNotifications": { + "label": "알림 활성화", + "hint": "로컬 알림이 나타나지 않도록 하려면 끄세요." + }, + "showNotificationsIcon": { + "label": "알림 아이콘 표시", + "hint": "사이드바에서 알림 아이콘을 숨기려면 끄세요." + }, + "archiveNotifications": { + "allSuccess": "모든 알림이 성공적으로 보관되었습니다", + "success": "알림이 성공적으로 보관되었습니다" + }, + "markAsReadNotifications": { + "allSuccess": "모두 읽음으로 표시되었습니다", + "success": "읽음으로 표시되었습니다" + }, + "action": { + "markAsRead": "읽음으로 표시", + "multipleChoice": "더 선택", + "archive": "보관" + }, + "settings": { + "settings": "설정", + "markAllAsRead": "모두 읽음으로 표시", + "archiveAll": "모두 보관" + }, + "emptyInbox": { + "title": "받은 편지함 비어 있음!", + "description": "알림을 받으려면 알림을 설정하세요." + }, + "emptyUnread": { + "title": "읽지 않은 알림 없음", + "description": "모든 알림을 확인했습니다!" + }, + "emptyArchived": { + "title": "보관된 항목 없음", + "description": "보관된 알림이 여기에 표시됩니다." + }, + "tabs": { + "inbox": "받은 편지함", + "unread": "읽지 않음", + "archived": "보관됨" + }, + "refreshSuccess": "알림이 성공적으로 새로고침되었습니다", + "titles": { + "notifications": "알림", + "reminder": "알림" + } }, "appearance": { + "resetSetting": "재설정", "fontFamily": { - "label": "글꼴 패밀리", - "search": "검색" + "label": "글꼴", + "search": "검색", + "defaultFont": "시스템" }, "themeMode": { "label": "테마 모드", "light": "라이트 모드", "dark": "다크 모드", - "system": "시스템에 적응" + "system": "시스템에 맞춤" + }, + "fontScaleFactor": "글꼴 크기 비율", + "displaySize": "디스플레이 크기", + "documentSettings": { + "cursorColor": "문서 커서 색상", + "selectionColor": "문서 선택 색상", + "width": "문서 너비", + "changeWidth": "변경", + "pickColor": "색상 선택", + "colorShade": "색상 음영", + "opacity": "불투명도", + "hexEmptyError": "16진수 색상은 비워둘 수 없습니다", + "hexLengthError": "16진수 값은 6자리여야 합니다", + "hexInvalidError": "잘못된 16진수 값", + "opacityEmptyError": "불투명도는 비워둘 수 없습니다", + "opacityRangeError": "불투명도는 1에서 100 사이여야 합니다", + "app": "앱", + "flowy": "Flowy", + "apply": "적용" + }, + "layoutDirection": { + "label": "레이아웃 방향", + "hint": "화면의 콘텐츠 흐름을 왼쪽에서 오른쪽 또는 오른쪽에서 왼쪽으로 제어합니다.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "기본 텍스트 방향", + "hint": "텍스트가 기본적으로 왼쪽에서 시작할지 오른쪽에서 시작할지 지정합니다.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "자동", + "fallback": "레이아웃 방향과 동일" }, "themeUpload": { "button": "업로드", - "description": "아래 버튼을 사용하여 나만의 @:appName 테마를 업로드하세요.", - "loading": "테마를 확인하고 업로드하는 동안 잠시 기다려 주십시오...", - "uploadSuccess": "테마가 성공적으로 업로드되었습니다.", - "deletionFailure": "테마를 삭제하지 못했습니다. 수동으로 삭제해 보십시오.", + "uploadTheme": "테마 업로드", + "description": "아래 버튼을 사용하여 사용자 정의 @:appName 테마를 업로드하세요.", + "loading": "테마를 검증하고 업로드하는 동안 기다려주세요...", + "uploadSuccess": "테마가 성공적으로 업로드되었습니다", + "deletionFailure": "테마를 삭제하지 못했습니다. 수동으로 삭제해 보세요.", "filePickerDialogTitle": ".flowy_plugin 파일 선택", - "urlUploadFailure": "URL을 열지 못했습니다: {}", - "failure": "업로드된 테마의 형식이 잘못되었습니다." + "urlUploadFailure": "URL을 열지 못했습니다: {}" }, - "theme": "주제", + "theme": "테마", "builtInsLabel": "내장 테마", "pluginsLabel": "플러그인", - "lightLabel": "라이트 모드", - "darkLabel": "다크 모드" + "dateFormat": { + "label": "날짜 형식", + "local": "로컬", + "us": "미국", + "iso": "ISO", + "friendly": "친숙한", + "dmy": "일/월/년" + }, + "timeFormat": { + "label": "시간 형식", + "twelveHour": "12시간 형식", + "twentyFourHour": "24시간 형식" + }, + "showNamingDialogWhenCreatingPage": "페이지 생성 시 이름 지정 대화 상자 표시", + "enableRTLToolbarItems": "RTL 도구 모음 항목 활성화", + "members": { + "title": "멤버 설정", + "inviteMembers": "멤버 초대", + "inviteHint": "이메일로 초대", + "sendInvite": "초대 보내기", + "copyInviteLink": "초대 링크 복사", + "label": "멤버", + "user": "사용자", + "role": "역할", + "removeFromWorkspace": "작업 공간에서 제거", + "removeFromWorkspaceSuccess": "작업 공간에서 성공적으로 제거되었습니다", + "removeFromWorkspaceFailed": "작업 공간에서 제거 실패", + "owner": "소유자", + "guest": "게스트", + "member": "멤버", + "memberHintText": "멤버는 페이지를 읽고 편집할 수 있습니다", + "guestHintText": "게스트는 페이지를 읽고, 반응하고, 댓글을 달 수 있으며, 권한이 있는 특정 페이지를 편집할 수 있습니다.", + "emailInvalidError": "잘못된 이메일입니다. 확인하고 다시 시도하세요", + "emailSent": "이메일이 전송되었습니다. 받은 편지함을 확인하세요", + "members": "멤버", + "membersCount": { + "zero": "{}명의 멤버", + "one": "{}명의 멤버", + "other": "{}명의 멤버" + }, + "inviteFailedDialogTitle": "초대 전송 실패", + "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 업그레이드하세요.", + "inviteFailedMemberLimitMobile": "작업 공간의 멤버 한도에 도달했습니다.", + "memberLimitExceeded": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 ", + "memberLimitExceededUpgrade": "업그레이드", + "memberLimitExceededPro": "멤버 한도에 도달했습니다. 더 많은 멤버가 필요하면 ", + "memberLimitExceededProContact": "support@appflowy.io에 문의하세요", + "failedToAddMember": "멤버 추가 실패", + "addMemberSuccess": "멤버가 성공적으로 추가되었습니다", + "removeMember": "멤버 제거", + "areYouSureToRemoveMember": "이 멤버를 제거하시겠습니까?", + "inviteMemberSuccess": "초대가 성공적으로 전송되었습니다", + "failedToInviteMember": "멤버 초대 실패", + "workspaceMembersError": "문제가 발생했습니다", + "workspaceMembersErrorDescription": "현재 멤버 목록을 로드할 수 없습니다. 나중에 다시 시도하세요" + } }, "files": { "copy": "복사", "defaultLocation": "파일 및 데이터 저장 위치 읽기", "exportData": "데이터 내보내기", - "doubleTapToCopy": "경로를 복사하려면 두 번 탭하세요.", + "doubleTapToCopy": "경로를 복사하려면 두 번 탭하세요", "restoreLocation": "@:appName 기본 경로로 복원", "customizeLocation": "다른 폴더 열기", - "restartApp": "변경 사항을 적용하려면 앱을 다시 시작하십시오.", + "restartApp": "변경 사항을 적용하려면 앱을 재시작하세요.", "exportDatabase": "데이터베이스 내보내기", - "selectFiles": "내보낼 파일을 선택하십시오", + "selectFiles": "내보낼 파일 선택", "selectAll": "모두 선택", - "deselectAll": "모두 선택 취소", + "deselectAll": "모두 선택 해제", "createNewFolder": "새 폴더 만들기", - "createNewFolderDesc": "데이터를 저장할 위치를 알려주십시오.", + "createNewFolderDesc": "데이터를 저장할 위치를 알려주세요", "defineWhereYourDataIsStored": "데이터가 저장되는 위치 정의", - "open": "열려 있는", + "open": "열기", "openFolder": "기존 폴더 열기", - "openFolderDesc": "기존 @:appName 폴더에서 읽고 쓰기", + "openFolderDesc": "기존 @:appName 폴더를 읽고 쓰기", "folderHintText": "폴더 이름", "location": "새 폴더 만들기", - "locationDesc": "@:appName 데이터 폴더의 이름을 선택하세요", - "browser": "검색", - "create": "만들다", - "set": "세트", + "locationDesc": "@:appName 데이터 폴더의 이름을 지정하세요", + "browser": "찾아보기", + "create": "생성", + "set": "설정", "folderPath": "폴더를 저장할 경로", - "locationCannotBeEmpty": "경로는 비워둘 수 없습니다.", + "locationCannotBeEmpty": "경로는 비워둘 수 없습니다", "pathCopiedSnackbar": "파일 저장 경로가 클립보드에 복사되었습니다!", "changeLocationTooltips": "데이터 디렉토리 변경", - "change": "변화", + "change": "변경", "openLocationTooltips": "다른 데이터 디렉토리 열기", "openCurrentDataFolder": "현재 데이터 디렉토리 열기", - "recoverLocationTooltips": "@:appName의 기본 데이터 디렉터리로 재설정", - "exportFileSuccess": "파일을 성공적으로 내보냈습니다!", + "recoverLocationTooltips": "@:appName의 기본 데이터 디렉토리로 재설정", + "exportFileSuccess": "파일이 성공적으로 내보내졌습니다!", "exportFileFail": "파일 내보내기 실패!", - "export": "내보내다" + "export": "내보내기", + "clearCache": "캐시 지우기", + "clearCacheDesc": "이미지가 로드되지 않거나 글꼴이 제대로 표시되지 않는 등의 문제가 발생하면 캐시를 지워보세요. 이 작업은 사용자 데이터에는 영향을 미치지 않습니다.", + "areYouSureToClearCache": "캐시를 지우시겠습니까?", + "clearCacheSuccess": "캐시가 성공적으로 지워졌습니다!" }, "user": { "name": "이름", - "selectAnIcon": "아이콘을 선택하세요", - "pleaseInputYourOpenAIKey": "AI 키를 입력하십시오" + "email": "이메일", + "tooltipSelectIcon": "아이콘 선택", + "selectAnIcon": "아이콘 선택", + "pleaseInputYourOpenAIKey": "AI 키를 입력하세요", + "clickToLogout": "현재 사용자 로그아웃" + }, + "mobile": { + "personalInfo": "개인 정보", + "username": "사용자 이름", + "usernameEmptyError": "사용자 이름은 비워둘 수 없습니다", + "about": "정보", + "pushNotifications": "푸시 알림", + "support": "지원", + "joinDiscord": "Discord에 참여", + "privacyPolicy": "개인정보 보호정책", + "userAgreement": "사용자 계약", + "termsAndConditions": "이용 약관", + "userprofileError": "사용자 프로필을 로드하지 못했습니다", + "userprofileErrorDescription": "로그아웃 후 다시 로그인하여 문제가 계속되는지 확인하세요.", + "selectLayout": "레이아웃 선택", + "selectStartingDay": "시작일 선택", + "version": "버전" } }, "grid": { "deleteView": "이 보기를 삭제하시겠습니까?", - "createView": "새로운", + "createView": "새로 만들기", + "title": { + "placeholder": "제목 없음" + }, "settings": { "filter": "필터", - "sort": "종류", + "sort": "정렬", "sortBy": "정렬 기준", "properties": "속성", - "reorderPropertiesTooltip": "드래그하여 속성 재정렬", + "reorderPropertiesTooltip": "속성 순서 변경", "group": "그룹", "addFilter": "필터 추가", "deleteFilter": "필터 삭제", - "filterBy": "필터링 기준...", - "typeAValue": "값을 입력하세요...", - "layout": "공들여 나열한 것", - "databaseLayout": "공들여 나열한 것", - "Properties": "속성" + "filterBy": "필터 기준", + "typeAValue": "값 입력...", + "layout": "레이아웃", + "compactMode": "압축 모드", + "databaseLayout": "레이아웃", + "viewList": { + "zero": "0개의 보기", + "one": "{count}개의 보기", + "other": "{count}개의 보기" + }, + "editView": "보기 편집", + "boardSettings": "보드 설정", + "calendarSettings": "캘린더 설정", + "createView": "새 보기", + "duplicateView": "보기 복제", + "deleteView": "보기 삭제", + "numberOfVisibleFields": "{}개 표시됨" + }, + "filter": { + "empty": "활성 필터 없음", + "addFilter": "필터 추가", + "cannotFindCreatableField": "필터링할 적절한 필드를 찾을 수 없습니다", + "conditon": "조건", + "where": "조건" }, "textFilter": { "contains": "포함", - "doesNotContain": "포함되어 있지 않다", - "endsWith": "로 끝나다", + "doesNotContain": "포함하지 않음", + "endsWith": "끝남", "startWith": "시작", - "is": "~이다", - "isNot": "아니다", - "isEmpty": "비었다", + "is": "일치", + "isNot": "일치하지 않음", + "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음", "choicechipPrefix": { - "isNot": "아니다", + "isNot": "일치하지 않음", "startWith": "시작", - "endWith": "로 끝나다", - "isEmpty": "비었다", - "isNotEmpty": "비어있지 않다" + "endWith": "끝남", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" } }, "checkboxFilter": { - "isChecked": "체크", - "isUnchecked": "체크 해제", + "isChecked": "체크됨", + "isUnchecked": "체크되지 않음", "choicechipPrefix": { - "is": "~이다" + "is": "체크됨" } }, "checklistFilter": { - "isComplete": "완료되었습니다", - "isIncomplted": "불완전하다" + "isComplete": "완료됨", + "isIncomplted": "미완료" }, "selectOptionFilter": { - "is": "~이다", - "isNot": "아니다", + "is": "일치", + "isNot": "일치하지 않음", "contains": "포함", - "doesNotContain": "포함되어 있지 않다", - "isEmpty": "비었다", + "doesNotContain": "포함하지 않음", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" + }, + "dateFilter": { + "is": "일치", + "before": "이전", + "after": "이후", + "onOrBefore": "이전 또는 일치", + "onOrAfter": "이후 또는 일치", + "between": "사이", + "empty": "비어 있음", + "notEmpty": "비어 있지 않음", + "startDate": "시작 날짜", + "endDate": "종료 날짜", + "choicechipPrefix": { + "before": "이전", + "after": "이후", + "between": "사이", + "onOrBefore": "이전 또는 일치", + "onOrAfter": "이후 또는 일치", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" + } + }, + "numberFilter": { + "equal": "일치", + "notEqual": "일치하지 않음", + "lessThan": "미만", + "greaterThan": "초과", + "lessThanOrEqualTo": "이하", + "greaterThanOrEqualTo": "이상", + "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" }, "field": { - "hide": "숨기기", - "insertLeft": "왼쪽 삽입", - "insertRight": "오른쪽 삽입", + "label": "속성", + "hide": "속성 숨기기", + "show": "속성 표시", + "insertLeft": "왼쪽에 삽입", + "insertRight": "오른쪽에 삽입", "duplicate": "복제", "delete": "삭제", + "wrapCellContent": "텍스트 줄 바꿈", + "clear": "셀 지우기", + "switchPrimaryFieldTooltip": "기본 필드의 필드 유형을 변경할 수 없습니다", "textFieldName": "텍스트", "checkboxFieldName": "체크박스", "dateFieldName": "날짜", - "updatedAtFieldName": "마지막 수정 시간", - "createdAtFieldName": "만든 시간", + "updatedAtFieldName": "마지막 수정", + "createdAtFieldName": "생성일", "numberFieldName": "숫자", "singleSelectFieldName": "선택", - "multiSelectFieldName": "다중선택", - "urlFieldName": "링크", + "multiSelectFieldName": "다중 선택", + "urlFieldName": "URL", "checklistFieldName": "체크리스트", + "relationFieldName": "관계", + "summaryFieldName": "AI 요약", + "timeFieldName": "시간", + "mediaFieldName": "파일 및 미디어", + "translateFieldName": "AI 번역", + "translateTo": "번역 대상", "numberFormat": "숫자 형식", "dateFormat": "날짜 형식", - "includeTime": "시간 표시", + "includeTime": "시간 포함", + "isRange": "종료 날짜", "dateFormatFriendly": "월 일, 년", "dateFormatISO": "년-월-일", "dateFormatLocal": "월/일/년", @@ -419,181 +1527,558 @@ "dateFormatDayMonthYear": "일/월/년", "timeFormat": "시간 형식", "invalidTimeFormat": "잘못된 형식", - "timeFormatTwelveHour": "12 시간", - "timeFormatTwentyFourHour": "24 시간", + "timeFormatTwelveHour": "12시간", + "timeFormatTwentyFourHour": "24시간", + "clearDate": "날짜 지우기", + "dateTime": "날짜 시간", + "startDateTime": "시작 날짜 시간", + "endDateTime": "종료 날짜 시간", + "failedToLoadDate": "날짜 값을 로드하지 못했습니다", + "selectTime": "시간 선택", + "selectDate": "날짜 선택", + "visibility": "가시성", + "propertyType": "속성 유형", "addSelectOption": "옵션 추가", + "typeANewOption": "새 옵션 입력", "optionTitle": "옵션", "addOption": "옵션 추가", "editProperty": "속성 편집", - "newProperty": "열 추가", - "deleteFieldPromptMessage": "해당 속성을 삭제 하시겠습니까?" + "newProperty": "새 속성", + "openRowDocument": "페이지로 열기", + "deleteFieldPromptMessage": "확실합니까? 이 속성과 모든 데이터가 삭제됩니다", + "clearFieldPromptMessage": "확실합니까? 이 열의 모든 셀이 비워집니다", + "newColumn": "새 열", + "format": "형식", + "reminderOnDateTooltip": "이 셀에는 예약된 알림이 있습니다", + "optionAlreadyExist": "옵션이 이미 존재합니다" + }, + "rowPage": { + "newField": "새 필드 추가", + "fieldDragElementTooltip": "메뉴 열기", + "showHiddenFields": { + "one": "숨겨진 {count}개의 필드 표시", + "many": "숨겨진 {count}개의 필드 표시", + "other": "숨겨진 {count}개의 필드 표시" + }, + "hideHiddenFields": { + "one": "숨겨진 {count}개의 필드 숨기기", + "many": "숨겨진 {count}개의 필드 숨기기", + "other": "숨겨진 {count}개의 필드 숨기기" + }, + "openAsFullPage": "전체 페이지로 열기", + "moreRowActions": "더 많은 행 작업" }, "sort": { "ascending": "오름차순", "descending": "내림차순", + "by": "기준", + "empty": "활성 정렬 없음", + "cannotFindCreatableField": "정렬할 적절한 필드를 찾을 수 없습니다", + "deleteAllSorts": "모든 정렬 삭제", "addSort": "정렬 추가", - "deleteSort": "정렬 삭제" + "sortsActive": "정렬 중에는 {intention}할 수 없습니다", + "removeSorting": "이 보기의 모든 정렬을 제거하고 계속하시겠습니까?", + "fieldInUse": "이미 이 필드로 정렬 중입니다" }, "row": { + "label": "행", "duplicate": "복제", "delete": "삭제", - "textPlaceholder": "비어있음", - "copyProperty": "속성이 클립보드로 복사됨", + "titlePlaceholder": "제목 없음", + "textPlaceholder": "비어 있음", + "copyProperty": "속성이 클립보드에 복사되었습니다", "count": "개수", - "newRow": "행 추가", - "action": "행동" + "newRow": "새 행", + "loadMore": "더 로드", + "action": "작업", + "add": "아래에 추가하려면 클릭", + "drag": "이동하려면 드래그", + "deleteRowPrompt": "이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "deleteCardPrompt": "이 카드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "dragAndClick": "이동하려면 드래그, 메뉴를 열려면 클릭", + "insertRecordAbove": "위에 레코드 삽입", + "insertRecordBelow": "아래에 레코드 삽입", + "noContent": "내용 없음", + "reorderRowDescription": "행 순서 변경", + "createRowAboveDescription": "위에 행 생성", + "createRowBelowDescription": "아래에 행 삽입" }, "selectOption": { "create": "생성", "purpleColor": "보라색", - "pinkColor": "핑크색", - "lightPinkColor": "연한 핑크색", - "orangeColor": "오렌지색", - "yellowColor": "노랑색", + "pinkColor": "분홍색", + "lightPinkColor": "연분홍색", + "orangeColor": "주황색", + "yellowColor": "노란색", "limeColor": "라임색", - "greenColor": "초록색", - "aquaColor": "아쿠아색", - "blueColor": "파랑색", + "greenColor": "녹색", + "aquaColor": "청록색", + "blueColor": "파란색", "deleteTag": "태그 삭제", "colorPanelTitle": "색상", "panelTitle": "옵션 선택 또는 생성", - "searchOption": "옵션 검색" + "searchOption": "옵션 검색", + "searchOrCreateOption": "옵션 검색 또는 생성", + "createNew": "새로 생성", + "orSelectOne": "또는 옵션 선택", + "typeANewOption": "새 옵션 입력", + "tagName": "태그 이름" }, "checklist": { - "addNew": "항목 추가" + "taskHint": "작업 설명", + "addNew": "새 작업 추가", + "submitNewTask": "생성", + "hideComplete": "완료된 작업 숨기기", + "showComplete": "모든 작업 표시" + }, + "url": { + "launch": "브라우저에서 링크 열기", + "copy": "링크를 클립보드에 복사", + "textFieldHint": "URL 입력" + }, + "relation": { + "relatedDatabasePlaceLabel": "관련 데이터베이스", + "relatedDatabasePlaceholder": "없음", + "inRelatedDatabase": "에", + "rowSearchTextFieldPlaceholder": "검색", + "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 아래 목록에서 하나를 먼저 선택하세요:", + "emptySearchResult": "레코드를 찾을 수 없습니다", + "linkedRowListLabel": "{count}개의 연결된 행", + "unlinkedRowListLabel": "다른 행 연결" }, "menuName": "그리드", - "referencedGridPrefix": "관점" + "referencedGridPrefix": "보기", + "calculate": "계산", + "calculationTypeLabel": { + "none": "없음", + "average": "평균", + "max": "최대", + "median": "중앙값", + "min": "최소", + "sum": "합계", + "count": "개수", + "countEmpty": "비어 있는 개수", + "countEmptyShort": "비어 있음", + "countNonEmpty": "비어 있지 않은 개수", + "countNonEmptyShort": "채워짐" + }, + "media": { + "rename": "이름 변경", + "download": "다운로드", + "expand": "확장", + "delete": "삭제", + "moreFilesHint": "+{}", + "addFileOrImage": "파일 또는 링크 추가", + "attachmentsHint": "{}", + "addFileMobile": "파일 추가", + "extraCount": "+{}", + "deleteFileDescription": "이 파일을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "showFileNames": "파일 이름 표시", + "downloadSuccess": "파일이 다운로드되었습니다", + "downloadFailedToken": "파일을 다운로드하지 못했습니다. 사용자 토큰이 없습니다", + "setAsCover": "표지로 설정", + "openInBrowser": "브라우저에서 열기", + "embedLink": "파일 링크 삽입" + } }, "document": { "menuName": "문서", "date": { - "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwelveHour": "오후 01:00", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "생성 중...", "slashMenu": { "board": { "selectABoardToLinkTo": "연결할 보드 선택", - "createANewBoard": "새 보드 만들기" + "createANewBoard": "새 보드 생성" }, "grid": { "selectAGridToLinkTo": "연결할 그리드 선택", - "createANewGrid": "새 그리드 만들기" + "createANewGrid": "새 그리드 생성" }, "calendar": { "selectACalendarToLinkTo": "연결할 캘린더 선택", - "createANewCalendar": "새 캘린더 만들기" + "createANewCalendar": "새 캘린더 생성" + }, + "document": { + "selectADocumentToLinkTo": "연결할 문서 선택" + }, + "name": { + "textStyle": "텍스트 스타일", + "list": "목록", + "toggle": "토글", + "fileAndMedia": "파일 및 미디어", + "simpleTable": "간단한 테이블", + "visuals": "시각 자료", + "document": "문서", + "advanced": "고급", + "text": "텍스트", + "heading1": "헤딩 1", + "heading2": "헤딩 2", + "heading3": "헤딩 3", + "image": "이미지", + "bulletedList": "글머리 기호 목록", + "numberedList": "번호 매기기 목록", + "todoList": "할 일 목록", + "doc": "문서", + "linkedDoc": "페이지로 연결", + "grid": "그리드", + "linkedGrid": "연결된 그리드", + "kanban": "칸반", + "linkedKanban": "연결된 칸반", + "calendar": "캘린더", + "linkedCalendar": "연결된 캘린더", + "quote": "인용", + "divider": "구분선", + "table": "테이블", + "callout": "콜아웃", + "outline": "개요", + "mathEquation": "수학 방정식", + "code": "코드", + "toggleList": "토글 목록", + "toggleHeading1": "토글 헤딩 1", + "toggleHeading2": "토글 헤딩 2", + "toggleHeading3": "토글 헤딩 3", + "emoji": "이모지", + "aiWriter": "AI에게 물어보기", + "dateOrReminder": "날짜 또는 알림", + "photoGallery": "사진 갤러리", + "file": "파일", + "twoColumns": "2열", + "threeColumns": "3열", + "fourColumns": "4열" + }, + "subPage": { + "name": "문서", + "keyword1": "하위 페이지", + "keyword2": "페이지", + "keyword3": "자식 페이지", + "keyword4": "페이지 삽입", + "keyword5": "페이지 포함", + "keyword6": "새 페이지", + "keyword7": "페이지 생성", + "keyword8": "문서" } }, "selectionMenu": { - "outline": "개요" + "outline": "개요", + "codeBlock": "코드 블록" }, "plugins": { - "referencedBoard": "참조 보드", + "referencedBoard": "참조된 보드", "referencedGrid": "참조된 그리드", - "referencedCalendar": "참조된 달력", - "autoGeneratorMenuItemName": "AI 작성자", - "autoGeneratorTitleName": "AI: AI에게 무엇이든 쓰라고 요청하세요...", - "autoGeneratorLearnMore": "더 알아보기", - "autoGeneratorGenerate": "생성하다", - "autoGeneratorHintText": "OpenAI에게 물어보세요 ...", - "autoGeneratorCantGetOpenAIKey": "AI 키를 가져올 수 없습니다.", - "autoGeneratorRewrite": "고쳐 쓰기", - "smartEdit": "AI 어시스턴트", + "referencedCalendar": "참조된 캘린더", + "referencedDocument": "참조된 문서", + "aiWriter": { + "userQuestion": "AI에게 물어보기", + "continueWriting": "계속 작성", + "fixSpelling": "맞춤법 및 문법 수정", + "improveWriting": "글쓰기 개선", + "summarize": "요약", + "explain": "설명", + "makeShorter": "짧게 만들기", + "makeLonger": "길게 만들기" + }, + "autoGeneratorMenuItemName": "AI 작성기", + "autoGeneratorTitleName": "AI: AI에게 무엇이든 물어보세요...", + "autoGeneratorLearnMore": "자세히 알아보기", + "autoGeneratorGenerate": "생성", + "autoGeneratorHintText": "AI에게 물어보기 ...", + "autoGeneratorCantGetOpenAIKey": "AI 키를 가져올 수 없습니다", + "autoGeneratorRewrite": "다시 작성", + "smartEdit": "AI에게 물어보기", "aI": "AI", - "smartEditFixSpelling": "맞춤법 수정", + "smartEditFixSpelling": "맞춤법 및 문법 수정", "warning": "⚠️ AI 응답은 부정확하거나 오해의 소지가 있을 수 있습니다.", - "smartEditSummarize": "요약하다", - "smartEditImproveWriting": "쓰기 향상", - "smartEditMakeLonger": "더 길게", - "smartEditCouldNotFetchResult": "OpenAI에서 결과를 가져올 수 없습니다.", - "smartEditCouldNotFetchKey": "AI 키를 가져올 수 없습니다.", + "smartEditSummarize": "요약", + "smartEditImproveWriting": "글쓰기 개선", + "smartEditMakeLonger": "길게 만들기", + "smartEditCouldNotFetchResult": "AI에서 결과를 가져올 수 없습니다", + "smartEditCouldNotFetchKey": "AI 키를 가져올 수 없습니다", "smartEditDisabled": "설정에서 AI 연결", - "discardResponse": "AI 응답을 삭제하시겠습니까?", - "createInlineMathEquation": "방정식 만들기", + "appflowyAIEditDisabled": "AI 기능을 활성화하려면 로그인하세요", + "discardResponse": "AI 응답을 버리시겠습니까?", + "createInlineMathEquation": "방정식 생성", + "fonts": "글꼴", + "insertDate": "날짜 삽입", + "emoji": "이모지", "toggleList": "토글 목록", + "emptyToggleHeading": "빈 토글 h{}. 내용을 추가하려면 클릭하세요.", + "emptyToggleList": "빈 토글 목록. 내용을 추가하려면 클릭하세요.", + "emptyToggleHeadingWeb": "빈 토글 h{level}. 내용을 추가하려면 클릭하세요", + "quoteList": "인용 목록", + "numberedList": "번호 매기기 목록", + "bulletedList": "글머리 기호 목록", + "todoList": "할 일 목록", + "callout": "콜아웃", + "simpleTable": { + "moreActions": { + "color": "색상", + "align": "정렬", + "delete": "삭제", + "duplicate": "복제", + "insertLeft": "왼쪽에 삽입", + "insertRight": "오른쪽에 삽입", + "insertAbove": "위에 삽입", + "insertBelow": "아래에 삽입", + "headerColumn": "헤더 열", + "headerRow": "헤더 행", + "clearContents": "내용 지우기", + "setToPageWidth": "페이지 너비로 설정", + "distributeColumnsWidth": "열 너비 균등 분배", + "duplicateRow": "행 복제", + "duplicateColumn": "열 복제", + "textColor": "텍스트 색상", + "cellBackgroundColor": "셀 배경 색상", + "duplicateTable": "테이블 복제" + }, + "clickToAddNewRow": "새 행을 추가하려면 클릭", + "clickToAddNewColumn": "새 열을 추가하려면 클릭", + "clickToAddNewRowAndColumn": "새 행과 열을 추가하려면 클릭", + "headerName": { + "table": "테이블", + "alignText": "텍스트 정렬" + } + }, "cover": { - "changeCover": "커버를 바꾸다", - "colors": "그림 물감", + "changeCover": "표지 변경", + "colors": "색상", "images": "이미지", "clearAll": "모두 지우기", - "abstract": "추상적인", + "abstract": "추상", "addCover": "표지 추가", "addLocalImage": "로컬 이미지 추가", "invalidImageUrl": "잘못된 이미지 URL", - "failedToAddImageToGallery": "갤러리에 이미지를 추가하지 못했습니다.", + "failedToAddImageToGallery": "갤러리에 이미지를 추가하지 못했습니다", "enterImageUrl": "이미지 URL 입력", - "add": "추가하다", - "back": "뒤쪽에", + "add": "추가", + "back": "뒤로", "saveToGallery": "갤러리에 저장", "removeIcon": "아이콘 제거", + "removeCover": "표지 제거", "pasteImageUrl": "이미지 URL 붙여넣기", "or": "또는", "pickFromFiles": "파일에서 선택", - "couldNotFetchImage": "이미지를 가져올 수 없습니다.", + "couldNotFetchImage": "이미지를 가져올 수 없습니다", "imageSavingFailed": "이미지 저장 실패", "addIcon": "아이콘 추가", + "changeIcon": "아이콘 변경", "coverRemoveAlert": "삭제 후 표지에서 제거됩니다.", - "alertDialogConfirmation": "너 정말 계속하고 싶니?" + "alertDialogConfirmation": "계속하시겠습니까?" }, "mathEquation": { - "addMathEquation": "수학 방정식 추가", + "name": "수학 방정식", + "addMathEquation": "TeX 방정식 추가", "editMathEquation": "수학 방정식 편집" }, "optionAction": { - "click": "딸깍 하는 소리", + "click": "클릭", "toOpenMenu": " 메뉴 열기", + "drag": "드래그", + "toMove": " 이동", "delete": "삭제", - "duplicate": "복제하다", - "turnInto": "로 변하다", - "moveUp": "이동", + "duplicate": "복제", + "turnInto": "변환", + "moveUp": "위로 이동", "moveDown": "아래로 이동", "color": "색상", - "align": "맞추다", + "align": "정렬", "left": "왼쪽", - "center": "센터", + "center": "가운데", "right": "오른쪽", - "defaultColor": "기본" + "defaultColor": "기본", + "depth": "깊이", + "copyLinkToBlock": "블록 링크 복사" }, "image": { - "copiedToPasteBoard": "이미지 링크가 클립보드에 복사되었습니다." + "addAnImage": "이미지 추가", + "copiedToPasteBoard": "이미지 링크가 클립보드에 복사되었습니다", + "addAnImageDesktop": "이미지 추가", + "addAnImageMobile": "하나 이상의 이미지를 추가하려면 클릭", + "dropImageToInsert": "이미지를 드롭하여 삽입", + "imageUploadFailed": "이미지 업로드 실패", + "imageDownloadFailed": "이미지 다운로드 실패, 다시 시도하세요", + "imageDownloadFailedToken": "사용자 토큰이 없어 이미지 다운로드 실패, 다시 시도하세요", + "errorCode": "오류 코드" + }, + "photoGallery": { + "name": "사진 갤러리", + "imageKeyword": "이미지", + "imageGalleryKeyword": "이미지 갤러리", + "photoKeyword": "사진", + "photoBrowserKeyword": "사진 브라우저", + "galleryKeyword": "갤러리", + "addImageTooltip": "이미지 추가", + "changeLayoutTooltip": "레이아웃 변경", + "browserLayout": "브라우저", + "gridLayout": "그리드", + "deleteBlockTooltip": "전체 갤러리 삭제" + }, + "math": { + "copiedToPasteBoard": "수학 방정식이 클립보드에 복사되었습니다" + }, + "urlPreview": { + "copiedToPasteBoard": "링크가 클립보드에 복사되었습니다", + "convertToLink": "링크로 변환" }, "outline": { - "addHeadingToCreateOutline": "제목을 추가하여 목차를 만듭니다." - } + "addHeadingToCreateOutline": "목차를 만들려면 헤딩을 추가하세요.", + "noMatchHeadings": "일치하는 헤딩이 없습니다." + }, + "table": { + "addAfter": "뒤에 추가", + "addBefore": "앞에 추가", + "delete": "삭제", + "clear": "내용 지우기", + "duplicate": "복제", + "bgColor": "배경 색상" + }, + "contextMenu": { + "copy": "복사", + "cut": "잘라내기", + "paste": "붙여넣기", + "pasteAsPlainText": "서식 없이 붙여넣기" + }, + "action": "작업", + "database": { + "selectDataSource": "데이터 소스 선택", + "noDataSource": "데이터 소스 없음", + "selectADataSource": "데이터 소스 선택", + "toContinue": "계속하려면", + "newDatabase": "새 데이터베이스", + "linkToDatabase": "데이터베이스로 연결" + }, + "date": "날짜", + "video": { + "label": "비디오", + "emptyLabel": "비디오 추가", + "placeholder": "비디오 링크 붙여넣기", + "copiedToPasteBoard": "비디오 링크가 클립보드에 복사되었습니다", + "insertVideo": "비디오 추가", + "invalidVideoUrl": "지원되지 않는 소스 URL입니다.", + "invalidVideoUrlYouTube": "YouTube는 아직 지원되지 않습니다.", + "supportedFormats": "지원되는 형식: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "파일", + "uploadTab": "업로드", + "uploadMobile": "파일 선택", + "uploadMobileGallery": "사진 갤러리에서", + "networkTab": "링크 삽입", + "placeholderText": "파일 업로드 또는 삽입", + "placeholderDragging": "업로드할 파일을 드롭하세요", + "dropFileToUpload": "업로드할 파일을 드롭하세요", + "fileUploadHint": "파일을 드래그 앤 드롭하거나 클릭하여 ", + "fileUploadHintSuffix": "찾아보기", + "networkHint": "파일 링크 붙여넣기", + "networkUrlInvalid": "잘못된 URL입니다. URL을 확인하고 다시 시도하세요.", + "networkAction": "삽입", + "fileTooBigError": "파일 크기가 너무 큽니다. 10MB 미만의 파일을 업로드하세요", + "renameFile": { + "title": "파일 이름 변경", + "description": "이 파일의 새 이름을 입력하세요", + "nameEmptyError": "파일 이름은 비워둘 수 없습니다." + }, + "uploadedAt": "{}에 업로드됨", + "linkedAt": "{}에 링크 추가됨", + "failedToOpenMsg": "열지 못했습니다. 파일을 찾을 수 없습니다" + }, + "subPage": { + "handlingPasteHint": " - (붙여넣기 처리 중)", + "errors": { + "failedDeletePage": "페이지 삭제 실패", + "failedCreatePage": "페이지 생성 실패", + "failedMovePage": "이 문서로 페이지 이동 실패", + "failedDuplicatePage": "페이지 복제 실패", + "failedDuplicateFindView": "페이지 복제 실패 - 원본 보기를 찾을 수 없습니다" + } + }, + "cannotMoveToItsChildren": "자식으로 이동할 수 없습니다" + }, + "outlineBlock": { + "placeholder": "목차" }, "textBlock": { - "placeholder": "명령에 '/' 입력" + "placeholder": "명령어를 입력하려면 '/'를 입력하세요" }, "title": { - "placeholder": "무제" + "placeholder": "제목 없음" }, "imageBlock": { - "placeholder": "이미지를 추가하려면 클릭하세요.", + "placeholder": "이미지 추가하려면 클릭", "upload": { "label": "업로드", - "placeholder": "이미지를 업로드하려면 클릭하세요." + "placeholder": "이미지 업로드하려면 클릭" }, "url": { "label": "이미지 URL", "placeholder": "이미지 URL 입력" }, + "ai": { + "label": "AI로 이미지 생성", + "placeholder": "AI가 이미지를 생성할 프롬프트를 입력하세요" + }, + "stability_ai": { + "label": "Stability AI로 이미지 생성", + "placeholder": "Stability AI가 이미지를 생성할 프롬프트를 입력하세요" + }, "support": "이미지 크기 제한은 5MB입니다. 지원되는 형식: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "잘못된 이미지", - "invalidImageSize": "이미지 크기는 5MB 미만이어야 합니다.", - "invalidImageFormat": "이미지 형식은 지원되지 않습니다. 지원되는 형식: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "잘못된 이미지 URL" + "invalidImageSize": "이미지 크기는 5MB 미만이어야 합니다", + "invalidImageFormat": "지원되지 않는 이미지 형식입니다. 지원되는 형식: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "잘못된 이미지 URL", + "noImage": "파일 또는 디렉토리가 없습니다", + "multipleImagesFailed": "하나 이상의 이미지 업로드 실패, 다시 시도하세요" + }, + "embedLink": { + "label": "링크 삽입", + "placeholder": "이미지 링크를 붙여넣거나 입력하세요" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "이미지 검색", + "pleaseInputYourOpenAIKey": "설정 페이지에서 AI 키를 입력하세요", + "saveImageToGallery": "이미지 저장", + "failedToAddImageToGallery": "이미지 저장 실패", + "successToAddImageToGallery": "이미지가 사진에 저장되었습니다", + "unableToLoadImage": "이미지를 로드할 수 없습니다", + "maximumImageSize": "최대 지원 업로드 이미지 크기는 10MB입니다", + "uploadImageErrorImageSizeTooBig": "이미지 크기는 10MB 미만이어야 합니다", + "imageIsUploading": "이미지 업로드 중", + "openFullScreen": "전체 화면으로 열기", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "이전 이미지", + "nextImageTooltip": "다음 이미지", + "zoomOutTooltip": "축소", + "zoomInTooltip": "확대", + "changeZoomLevelTooltip": "확대/축소 수준 변경", + "openLocalImage": "이미지 열기", + "downloadImage": "이미지 다운로드", + "closeViewer": "인터랙티브 뷰어 닫기", + "scalePercentage": "{}%", + "deleteImageTooltip": "이미지 삭제" + } } }, "codeBlock": { "language": { "label": "언어", - "placeholder": "언어 선택" - } + "placeholder": "언어 선택", + "auto": "자동" + }, + "copyTooltip": "복사", + "searchLanguageHint": "언어 검색", + "codeCopiedSnackbar": "코드가 클립보드에 복사되었습니다!" }, "inlineLink": { - "placeholder": "링크 붙여넣기 또는 입력", + "placeholder": "링크를 붙여넣거나 입력하세요", + "openInNewTab": "새 탭에서 열기", + "copyLink": "링크 복사", + "removeLink": "링크 제거", "url": { "label": "링크 URL", "placeholder": "링크 URL 입력" @@ -602,89 +2087,1101 @@ "label": "링크 제목", "placeholder": "링크 제목 입력" } + }, + "mention": { + "placeholder": "사람, 페이지 또는 날짜 언급...", + "page": { + "label": "페이지로 연결", + "tooltip": "페이지 열기" + }, + "deleted": "삭제됨", + "deletedContent": "이 콘텐츠는 존재하지 않거나 삭제되었습니다", + "noAccess": "액세스 불가", + "deletedPage": "삭제된 페이지", + "trashHint": " - 휴지통에 있음", + "morePages": "더 많은 페이지" + }, + "toolbar": { + "resetToDefaultFont": "기본값으로 재설정", + "textSize": "텍스트 크기", + "h1": "헤딩 1", + "h2": "헤딩 2", + "h3": "헤딩 3", + "alignLeft": "왼쪽 정렬", + "alignRight": "오른쪽 정렬", + "alignCenter": "가운데 정렬", + "link": "링크", + "textAlign": "텍스트 정렬", + "moreOptions": "더 많은 옵션", + "font": "글꼴", + "suggestions": "제안", + "turnInto": "변환" + }, + "errorBlock": { + "theBlockIsNotSupported": "블록 콘텐츠를 구문 분석할 수 없습니다", + "clickToCopyTheBlockContent": "블록 콘텐츠를 복사하려면 클릭", + "blockContentHasBeenCopied": "블록 콘텐츠가 복사되었습니다.", + "parseError": "{} 블록을 구문 분석하는 동안 오류가 발생했습니다.", + "copyBlockContent": "블록 콘텐츠 복사" + }, + "mobilePageSelector": { + "title": "페이지 선택", + "failedToLoad": "페이지 목록을 로드하지 못했습니다", + "noPagesFound": "페이지를 찾을 수 없습니다" + }, + "attachmentMenu": { + "choosePhoto": "사진 선택", + "takePicture": "사진 찍기", + "chooseFile": "파일 선택" } }, "board": { "column": { - "createNewCard": "추가" + "label": "열", + "createNewCard": "새로 만들기", + "renameGroupTooltip": "그룹 이름 변경", + "createNewColumn": "새 그룹 추가", + "addToColumnTopTooltip": "맨 위에 새 카드 추가", + "addToColumnBottomTooltip": "맨 아래에 새 카드 추가", + "renameColumn": "이름 변경", + "hideColumn": "숨기기", + "newGroup": "새 그룹", + "deleteColumn": "삭제", + "deleteColumnConfirmation": "이 그룹과 그룹 내 모든 카드를 삭제합니다. 계속하시겠습니까?" }, + "hiddenGroupSection": { + "sectionTitle": "숨겨진 그룹", + "collapseTooltip": "숨겨진 그룹 숨기기", + "expandTooltip": "숨겨진 그룹 보기" + }, + "cardDetail": "카드 세부 정보", + "cardActions": "카드 작업", + "cardDuplicated": "카드가 복제되었습니다", + "cardDeleted": "카드가 삭제되었습니다", + "showOnCard": "카드 세부 정보에 표시", + "setting": "설정", + "propertyName": "속성 이름", "menuName": "보드", - "referencedBoardPrefix": "관점", + "showUngrouped": "그룹화되지 않은 항목 표시", + "ungroupedButtonText": "그룹화되지 않음", + "ungroupedButtonTooltip": "어떤 그룹에도 속하지 않는 카드가 포함되어 있습니다", + "ungroupedItemsTitle": "보드에 추가하려면 클릭", + "groupBy": "그룹 기준", + "groupCondition": "그룹 조건", + "referencedBoardPrefix": "보기", + "notesTooltip": "내부에 노트 있음", "mobile": { + "editURL": "URL 편집", "showGroup": "그룹 표시", "showGroupContent": "이 그룹을 보드에 표시하시겠습니까?", - "failedToLoad": "보드 보기를 로드하지 못했습니다." + "failedToLoad": "보드 보기를 로드하지 못했습니다" + }, + "dateCondition": { + "weekOf": "{} - {} 주", + "today": "오늘", + "yesterday": "어제", + "tomorrow": "내일", + "lastSevenDays": "지난 7일", + "nextSevenDays": "다음 7일", + "lastThirtyDays": "지난 30일", + "nextThirtyDays": "다음 30일" + }, + "noGroup": "그룹화할 속성 없음", + "noGroupDesc": "보드 보기를 표시하려면 그룹화할 속성이 필요합니다", + "media": { + "cardText": "{} {}", + "fallbackName": "파일" } }, "calendar": { - "menuName": "달력", - "defaultNewCalendarTitle": "무제", + "menuName": "캘린더", + "defaultNewCalendarTitle": "제목 없음", + "newEventButtonTooltip": "새 이벤트 추가", "navigation": { "today": "오늘", "jumpToday": "오늘로 이동", - "previousMonth": "지난달", - "nextMonth": "다음 달" + "previousMonth": "이전 달", + "nextMonth": "다음 달", + "views": { + "day": "일", + "week": "주", + "month": "월", + "year": "년" + } + }, + "mobileEventScreen": { + "emptyTitle": "이벤트 없음", + "emptyBody": "이 날에 이벤트를 생성하려면 더하기 버튼을 누르세요." }, "settings": { "showWeekNumbers": "주 번호 표시", - "showWeekends": "주말 보기", - "firstDayOfWeek": "주 시작", - "layoutDateField": "레이아웃 캘린더", + "showWeekends": "주말 표시", + "firstDayOfWeek": "주 시작일", + "layoutDateField": "캘린더 레이아웃 기준", + "changeLayoutDateField": "레이아웃 필드 변경", "noDateTitle": "날짜 없음", - "clickToAdd": "캘린더에 추가하려면 클릭하세요.", - "name": "달력 레이아웃", - "noDateHint": "예약되지 않은 일정이 여기에 표시됩니다." + "noDateHint": { + "zero": "일정이 없는 이벤트가 여기에 표시됩니다", + "one": "{count}개의 일정이 없는 이벤트", + "other": "{count}개의 일정이 없는 이벤트" + }, + "unscheduledEventsTitle": "일정이 없는 이벤트", + "clickToAdd": "캘린더에 추가하려면 클릭", + "name": "캘린더 설정", + "clickToOpen": "레코드를 열려면 클릭" }, - "referencedCalendarPrefix": "관점" + "referencedCalendarPrefix": "보기", + "quickJumpYear": "이동", + "duplicateEvent": "이벤트 복제" }, "errorDialog": { "title": "@:appName 오류", - "howToFixFallback": "불편을 끼쳐드려 죄송합니다! 오류를 설명하는 문제를 GitHub 페이지에 제출하세요.", + "howToFixFallback": "불편을 드려 죄송합니다! GitHub 페이지에 오류를 설명하는 문제를 제출하세요.", + "howToFixFallbackHint1": "불편을 드려 죄송합니다! ", + "howToFixFallbackHint2": " 페이지에 오류를 설명하는 문제를 제출하세요.", "github": "GitHub에서 보기" }, "search": { "label": "검색", + "sidebarSearchIcon": "검색하고 페이지로 빠르게 이동", "placeholder": { - "actions": "검색 작업..." + "actions": "작업 검색..." } }, "message": { "copy": { - "success": "복사했습니다!", + "success": "복사됨!", "fail": "복사할 수 없음" } }, - "unSupportBlock": "현재 버전은 이 블록을 지원하지 않습니다.", + "unSupportBlock": "현재 버전에서는 이 블록을 지원하지 않습니다.", "views": { "deleteContentTitle": "{pageType}을(를) 삭제하시겠습니까?", "deleteContentCaption": "이 {pageType}을(를) 삭제하면 휴지통에서 복원할 수 있습니다." }, + "colors": { + "custom": "사용자 정의", + "default": "기본", + "red": "빨간색", + "orange": "주황색", + "yellow": "노란색", + "green": "녹색", + "blue": "파란색", + "purple": "보라색", + "pink": "분홍색", + "brown": "갈색", + "gray": "회색" + }, + "emoji": { + "emojiTab": "이모지", + "search": "이모지 검색", + "noRecent": "최근 사용한 이모지 없음", + "noEmojiFound": "이모지를 찾을 수 없음", + "filter": "필터", + "random": "무작위", + "selectSkinTone": "피부 톤 선택", + "remove": "이모지 제거", + "categories": { + "smileys": "스마일리 및 감정", + "people": "사람", + "animals": "자연", + "food": "음식", + "activities": "활동", + "places": "장소", + "objects": "사물", + "symbols": "기호", + "flags": "깃발", + "nature": "자연", + "frequentlyUsed": "자주 사용됨" + }, + "skinTone": { + "default": "기본", + "light": "밝은", + "mediumLight": "중간 밝은", + "medium": "중간", + "mediumDark": "중간 어두운", + "dark": "어두운" + }, + "openSourceIconsFrom": "오픈 소스 아이콘 제공" + }, + "inlineActions": { + "noResults": "결과 없음", + "recentPages": "최근 페이지", + "pageReference": "페이지 참조", + "docReference": "문서 참조", + "boardReference": "보드 참조", + "calReference": "캘린더 참조", + "gridReference": "그리드 참조", + "date": "날짜", + "reminder": { + "groupTitle": "알림", + "shortKeyword": "알림" + }, + "createPage": "\"{}\" 하위 페이지 생성" + }, + "datePicker": { + "dateTimeFormatTooltip": "설정에서 날짜 및 시간 형식 변경", + "dateFormat": "날짜 형식", + "includeTime": "시간 포함", + "isRange": "종료 날짜", + "timeFormat": "시간 형식", + "clearDate": "날짜 지우기", + "reminderLabel": "알림", + "selectReminder": "알림 선택", + "reminderOptions": { + "none": "없음", + "atTimeOfEvent": "이벤트 시간", + "fiveMinsBefore": "5분 전", + "tenMinsBefore": "10분 전", + "fifteenMinsBefore": "15분 전", + "thirtyMinsBefore": "30분 전", + "oneHourBefore": "1시간 전", + "twoHoursBefore": "2시간 전", + "onDayOfEvent": "이벤트 당일", + "oneDayBefore": "1일 전", + "twoDaysBefore": "2일 전", + "oneWeekBefore": "1주일 전", + "custom": "사용자 정의" + } + }, + "relativeDates": { + "yesterday": "어제", + "today": "오늘", + "tomorrow": "내일", + "oneWeek": "1주일" + }, + "notificationHub": { + "title": "알림", + "mobile": { + "title": "업데이트" + }, + "emptyTitle": "모두 확인했습니다!", + "emptyBody": "대기 중인 알림이나 작업이 없습니다. 평온을 즐기세요.", + "tabs": { + "inbox": "받은 편지함", + "upcoming": "다가오는" + }, + "actions": { + "markAllRead": "모두 읽음으로 표시", + "showAll": "모두", + "showUnreads": "읽지 않음" + }, + "filters": { + "ascending": "오름차순", + "descending": "내림차순", + "groupByDate": "날짜별 그룹", + "showUnreadsOnly": "읽지 않은 항목만 표시", + "resetToDefault": "기본값으로 재설정" + } + }, + "reminderNotification": { + "title": "알림", + "message": "잊기 전에 확인하세요!", + "tooltipDelete": "삭제", + "tooltipMarkRead": "읽음으로 표시", + "tooltipMarkUnread": "읽지 않음으로 표시" + }, + "findAndReplace": { + "find": "찾기", + "previousMatch": "이전 일치 항목", + "nextMatch": "다음 일치 항목", + "close": "닫기", + "replace": "교체", + "replaceAll": "모두 교체", + "noResult": "결과 없음", + "caseSensitive": "대소문자 구분", + "searchMore": "더 많은 결과를 찾으려면 검색" + }, + "error": { + "weAreSorry": "죄송합니다", + "loadingViewError": "이 보기를 로드하는 데 문제가 있습니다. 인터넷 연결을 확인하고 앱을 새로 고침하세요. 문제가 계속되면 팀에 문의하세요.", + "syncError": "다른 장치에서 데이터가 동기화되지 않음", + "syncErrorHint": "마지막으로 편집한 장치에서 이 페이지를 다시 열고 현재 장치에서 다시 열어보세요.", + "clickToCopy": "오류 코드를 복사하려면 클릭" + }, + "editor": { + "bold": "굵게", + "bulletedList": "글머리 기호 목록", + "bulletedListShortForm": "글머리 기호", + "checkbox": "체크박스", + "embedCode": "코드 삽입", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "강조", + "color": "색상", + "image": "이미지", + "date": "날짜", + "page": "페이지", + "italic": "기울임꼴", + "link": "링크", + "numberedList": "번호 매기기 목록", + "numberedListShortForm": "번호 매기기", + "toggleHeading1ShortForm": "토글 h1", + "toggleHeading2ShortForm": "토글 h2", + "toggleHeading3ShortForm": "토글 h3", + "quote": "인용", + "strikethrough": "취소선", + "text": "텍스트", + "underline": "밑줄", + "fontColorDefault": "기본", + "fontColorGray": "회색", + "fontColorBrown": "갈색", + "fontColorOrange": "주황색", + "fontColorYellow": "노란색", + "fontColorGreen": "녹색", + "fontColorBlue": "파란색", + "fontColorPurple": "보라색", + "fontColorPink": "분홍색", + "fontColorRed": "빨간색", + "backgroundColorDefault": "기본 배경", + "backgroundColorGray": "회색 배경", + "backgroundColorBrown": "갈색 배경", + "backgroundColorOrange": "주황색 배경", + "backgroundColorYellow": "노란색 배경", + "backgroundColorGreen": "녹색 배경", + "backgroundColorBlue": "파란색 배경", + "backgroundColorPurple": "보라색 배경", + "backgroundColorPink": "분홍색 배경", + "backgroundColorRed": "빨간색 배경", + "backgroundColorLime": "라임색 배경", + "backgroundColorAqua": "청록색 배경", + "done": "완료", + "cancel": "취소", + "tint1": "색조 1", + "tint2": "색조 2", + "tint3": "색조 3", + "tint4": "색조 4", + "tint5": "색조 5", + "tint6": "색조 6", + "tint7": "색조 7", + "tint8": "색조 8", + "tint9": "색조 9", + "lightLightTint1": "보라색", + "lightLightTint2": "분홍색", + "lightLightTint3": "연분홍색", + "lightLightTint4": "주황색", + "lightLightTint5": "노란색", + "lightLightTint6": "라임색", + "lightLightTint7": "녹색", + "lightLightTint8": "청록색", + "lightLightTint9": "파란색", + "urlHint": "URL", + "mobileHeading1": "헤딩 1", + "mobileHeading2": "헤딩 2", + "mobileHeading3": "헤딩 3", + "mobileHeading4": "헤딩 4", + "mobileHeading5": "헤딩 5", + "mobileHeading6": "헤딩 6", + "textColor": "텍스트 색상", + "backgroundColor": "배경 색상", + "addYourLink": "링크 추가", + "openLink": "링크 열기", + "copyLink": "링크 복사", + "removeLink": "링크 제거", + "editLink": "링크 편집", + "linkText": "텍스트", + "linkTextHint": "텍스트를 입력하세요", + "linkAddressHint": "URL을 입력하세요", + "highlightColor": "강조 색상", + "clearHighlightColor": "강조 색상 지우기", + "customColor": "사용자 정의 색상", + "hexValue": "16진수 값", + "opacity": "불투명도", + "resetToDefaultColor": "기본 색상으로 재설정", + "ltr": "LTR", + "rtl": "RTL", + "auto": "자동", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "find": "찾기", + "select": "선택", + "selectAll": "모두 선택", + "previousMatch": "이전 일치 항목", + "nextMatch": "다음 일치 항목", + "closeFind": "닫기", + "replace": "교체", + "replaceAll": "모두 교체", + "regex": "정규식", + "caseSensitive": "대소문자 구분", + "uploadImage": "이미지 업로드", + "urlImage": "URL 이미지", + "incorrectLink": "잘못된 링크", + "upload": "업로드", + "chooseImage": "이미지 선택", + "loading": "로드 중", + "imageLoadFailed": "이미지 로드 실패", + "divider": "구분선", + "table": "테이블", + "colAddBefore": "앞에 추가", + "rowAddBefore": "앞에 추가", + "colAddAfter": "뒤에 추가", + "rowAddAfter": "뒤에 추가", + "colRemove": "제거", + "rowRemove": "제거", + "colDuplicate": "복제", + "rowDuplicate": "복제", + "colClear": "내용 지우기", + "rowClear": "내용 지우기", + "slashPlaceHolder": "'/'를 입력하여 블록을 삽입하거나 입력 시작", + "typeSomething": "무언가 입력...", + "toggleListShortForm": "토글", + "quoteListShortForm": "인용", + "mathEquationShortForm": "수식", + "codeBlockShortForm": "코드" + }, + "favorite": { + "noFavorite": "즐겨찾기 페이지 없음", + "noFavoriteHintText": "페이지를 왼쪽으로 스와이프하여 즐겨찾기에 추가하세요", + "removeFromSidebar": "사이드바에서 제거", + "addToSidebar": "사이드바에 고정" + }, + "cardDetails": { + "notesPlaceholder": "/를 입력하여 블록을 삽입하거나 입력 시작" + }, + "blockPlaceholders": { + "todoList": "할 일", + "bulletList": "목록", + "numberList": "목록", + "quote": "인용", + "heading": "헤딩 {}" + }, + "titleBar": { + "pageIcon": "페이지 아이콘", + "language": "언어", + "font": "글꼴", + "actions": "작업", + "date": "날짜", + "addField": "필드 추가", + "userIcon": "사용자 아이콘" + }, + "noLogFiles": "로그 파일이 없습니다", "newSettings": { "myAccount": { + "title": "내 계정", + "subtitle": "프로필을 사용자 정의하고, 계정 보안을 관리하고, AI 키를 열거나 계정에 로그인하세요.", + "profileLabel": "계정 이름 및 프로필 이미지", + "profileNamePlaceholder": "이름 입력", + "accountSecurity": "계정 보안", + "2FA": "2단계 인증", + "aiKeys": "AI 키", + "accountLogin": "계정 로그인", + "updateNameError": "이름 업데이트 실패", + "updateIconError": "아이콘 업데이트 실패", + "aboutAppFlowy": "@:appName 정보", "deleteAccount": { - "dialogContent2": "이 작업은 실행 취소할 수 없으며 모든 작업 공간에서의 액세스가 제거되고, 개인 작업 공간을 포함한 전체 계정이 삭제되고, 모든 공유 작업 공간에서 제거됩니다." + "title": "계정 삭제", + "subtitle": "계정과 모든 데이터를 영구적으로 삭제합니다.", + "description": "계정을 영구적으로 삭제하고 모든 작업 공간에서 액세스를 제거합니다.", + "deleteMyAccount": "내 계정 삭제", + "dialogTitle": "계정 삭제", + "dialogContent1": "계정을 영구적으로 삭제하시겠습니까?", + "dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.", + "confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.", + "confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.", + "confirmHint3": "내 계정 삭제", + "checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다", + "failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다", + "confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다", + "deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다" } + }, + "workplace": { + "name": "작업 공간", + "title": "작업 공간 설정", + "subtitle": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", + "workplaceName": "작업 공간 이름", + "workplaceNamePlaceholder": "작업 공간 이름 입력", + "workplaceIcon": "작업 공간 아이콘", + "workplaceIconSubtitle": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다.", + "renameError": "작업 공간 이름 변경 실패", + "updateIconError": "아이콘 업데이트 실패", + "chooseAnIcon": "아이콘 선택", + "appearance": { + "name": "외관", + "themeMode": { + "auto": "자동", + "light": "라이트", + "dark": "다크" + }, + "language": "언어" + } + }, + "syncState": { + "syncing": "동기화 중", + "synced": "동기화됨", + "noNetworkConnected": "네트워크에 연결되지 않음" } }, + "pageStyle": { + "title": "페이지 스타일", + "layout": "레이아웃", + "coverImage": "표지 이미지", + "pageIcon": "페이지 아이콘", + "colors": "색상", + "gradient": "그라데이션", + "backgroundImage": "배경 이미지", + "presets": "프리셋", + "photo": "사진", + "unsplash": "Unsplash", + "pageCover": "페이지 표지", + "none": "없음", + "openSettings": "설정 열기", + "photoPermissionTitle": "@:appName가 사진 라이브러리에 접근하려고 합니다", + "photoPermissionDescription": "@:appName가 문서에 이미지를 추가할 수 있도록 사진에 접근해야 합니다", + "cameraPermissionTitle": "@:appName가 카메라에 접근하려고 합니다", + "cameraPermissionDescription": "카메라에서 문서에 이미지를 추가하려면 @:appName가 카메라에 액세스할 수 있어야 합니다.", + "doNotAllow": "허용하지 않음", + "image": "이미지" + }, + "commandPalette": { + "placeholder": "검색하거나 질문하세요...", + "bestMatches": "최고의 일치 항목", + "recentHistory": "최근 기록", + "navigateHint": "탐색하려면", + "loadingTooltip": "결과를 찾고 있습니다...", + "betaLabel": "베타", + "betaTooltip": "현재 문서의 페이지 및 콘텐츠 검색만 지원합니다", + "fromTrashHint": "휴지통에서", + "noResultsHint": "찾고 있는 항목을 찾지 못했습니다. 다른 용어로 검색해 보세요.", + "clearSearchTooltip": "검색 필드 지우기" + }, "space": { - "createNewSpace": "새로운 스페이스 생성", - "defaultSpaceName": "일반" + "delete": "삭제", + "deleteConfirmation": "삭제: ", + "deleteConfirmationDescription": "이 공간 내의 모든 페이지가 삭제되어 휴지통으로 이동되며, 게시한 모든 페이지가 게시 취소됩니다.", + "rename": "공간 이름 변경", + "changeIcon": "아이콘 변경", + "manage": "공간 관리", + "addNewSpace": "새 공간 생성", + "collapseAllSubPages": "모든 하위 페이지 접기", + "createNewSpace": "새 공간 생성", + "createSpaceDescription": "작업을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", + "spaceName": "공간 이름", + "spaceNamePlaceholder": "예: 마케팅, 엔지니어링, 인사", + "permission": "공간 권한", + "publicPermission": "공용", + "publicPermissionDescription": "전체 액세스 권한이 있는 모든 작업 공간 멤버", + "privatePermission": "비공개", + "privatePermissionDescription": "이 공간에 접근할 수 있는 사람은 본인뿐입니다", + "spaceIconBackground": "배경 색상", + "spaceIcon": "아이콘", + "dangerZone": "위험 구역", + "unableToDeleteLastSpace": "마지막 공간을 삭제할 수 없습니다", + "unableToDeleteSpaceNotCreatedByYou": "다른 사람이 생성한 공간을 삭제할 수 없습니다", + "enableSpacesForYourWorkspace": "작업 공간에 공간을 활성화하세요", + "title": "공간", + "defaultSpaceName": "일반", + "upgradeSpaceTitle": "공간 활성화", + "upgradeSpaceDescription": "작업 공간을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", + "upgrade": "업데이트", + "upgradeYourSpace": "여러 공간 생성", + "quicklySwitch": "다음 공간으로 빠르게 전환", + "duplicate": "공간 복제", + "movePageToSpace": "페이지를 공간으로 이동", + "cannotMovePageToDatabase": "페이지를 데이터베이스로 이동할 수 없습니다", + "switchSpace": "공간 전환", + "spaceNameCannotBeEmpty": "공간 이름은 비워둘 수 없습니다", + "success": { + "deleteSpace": "공간이 성공적으로 삭제되었습니다", + "renameSpace": "공간 이름이 성공적으로 변경되었습니다", + "duplicateSpace": "공간이 성공적으로 복제되었습니다", + "updateSpace": "공간이 성공적으로 업데이트되었습니다" + }, + "error": { + "deleteSpace": "공간 삭제 실패", + "renameSpace": "공간 이름 변경 실패", + "duplicateSpace": "공간 복제 실패", + "updateSpace": "공간 업데이트 실패" + }, + "createSpace": "공간 생성", + "manageSpace": "공간 관리", + "renameSpace": "공간 이름 변경", + "mSpaceIconColor": "공간 아이콘 색상", + "mSpaceIcon": "공간 아이콘" }, "publish": { - "saveThisPage": "이 템플릿으로 시작" + "hasNotBeenPublished": "이 페이지는 아직 게시되지 않았습니다", + "spaceHasNotBeenPublished": "아직 공간 게시를 지원하지 않습니다", + "reportPage": "페이지 신고", + "databaseHasNotBeenPublished": "데이터베이스 게시를 아직 지원하지 않습니다.", + "createdWith": "제작", + "downloadApp": "AppFlowy 다운로드", + "copy": { + "codeBlock": "코드 블록의 내용이 클립보드에 복사되었습니다", + "imageBlock": "이미지 링크가 클립보드에 복사되었습니다", + "mathBlock": "수학 방정식이 클립보드에 복사되었습니다", + "fileBlock": "파일 링크가 클립보드에 복사되었습니다" + }, + "containsPublishedPage": "이 페이지에는 하나 이상의 게시된 페이지가 포함되어 있습니다. 계속하면 게시가 취소됩니다. 삭제를 진행하시겠습니까?", + "publishSuccessfully": "성공적으로 게시되었습니다", + "unpublishSuccessfully": "성공적으로 게시 취소되었습니다", + "publishFailed": "게시 실패", + "unpublishFailed": "게시 취소 실패", + "noAccessToVisit": "이 페이지에 접근할 수 없습니다...", + "createWithAppFlowy": "AppFlowy로 웹사이트 만들기", + "fastWithAI": "AI로 빠르고 쉽게.", + "tryItNow": "지금 시도해보세요", + "onlyGridViewCanBePublished": "그리드 보기만 게시할 수 있습니다", + "database": { + "zero": "선택한 {} 보기 게시", + "one": "선택한 {} 보기 게시", + "many": "선택한 {} 보기 게시", + "other": "선택한 {} 보기 게시" + }, + "mustSelectPrimaryDatabase": "기본 보기를 선택해야 합니다", + "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 최소 하나의 데이터베이스를 선택하세요.", + "unableToDeselectPrimaryDatabase": "기본 보기를 선택 해제할 수 없습니다", + "saveThisPage": "이 템플릿으로 시작", + "duplicateTitle": "추가할 위치 선택", + "selectWorkspace": "작업 공간 선택", + "addTo": "추가", + "duplicateSuccessfully": "작업 공간에 추가되었습니다", + "duplicateSuccessfullyDescription": "AppFlowy가 설치되어 있지 않습니까? '다운로드'를 클릭하면 다운로드가 자동으로 시작됩니다.", + "downloadIt": "다운로드", + "openApp": "앱에서 열기", + "duplicateFailed": "복제 실패", + "membersCount": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "useThisTemplate": "템플릿 사용" + }, + "web": { + "continue": "계속", + "or": "또는", + "continueWithGoogle": "Google로 계속", + "continueWithGithub": "GitHub로 계속", + "continueWithDiscord": "Discord로 계속", + "continueWithApple": "Apple로 계속", + "moreOptions": "더 많은 옵션", + "collapse": "접기", + "signInAgreement": "\"계속\"을 클릭하면 AppFlowy의", + "and": "및", + "termOfUse": "이용 약관", + "privacyPolicy": "개인정보 보호정책", + "signInError": "로그인 오류", + "login": "가입 또는 로그인", + "fileBlock": { + "uploadedAt": "{time}에 업로드됨", + "linkedAt": "{time}에 링크 추가됨", + "empty": "파일 업로드 또는 삽입", + "uploadFailed": "업로드 실패, 다시 시도하세요", + "retry": "다시 시도" + }, + "importNotion": "Notion에서 가져오기", + "import": "가져오기", + "importSuccess": "성공적으로 업로드되었습니다", + "importSuccessMessage": "가져오기가 완료되면 알림을 받게 됩니다. 이후 사이드바에서 가져온 페이지를 확인할 수 있습니다.", + "importFailed": "가져오기 실패, 파일 형식을 확인하세요", + "dropNotionFile": "Notion zip 파일을 여기에 드롭하여 업로드하거나 클릭하여 찾아보기", + "error": { + "pageNameIsEmpty": "페이지 이름이 비어 있습니다. 다른 이름을 시도하세요" + } + }, + "globalComment": { + "comments": "댓글", + "addComment": "댓글 추가", + "reactedBy": "반응한 사람", + "addReaction": "반응 추가", + "reactedByMore": "및 {count}명", + "showSeconds": { + "one": "1초 전", + "other": "{count}초 전", + "zero": "방금", + "many": "{count}초 전" + }, + "showMinutes": { + "one": "1분 전", + "other": "{count}분 전", + "many": "{count}분 전" + }, + "showHours": { + "one": "1시간 전", + "other": "{count}시간 전", + "many": "{count}시간 전" + }, + "showDays": { + "one": "1일 전", + "other": "{count}일 전", + "many": "{count}일 전" + }, + "showMonths": { + "one": "1개월 전", + "other": "{count}개월 전", + "many": "{count}개월 전" + }, + "showYears": { + "one": "1년 전", + "other": "{count}년 전", + "many": "{count}년 전" + }, + "reply": "답글", + "deleteComment": "댓글 삭제", + "youAreNotOwner": "이 댓글의 소유자가 아닙니다", + "confirmDeleteDescription": "이 댓글을 삭제하시겠습니까?", + "hasBeenDeleted": "삭제됨", + "replyingTo": "답글 대상", + "noAccessDeleteComment": "이 댓글을 삭제할 수 없습니다", + "collapse": "접기", + "readMore": "더 읽기", + "failedToAddComment": "댓글 추가 실패", + "commentAddedSuccessfully": "댓글이 성공적으로 추가되었습니다.", + "commentAddedSuccessTip": "댓글을 추가하거나 답글을 달았습니다. 최신 댓글을 보려면 상단으로 이동하시겠습니까?" }, "template": { - "deleteFromTemplate": "템플릿 목록에서 제거", - "relatedTemplates": "관련된 템플릿 목록", - "deleteTemplate": "템플릿 제거", - "removeRelatedTemplate": "관련된 템플릿 제거", - "label": "템플릿 목록" + "asTemplate": "템플릿으로 저장", + "name": "템플릿 이름", + "description": "템플릿 설명", + "about": "템플릿 정보", + "deleteFromTemplate": "템플릿에서 삭제", + "preview": "템플릿 미리보기", + "categories": "템플릿 카테고리", + "isNewTemplate": "새 템플릿에 고정", + "featured": "추천에 고정", + "relatedTemplates": "관련 템플릿", + "requiredField": "{field}은(는) 필수 항목입니다", + "addCategory": "\"{category}\" 추가", + "addNewCategory": "새 카테고리 추가", + "addNewCreator": "새 제작자 추가", + "deleteCategory": "카테고리 삭제", + "editCategory": "카테고리 편집", + "editCreator": "제작자 편집", + "category": { + "name": "카테고리 이름", + "icon": "카테고리 아이콘", + "bgColor": "카테고리 배경 색상", + "priority": "카테고리 우선순위", + "desc": "카테고리 설명", + "type": "카테고리 유형", + "icons": "카테고리 아이콘", + "colors": "카테고리 색상", + "byUseCase": "사용 사례별", + "byFeature": "기능별", + "deleteCategory": "카테고리 삭제", + "deleteCategoryDescription": "이 카테고리를 삭제하시겠습니까?", + "typeToSearch": "카테고리 검색..." + }, + "creator": { + "label": "템플릿 제작자", + "name": "제작자 이름", + "avatar": "제작자 아바타", + "accountLinks": "제작자 계정 링크", + "uploadAvatar": "아바타 업로드", + "deleteCreator": "제작자 삭제", + "deleteCreatorDescription": "이 제작자를 삭제하시겠습니까?", + "typeToSearch": "제작자 검색..." + }, + "uploadSuccess": "템플릿이 성공적으로 업로드되었습니다", + "uploadSuccessDescription": "템플릿이 성공적으로 업로드되었습니다. 이제 템플릿 갤러리에서 확인할 수 있습니다.", + "viewTemplate": "템플릿 보기", + "deleteTemplate": "템플릿 삭제", + "deleteSuccess": "템플릿이 성공적으로 삭제되었습니다", + "deleteTemplateDescription": "현재 페이지나 게시 상태에는 영향을 미치지 않습니다. 이 템플릿을 삭제하시겠습니까?", + "addRelatedTemplate": "관련 템플릿 추가", + "removeRelatedTemplate": "관련 템플릿 제거", + "uploadAvatar": "아바타 업로드", + "searchInCategory": "{category}에서 검색", + "label": "템플릿" + }, + "fileDropzone": { + "dropFile": "업로드하려면 파일을 클릭하거나 드래그하세요", + "uploading": "업로드 중...", + "uploadFailed": "업로드 실패", + "uploadSuccess": "업로드 성공", + "uploadSuccessDescription": "파일이 성공적으로 업로드되었습니다", + "uploadFailedDescription": "파일 업로드 실패", + "uploadingDescription": "파일이 업로드 중입니다" + }, + "gallery": { + "preview": "전체 화면으로 열기", + "copy": "복사", + "download": "다운로드", + "prev": "이전", + "next": "다음", + "resetZoom": "확대/축소 재설정", + "zoomIn": "확대", + "zoomOut": "축소" + }, + "invitation": { + "join": "참여", + "on": "에", + "invitedBy": "초대자", + "membersCount": { + "zero": "{count}명의 멤버", + "one": "{count}명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "tip": "아래 연락처 정보로 이 작업 공간에 참여하도록 초대되었습니다. 정보가 잘못된 경우 관리자에게 연락하여 초대를 다시 보내달라고 요청하세요.", + "joinWorkspace": "작업 공간 참여", + "success": "작업 공간에 성공적으로 참여했습니다", + "successMessage": "이제 작업 공간 내의 모든 페이지와 작업 공간에 접근할 수 있습니다.", + "openWorkspace": "AppFlowy 열기", + "alreadyAccepted": "이미 초대를 수락했습니다", + "errorModal": { + "title": "문제가 발생했습니다", + "description": "현재 계정 {email}이(가) 이 작업 공간에 접근할 수 없을 수 있습니다. 올바른 계정으로 로그인하거나 작업 공간 소유자에게 도움을 요청하세요.", + "contactOwner": "소유자에게 연락", + "close": "홈으로 돌아가기", + "changeAccount": "계정 변경" + } + }, + "requestAccess": { + "title": "이 페이지에 접근할 수 없습니다", + "subtitle": "이 페이지의 소유자에게 접근을 요청할 수 있습니다. 승인되면 페이지를 볼 수 있습니다.", + "requestAccess": "접근 요청", + "backToHome": "홈으로 돌아가기", + "tip": "현재 로 로그인 중입니다.", + "mightBe": "다른 계정으로 해야 할 수 있습니다.", + "successful": "요청이 성공적으로 전송되었습니다", + "successfulMessage": "소유자가 요청을 승인하면 알림을 받게 됩니다.", + "requestError": "접근 요청 실패", + "repeatRequestError": "이미 이 페이지에 접근을 요청했습니다" + }, + "approveAccess": { + "title": "작업 공간 참여 요청 승인", + "requestSummary": "이(가) 에 참여하고 에 접근하려고 요청합니다", + "upgrade": "업그레이드", + "downloadApp": "AppFlowy 다운로드", + "approveButton": "승인", + "approveSuccess": "성공적으로 승인되었습니다", + "approveError": "승인 실패, 작업 공간 플랜 한도를 초과하지 않았는지 확인하세요", + "getRequestInfoError": "요청 정보를 가져오지 못했습니다", + "memberCount": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "alreadyProTitle": "작업 공간 플랜 한도에 도달했습니다", + "alreadyProMessage": "에 연락하여 더 많은 멤버를 잠금 해제하도록 요청하세요", + "repeatApproveError": "이미 이 요청을 승인했습니다", + "ensurePlanLimit": "작업 공간 플랜 한도를 초과하지 않았는지 확인하세요. 한도를 초과한 경우 작업 공간 플랜을 하거나 를 고려하세요.", + "requestToJoin": "참여 요청", + "asMember": "멤버로" + }, + "upgradePlanModal": { + "title": "Pro로 업그레이드", + "message": "{name}이(가) 무료 멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 Pro 플랜으로 업그레이드하세요.", + "upgradeSteps": "AppFlowy에서 플랜을 업그레이드하는 방법:", + "step1": "1. 설정으로 이동", + "step2": "2. '플랜' 클릭", + "step3": "3. '플랜 변경' 선택", + "appNote": "참고: ", + "actionButton": "업그레이드", + "downloadLink": "앱 다운로드", + "laterButton": "나중에", + "refreshNote": "성공적으로 업그레이드한 후 새 기능을 활성화하려면 를 클릭하세요.", + "refresh": "여기" + }, + "breadcrumbs": { + "label": "탐색 경로" + }, + "time": { + "justNow": "방금", + "seconds": { + "one": "1초", + "other": "{count}초" + }, + "minutes": { + "one": "1분", + "other": "{count}분" + }, + "hours": { + "one": "1시간", + "other": "{count}시간" + }, + "days": { + "one": "1일", + "other": "{count}일" + }, + "weeks": { + "one": "1주일", + "other": "{count}주일" + }, + "months": { + "one": "1개월", + "other": "{count}개월" + }, + "years": { + "one": "1년", + "other": "{count}년" + }, + "ago": "전", + "yesterday": "어제", + "today": "오늘" + }, + "members": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "tabMenu": { + "close": "닫기", + "closeDisabledHint": "고정된 탭은 닫을 수 없습니다. 먼저 고정을 해제하세요", + "closeOthers": "다른 탭 닫기", + "closeOthersHint": "이 탭을 제외한 모든 고정되지 않은 탭을 닫습니다", + "closeOthersDisabledHint": "모든 탭이 고정되어 있어 닫을 탭을 찾을 수 없습니다", + "favorite": "즐겨찾기", + "unfavorite": "즐겨찾기 해제", + "favoriteDisabledHint": "이 보기를 즐겨찾기에 추가할 수 없습니다", + "pinTab": "고정", + "unpinTab": "고정 해제" + }, + "openFileMessage": { + "success": "파일이 성공적으로 열렸습니다", + "fileNotFound": "파일을 찾을 수 없습니다", + "noAppToOpenFile": "이 파일을 열 수 있는 앱이 없습니다", + "permissionDenied": "이 파일을 열 수 있는 권한이 없습니다", + "unknownError": "파일 열기 실패" + }, + "inviteMember": { + "requestInviteMembers": "작업 공간에 초대", + "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. ", + "upgrade": "업그레이드", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "초대 보내기", + "inviteAlready": "이미 이 이메일을 초대했습니다: {email}", + "inviteSuccess": "초대가 성공적으로 전송되었습니다", + "description": "아래에 이메일을 쉼표로 구분하여 입력하세요. 멤버 수에 따라 요금이 부과됩니다.", + "emails": "이메일" + }, + "quickNote": { + "label": "빠른 노트", + "quickNotes": "빠른 노트", + "search": "빠른 노트 검색", + "collapseFullView": "전체 보기 축소", + "expandFullView": "전체 보기 확장", + "createFailed": "빠른 노트 생성 실패", + "quickNotesEmpty": "빠른 노트 없음", + "emptyNote": "빈 노트", + "deleteNotePrompt": "선택한 노트가 영구적으로 삭제됩니다. 삭제하시겠습니까?", + "addNote": "새 노트", + "noAdditionalText": "추가 텍스트 없음" }, "subscribe": { + "upgradePlanTitle": "플랜 비교 및 선택", + "yearly": "연간", + "save": "{discount}% 절약", + "monthly": "월별", + "priceIn": "가격 ", + "free": "무료", + "pro": "Pro", + "freeDescription": "모든 것을 정리하기 위한 최대 2명의 개인용", + "proDescription": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", + "proDuration": { + "monthly": "월별 청구되는 멤버당 월별", + "yearly": "연간 청구되는 멤버당 월별" + }, + "cancel": "다운그레이드", + "changePlan": "Pro 플랜으로 업그레이드", + "everythingInFree": "무료 플랜의 모든 기능 +", + "currentPlan": "현재", + "freeDuration": "영원히", + "freePoints": { + "first": "최대 2명의 협업 작업 공간", + "second": "무제한 페이지 및 블록", + "three": "5 GB 저장 공간", + "four": "지능형 검색", + "five": "20 AI 응답", + "six": "모바일 앱", + "seven": "실시간 협업" + }, + "proPoints": { + "first": "무제한 저장 공간", + "second": "최대 10명의 작업 공간 멤버", + "three": "무제한 AI 응답", + "four": "무제한 파일 업로드", + "five": "맞춤 네임스페이스" + }, "cancelPlan": { + "title": "떠나셔서 아쉽습니다", + "success": "구독이 성공적으로 취소되었습니다", + "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", + "commonOther": "기타", + "otherHint": "여기에 답변을 작성하세요", + "questionOne": { + "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", + "answerOne": "비용이 너무 높음", + "answerTwo": "기능이 기대에 미치지 못함", + "answerThree": "더 나은 대안을 찾음", + "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", + "answerFive": "서비스 문제 또는 기술적 어려움" + }, + "questionTwo": { + "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", + "answerOne": "매우 가능성이 높음", + "answerTwo": "어느 정도 가능성이 있음", + "answerThree": "잘 모르겠음", + "answerFour": "가능성이 낮음", + "answerFive": "매우 가능성이 낮음" + }, "questionThree": { - "answerFour": "로컬 AI 모델에 대한 액세스" + "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", + "answerOne": "다중 사용자 협업", + "answerTwo": "더 긴 시간 버전 기록", + "answerThree": "무제한 AI 응답", + "answerFour": "로컬 AI 모델 액세스" + }, + "questionFour": { + "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", + "answerOne": "훌륭함", + "answerTwo": "좋음", + "answerThree": "보통", + "answerFour": "평균 이하", + "answerFive": "불만족" } } + }, + "ai": { + "contentPolicyViolation": "민감한 콘텐츠로 인해 이미지 생성에 실패했습니다. 입력을 다시 작성하고 다시 시도하세요", + "textLimitReachedDescription": "작업 공간의 무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "imageLimitReachedDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "limitReachedAction": { + "textDescription": "작업 공간의 무료 AI 응답이 부족합니다. 더 많은 응답을 받으려면 ", + "imageDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. ", + "upgrade": "업그레이드", + "toThe": " ", + "proPlan": "Pro 플랜", + "orPurchaseAn": " 또는 ", + "aiAddon": "AI 애드온을 구매하세요" + }, + "editing": "편집 중", + "analyzing": "분석 중", + "continueWritingEmptyDocumentTitle": "계속 작성 오류", + "continueWritingEmptyDocumentDescription": "문서의 내용을 확장하는 데 문제가 있습니다. 간단한 소개를 작성하면 나머지는 우리가 처리할 수 있습니다!" + }, + "autoUpdate": { + "criticalUpdateTitle": "계속하려면 업데이트가 필요합니다", + "criticalUpdateDescription": "경험을 향상시키기 위해 개선 사항을 추가했습니다! 앱을 계속 사용하려면 {currentVersion}에서 {newVersion}으로 업데이트하세요.", + "criticalUpdateButton": "업데이트", + "bannerUpdateTitle": "새 버전 사용 가능!", + "bannerUpdateDescription": "최신 기능 및 수정 사항을 받으세요. 지금 설치하려면 \"업데이트\"를 클릭하세요", + "bannerUpdateButton": "업데이트", + "settingsUpdateTitle": "새 버전 ({newVersion}) 사용 가능!", + "settingsUpdateDescription": "현재 버전: {currentVersion} (공식 빌드) → {newVersion}", + "settingsUpdateButton": "업데이트", + "settingsUpdateWhatsNew": "새로운 기능" + }, + "lockPage": { + "lockPage": "잠금", + "reLockPage": "다시 잠금", + "lockTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다. 잠금 해제하려면 클릭하세요.", + "pageLockedToast": "페이지가 잠겼습니다. 누군가 잠금을 해제할 때까지 편집이 비활성화됩니다.", + "lockedOperationTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다." + }, + "suggestion": { + "accept": "수락", + "keep": "유지", + "discard": "버리기", + "close": "닫기", + "tryAgain": "다시 시도", + "rewrite": "다시 작성", + "insertBelow": "아래에 삽입" } } diff --git a/frontend/resources/translations/mr-IN.json b/frontend/resources/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/resources/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index e3ca580354..9473d7e2f0 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -164,14 +164,14 @@ "questionBubble": { "shortcuts": "Skróty", "whatsNew": "Co nowego?", - "help": "Pomoc & Wsparcie", "markdown": "Markdown", "debug": { "name": "Informacje Debugowania", "success": "Skopiowano informacje debugowania do schowka!", "fail": "Nie mozna skopiować informacji debugowania do schowka" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Pomoc & Wsparcie" }, "menuAppHeader": { "moreButtonToolTip": "Usuń, zmień nazwę i więcej...", diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index 51b585f14b..864d225095 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -225,14 +225,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda e Suporte", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Informação de depuração copiada para a área de transferência!", "fail": "Falha ao copiar a informação de depuração para a área de transferência" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda e Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 617e097f6b..c9892bf9df 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -128,14 +128,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda & Suporte", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Copiar informação de depuração para o clipboard!", "fail": "Falha em copiar a informação de depuração para o clipboard" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda & Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index b89b26f0c5..c45010b8fc 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -76,9 +76,14 @@ }, "workspace": { "chooseWorkspace": "Выберите рабочее пространство", + "defaultName": "Моё рабочее пространство", "create": "Создать рабочее пространство", + "new": "Новое рабочее пространство", + "importFromNotion": "Импортировать с Notion", + "learnMore": "Узнать больше", "reset": "Сбросить рабочее пространство", "renameWorkspace": "Переименовать рабочее пространство", + "workspaceNameCannotBeEmpty": "Название рабочего пространства не может быть пустым", "resetWorkspacePrompt": "Сброс рабочего пространства приведет к удалению всех страниц и данных внутри него. Вы уверены, что хотите сбросить рабочее пространство? В качестве альтернативы вы можете обратиться в службу поддержки для восстановления рабочего пространства", "hint": "рабочее пространство", "notFoundError": "Рабочее пространство не найдено.", @@ -122,7 +127,9 @@ "visitSite": "Посетить сайт", "exportAsTab": "Экспортировать как", "publishTab": "Опубликовать", - "shareTab": "Поделиться" + "shareTab": "Поделиться", + "publishOnAppFlowy": "Выложить на AppFlowy", + "shareTabTitle": "Пригласить к сотрудничеству" }, "moreAction": { "small": "маленький", @@ -144,6 +151,11 @@ "csv": "CSV", "database": "База данных" }, + "emojiIconPicker": { + "iconUploader": { + "change": "Изменить" + } + }, "disclosureAction": { "rename": "Переименовать", "delete": "Удалить", @@ -155,7 +167,8 @@ "addToFavorites": "Добавить в избранное", "copyLink": "Скопировать ссылку", "changeIcon": "Изменить иконку", - "collapseAllPages": "Свернуть все подстраницы" + "collapseAllPages": "Свернуть все подстраницы", + "lockPage": "Заблокировать страницу" }, "blankPageTitle": "Пустая страница", "newPageText": "Новая страница", @@ -170,17 +183,39 @@ "relatedQuestion": "Связано", "serverUnavailable": "Сервис временно недоступен. Пожалуйста, повторите попытку позже.", "aiServerUnavailable": "🌈 Ой-ой! 🌈. Единорог съел наш ответ. Пожалуйста, повторите попытку!", + "retry": "Повторить", "clickToRetry": "Нажмите, чтобы повторить попытку", "regenerateAnswer": "Повторно сгенерировать", "question1": "Как использовать канбан для управления задачами.", "question2": "Объясните метод GTD.", "question3": "Зачем использовать Rust.", "question4": "Рецепт из того, что есть у меня на кухне.", - "aiMistakePrompt": "ИИ может ошибаться. Проверяйте важную информацию." + "aiMistakePrompt": "ИИ может ошибаться. Проверяйте важную информацию.", + "inputActionNoPages": "Нет результатов на странице", + "currentPage": "Текущая страница", + "regenerate": "Попробуйте ещё раз", + "addToNewPage": "Создать новую страницу", + "openPagePreviewFailedToast": "Не удалось открыть страницу", + "changeFormat": { + "actionButton": "Изменить формат", + "textOnly": "Текст", + "imageOnly": "Только изображение", + "textAndImage": "Текст и изображение", + "text": "Параграф", + "bullet": "Список маркеров", + "number": "Нумерованный список", + "defaultDescription": "Автоматический режим" + }, + "selectBanner": { + "selectMessages": "Выбрать сообщения", + "allSelected": "Все выбрано" + }, + "stopTooltip": "Остановить генерацию" }, "trash": { "text": "Корзина", "restoreAll": "Восстановить всё", + "restore": "Восстановить", "deleteAll": "Удалить всё", "pageHeader": { "fileName": "Имя файла", @@ -195,6 +230,9 @@ "title": "Вы уверены, что хотите восстановить все страницы в корзине?", "caption": "Это действие не может быть отменено." }, + "restorePage": { + "caption": "Вы уверены, что хотите восстановить эту страницу?" + }, "mobile": { "actions": "Действия с корзиной", "empty": "Корзина пуста", @@ -213,14 +251,14 @@ "questionBubble": { "shortcuts": "Горячие клавиши", "whatsNew": "Что нового?", - "help": "Помощь и поддержка", "markdown": "Markdown", "debug": { "name": "Отладочная информация", "success": "Отладочная информация скопирована в буфер обмена!", "fail": "Не удалось скопировать отладочную информацию в буфер обмена" }, - "feedback": "Обратная связь" + "feedback": "Обратная связь", + "help": "Помощь и поддержка" }, "menuAppHeader": { "moreButtonToolTip": "Удалить, переименовать и другие действия...", @@ -289,7 +327,10 @@ "removeSuccess": "Удалено успешно", "favoriteSpace": "Избранное", "RecentSpace": "Недавнее", - "Spaces": "Пространства" + "Spaces": "Пространства", + "upgradeToPro": "Обновление до Pro", + "upgradeToAIMax": "Разблокируйте неограниченный ИИ", + "purchaseAIResponse": "Покупка " }, "notifications": { "export": { @@ -323,6 +364,7 @@ "upload": "Загрузить", "edit": "Редактировать", "delete": "Удалить", + "copy": "Копировать", "duplicate": "Дублировать", "putback": "Вернуть", "update": "Обновить", @@ -334,6 +376,7 @@ "helpCenter": "Центр помощи", "add": "Добавить", "yes": "Да", + "no": "Нет", "clear": "Очистить", "remove": "Удалить", "dontRemove": "Не удалять", @@ -349,6 +392,16 @@ "more": "Больше", "create": "Создать", "close": "Закрыть", + "next": "Следующий", + "previous": "Предыдущий", + "submit": "Представить", + "download": "Скачать", + "backToHome": "Вернуться на главную", + "viewing": "Просмотр", + "editing": "Редактирование", + "gotIt": "Понятно", + "retry": "Повторить попытку", + "uploadFailed": "Загрузка не удалась.", "tryAGain": "Попробовать ещё раз", "Done": "Готово", "Cancel": "Отмена", @@ -376,6 +429,28 @@ }, "settings": { "title": "Настройки", + "popupMenuItem": { + "settings": "Настройки", + "members": "Участники", + "helpAndSupport": "Помощь и поддержка" + }, + "sites": { + "namespaceTitle": "Пространство имен", + "namespaceDescription": "Управляйте своим пространством имен и домашней страницей", + "namespaceHeader": "Пространство имен", + "homepageHeader": "Домашняя страница", + "updateNamespace": "Обновить пространство имен", + "removeHomepage": "Удалить домашнюю страницу", + "selectHomePage": "Выберите страницу", + "clearHomePage": "Очистить домашнюю страницу для этого пространства имен", + "customUrl": "Пользовательский URL-адрес", + "namespace": { + "description": "Это изменение будет применено ко всем опубликованным страницам, размещенным в этом пространстве имен." + }, + "publishedPage": { + "page": "Страница" + } + }, "accountPage": { "menuLabel": "Мой аккаунт", "title": "Мой аккаунт", @@ -1345,8 +1420,7 @@ "url": { "launch": "Открыть в браузере", "copy": "Скопировать URL", - "textFieldHint": "Введите URL-адрес", - "copiedNotification": "Скопировано в буфер обмена!" + "textFieldHint": "Введите URL-адрес" }, "relation": { "relatedDatabasePlaceLabel": "Связанная база данных", @@ -2183,5 +2257,15 @@ "privacyPolicy": "Политика конфиденциальности", "signInError": "Ошибка входа", "login": "Зарегистрироваться или войти" + }, + "ai": { + "limitReachedAction": { + "upgrade": "улучшить", + "proPlan": "план Pro", + "aiAddon": "Дополнение ИИ" + }, + "editing": "Редактирование", + "analyzing": "Анализ", + "more": "Более" } } diff --git a/frontend/resources/translations/sv-SE.json b/frontend/resources/translations/sv-SE.json index 42855011b2..3210aa1f15 100644 --- a/frontend/resources/translations/sv-SE.json +++ b/frontend/resources/translations/sv-SE.json @@ -107,14 +107,14 @@ "questionBubble": { "shortcuts": "Genvägar", "whatsNew": "Vad nytt?", - "help": "Hjälp & Support", "markdown": "Prissänkning", "debug": { "name": "Felsökningsinfo", "success": "Kopierade felsökningsinfo till urklipp!", "fail": "Kunde inte kopiera felsökningsinfo till urklipp" }, - "feedback": "Återkoppling" + "feedback": "Återkoppling", + "help": "Hjälp & Support" }, "menuAppHeader": { "addPageTooltip": "Lägg till en underliggande sida", diff --git a/frontend/resources/translations/th-TH.json b/frontend/resources/translations/th-TH.json index 6bfa0c0ea0..78e5462d7f 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -250,14 +250,14 @@ "questionBubble": { "shortcuts": "ทางลัด", "whatsNew": "มีอะไรใหม่?", - "help": "ช่วยเหลือและสนับสนุน", "markdown": "Markdown", "debug": { "name": "ข้อมูลดีบัก", "success": "คัดลอกข้อมูลดีบักไปยังคลิปบอร์ดแล้ว!", "fail": "ไม่สามารถคัดลอกข้อมูลดีบักไปยังคลิปบอร์ด" }, - "feedback": "ข้อเสนอแนะ" + "feedback": "ข้อเสนอแนะ", + "help": "ช่วยเหลือและสนับสนุน" }, "menuAppHeader": { "moreButtonToolTip": "ลบ เปลี่ยนชื่อ และอื่นๆ...", @@ -333,7 +333,6 @@ "storageLimitDialogTitle": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว กรุณาอัปเกรดเพื่อปลดล็อกพื้นที่จัดเก็บแบบไม่จำกัด", "storageLimitDialogTitleIOS": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว", "aiResponseLimitTitle": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว กรุณาอัปเกรดเป็นแผน Pro หรือซื้อส่วนเสริม AI เพื่อปลดล็อกการตอบกลับไม่จำกัด", - "aiResponseLimitTitleIOS": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว", "aiResponseLimitDialogTitle": "ถึงขีดจำกัดการตอบกลับ AI แล้ว", "aiResponseLimit": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว\nไปที่ การตั้งค่า -> แผน -> คลิก AI Max หรือแผน Pro เพื่อรับการตอบกลับ AI เพิ่มเติม", "askOwnerToUpgradeToPro": "พื้นที่จัดเก็บฟรีในพื้นที่ทำงานของคุณกำลังจะหมด กรุณาขอให้เจ้าของพื้นที่ทำงานของคุณอัปเกรดเป็นแผน Pro", @@ -1571,8 +1570,7 @@ "url": { "launch": "เปิดในเบราว์เซอร์", "copy": "คัดลอก URL", - "textFieldHint": "ป้อน URL", - "copiedNotification": "คัดลอกไปยังคลิปบอร์ดแล้ว!" + "textFieldHint": "ป้อน URL" }, "relation": { "relatedDatabasePlaceLabel": "ฐานข้อมูลที่เกี่ยวข้อง", diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index 873f4cf355..0eeac684c6 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -1,98 +1,100 @@ { "appName": "AppFlowy", - "defaultUsername": "Ben", - "welcomeText": "@:appName'a Hoş Geldiniz", + "defaultUsername": "Kullanıcı", + "welcomeText": "@:appName'ye Hoş Geldiniz", "welcomeTo": "Hoş Geldiniz", - "githubStarText": "GitHub'da Yıldız Verin", - "subscribeNewsletterText": "Bültene Abone Olun", - "letsGoButtonText": "Hızlı Başlangıç", + "githubStarText": "GitHub'da Yıldız Ver", + "subscribeNewsletterText": "Bültenimize Abone Ol", + "letsGoButtonText": "Hemen Başla", "title": "Başlık", - "youCanAlso": "Ayrıca şunları da yapabilirsiniz", + "youCanAlso": "Ayrıca", "and": "ve", "failedToOpenUrl": "URL açılamadı: {}", "blockActions": { - "addBelowTooltip": "Alta eklemek için tıklayın", - "addAboveCmd": "Alt+tıkla", - "addAboveMacCmd": "Option+tıkla", - "addAboveTooltip": "üste eklemek için", - "dragTooltip": "Taşımak için sürükleyin", - "openMenuTooltip": "Menüyü açmak için tıklayın" + "addBelowTooltip": "Altına eklemek için tıklayın", + "addAboveCmd": "Alt+tıklama", + "addAboveMacCmd": "Option+tıklama", + "addAboveTooltip": "Üstüne eklemek için", + "dragTooltip": "Sürükleyerek taşıyın", + "openMenuTooltip": "Menüyü aç" }, "signUp": { "buttonText": "Kayıt Ol", - "title": "@:appName'a Kaydolun", + "title": "@:appName'e Kayıt Ol", "getStartedText": "Başlayın", - "emptyPasswordError": "Parola boş bırakılamaz", - "repeatPasswordEmptyError": "Parola tekrarı boş bırakılamaz", - "unmatchedPasswordError": "Parola tekrarı, girdiğiniz parolayla aynı değil", - "alreadyHaveAnAccount": "Zaten bir hesabınız var mı?", - "emailHint": "E-posta", + "emptyPasswordError": "Parola boş olamaz", + "repeatPasswordEmptyError": "Parola tekrarı alanı boş olamaz", + "unmatchedPasswordError": "Parola tekrarı, parola ile aynı değil", + "alreadyHaveAnAccount": "Hesabınız zaten var mı?", + "emailHint": "E-posta adresi", "passwordHint": "Parola", - "repeatPasswordHint": "Parolayı tekrar girin", - "signUpWith": "Şununla kaydolun:" + "repeatPasswordHint": "Parolayı tekrarla", + "signUpWith": "Kayıt ol:" }, "signIn": { - "loginTitle": "@:appName'a Giriş Yapın", - "loginButtonText": "Giriş Yap", - "loginStartWithAnonymous": "Anonim oturumla devam et", + "loginTitle": "@:appName'e Oturum Aç", + "loginButtonText": "Oturum Aç", + "loginStartWithAnonymous": "Anonim oturumla başla", "continueAnonymousUser": "Anonim oturumla devam et", "anonymous": "Anonim", - "buttonText": "Giriş Yap", - "signingInText": "Giriş yapılıyor...", - "forgotPassword": "Parolanızı mı unuttunuz?", - "emailHint": "E-posta", + "buttonText": "Oturum Aç", + "signingInText": "Oturum açılıyor...", + "forgotPassword": "Parolamı Unuttum?", + "emailHint": "E-posta adresi", "passwordHint": "Parola", "dontHaveAnAccount": "Hesabınız yok mu?", "createAccount": "Hesap oluştur", - "repeatPasswordEmptyError": "Parola tekrarı boş bırakılamaz", - "unmatchedPasswordError": "Parola tekrarı, girdiğiniz parolayla aynı değil", - "syncPromptMessage": "Veriler senkronize ediliyor, lütfen bu sayfayı kapatmayın", + "repeatPasswordEmptyError": "Parola tekrarı alanı boş bırakılamaz", + "unmatchedPasswordError": "Parola tekrarı parolayla eşleşmiyor", + "syncPromptMessage": "Verilerin senkronize edilmesi biraz zaman alabilir. Lütfen bu sayfayı kapatmayın", "or": "VEYA", - "signInWithGoogle": "Google ile Giriş Yap", - "signInWithGithub": "Github ile Giriş Yap", - "signInWithDiscord": "Discord ile Giriş Yap", + "signInWithGoogle": "Google ile devam et", + "signInWithGithub": "GitHub ile devam et", + "signInWithDiscord": "Discord ile devam et", "signInWithApple": "Apple ile devam et", - "signUpWithGoogle": "Google ile Kaydol", - "signUpWithGithub": "Github ile Kaydol", - "signUpWithDiscord": "Discord ile Kaydol", - "signInWith": "Şununla giriş yapın:", - "signInWithEmail": "E-posta ile giriş yap", - "signInWithMagicLink": "Devam Et", - "signUpWithMagicLink": "Sihirli Bağlantı ile Kaydol", + "continueAnotherWay": "Başka yöntemle devam et", + "signUpWithGoogle": "Google ile kaydol", + "signUpWithGithub": "GitHub ile kaydol", + "signUpWithDiscord": "Discord ile kaydol", + "signInWith": "Devam et:", + "signInWithEmail": "E-posta ile devam et", + "signInWithMagicLink": "Devam et", + "signUpWithMagicLink": "Sihirli Bağlantı ile kaydol", "pleaseInputYourEmail": "Lütfen e-posta adresinizi girin", "settings": "Ayarlar", - "magicLinkSent": "Sihirli bağlantı e-postanıza gönderildi!", - "invalidEmail": "Lütfen geçerli bir e-posta adresi girin", - "alreadyHaveAnAccount": "Zaten bir hesabınız var mı?", - "logIn": "Giriş Yap", - "generalError": "Bir şeyler yanlış gitti. Lütfen daha sonra tekrar deneyin", - "limitRateError": "Güvenlik nedeniyle, sihirli bağlantı talebi 60 saniyede bir yapılabilir", - "magicLinkSentDescription": "E-postanıza bir Sihirli Bağlantı gönderildi. Girişinizi tamamlamak için bağlantıya tıklayın. Bağlantı 5 dakika sonra sona erecek." + "magicLinkSent": "Sihirli Bağlantı gönderildi!", + "invalidEmail": "Geçerli bir e-posta adresi girin", + "alreadyHaveAnAccount": "Zaten hesabınız var mı?", + "logIn": "Oturum Aç", + "generalError": "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.", + "limitRateError": "Güvenlik önlemi olarak, sihirli bağlantı talepleri 60 saniyede bir ile sınırlandırılmıştır.", + "magicLinkSentDescription": "E-posta adresinize sihirli bir bağlantı gönderdik. Giriş yapmak için bu bağlantıya tıklayın. Bağlantı 5 dakika içinde geçersiz hale gelecektir." }, "workspace": { "chooseWorkspace": "Çalışma alanınızı seçin", "defaultName": "Çalışma Alanım", - "create": "Çalışma Alanı Oluştur", - "importFromNotion": "Notion'dan İçe Aktar", - "learnMore": "Daha fazla bilgi edin", - "reset": "Çalışma Alanını Sıfırla", - "renameWorkspace": "Çalışma Alanını Yeniden Adlandır", + "create": "Çalışma alanı oluştur", + "new": "Yeni çalışma alanı", + "importFromNotion": "Notion'dan içe aktar", + "learnMore": "Daha fazla bilgi", + "reset": "Çalışma alanını sıfırla", + "renameWorkspace": "Çalışma alanını yeniden adlandır", "workspaceNameCannotBeEmpty": "Çalışma alanı adı boş olamaz", - "resetWorkspacePrompt": "Çalışma alanını sıfırlamak, içindeki tüm sayfaları ve verileri silecektir. Çalışma alanını sıfırlamak istediğinizden emin misiniz? Alternatif olarak, çalışma alanını geri yüklemek için destek ekibiyle iletişime geçebilirsiniz", + "resetWorkspacePrompt": "Çalışma alanını sıfırlamak tüm sayfaları ve verileri silecektir. Çalışma alanını sıfırlamak istediğinizden emin misiniz? Geri yüklemek için destek ekibine ulaşabilirsiniz.", "hint": "çalışma alanı", "notFoundError": "Çalışma alanı bulunamadı", - "failedToLoad": "Bir şeyler yanlış gitti! Çalışma alanı yüklenemedi. Açık olan tüm @:appName örneklerini kapatıp tekrar deneyin.", + "failedToLoad": "Bir şeyler yanlış gitti! Çalışma alanı yüklenemedi. @:appName'in açık olan tüm örneklerini kapatıp tekrar deneyin.", "errorActions": { - "reportIssue": "Sorun bildir", - "reportIssueOnGithub": "GitHub'da sorun bildir", + "reportIssue": "Hata bildir", + "reportIssueOnGithub": "GitHub'da hata bildir", "exportLogFiles": "Günlük dosyalarını dışa aktar", - "reachOut": "Discord'da ulaşın" + "reachOut": "Discord'da iletişime geç" }, "menuTitle": "Çalışma Alanları", - "deleteWorkspaceHintText": "Çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız sayfalar da silinecektir.", + "deleteWorkspaceHintText": "Çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır.", "createSuccess": "Çalışma alanı başarıyla oluşturuldu", "createFailed": "Çalışma alanı oluşturulamadı", - "createLimitExceeded": "Hesabınız için izin verilen maksimum çalışma alanı sınırına ulaştınız. Çalışmanıza devam etmek için ek çalışma alanlarına ihtiyacınız varsa, lütfen GitHub'da talepte bulunun", + "createLimitExceeded": "Hesabınız için izin verilen maksimum çalışma alanı limitine ulaştınız. Çalışmanıza devam etmek için ek çalışma alanlarına ihtiyacınız varsa, lütfen GitHub'da talepte bulunun", "deleteSuccess": "Çalışma alanı başarıyla silindi", "deleteFailed": "Çalışma alanı silinemedi", "openSuccess": "Çalışma alanı başarıyla açıldı", @@ -103,85 +105,147 @@ "updateIconFailed": "Çalışma alanı simgesi güncellenemedi", "cannotDeleteTheOnlyWorkspace": "Tek çalışma alanı silinemez", "fetchWorkspacesFailed": "Çalışma alanları getirilemedi", - "leaveCurrentWorkspace": "Çalışma alanından çık", - "leaveCurrentWorkspacePrompt": "Geçerli çalışma alanından çıkmak istediğinizden emin misiniz?" + "leaveCurrentWorkspace": "Çalışma alanından ayrıl", + "leaveCurrentWorkspacePrompt": "Mevcut çalışma alanından ayrılmak istediğinizden emin misiniz?" }, "shareAction": { "buttonText": "Paylaş", - "workInProgress": "Yakında geliyor", + "workInProgress": "Yakında", "markdown": "Markdown", "html": "HTML", "clipboard": "Panoya kopyala", "csv": "CSV", - "copyLink": "Bağlantıyı Kopyala", + "copyLink": "Bağlantıyı kopyala", "publishToTheWeb": "Web'de Yayınla", "publishToTheWebHint": "AppFlowy ile bir web sitesi oluşturun", "publish": "Yayınla", "unPublish": "Yayından kaldır", "visitSite": "Siteyi ziyaret et", - "exportAsTab": "Şu şekilde dışa aktar", + "exportAsTab": "Farklı dışa aktar", "publishTab": "Yayınla", "shareTab": "Paylaş", - "publishOnAppFlowy": "AppFlowy'de yayınla", + "publishOnAppFlowy": "AppFlowy'de Yayınla", + "shareTabTitle": "İşbirliği için davet et", + "shareTabDescription": "Herhangi biriyle kolay işbirliği için", "copyLinkSuccess": "Bağlantı panoya kopyalandı", - "copyLinkFailed": "Bağlantı panoya kopyalanamadı" + "copyShareLink": "Paylaşım bağlantısını kopyala", + "copyLinkFailed": "Bağlantı panoya kopyalanamadı", + "copyLinkToBlockSuccess": "Blok bağlantısı panoya kopyalandı", + "copyLinkToBlockFailed": "Blok bağlantısı panoya kopyalanamadı", + "manageAllSites": "Tüm siteleri yönet", + "updatePathName": "Yol adını güncelle" }, "moreAction": { "small": "küçük", "medium": "orta", "large": "büyük", - "fontSize": "Yazı boyutu", - "import": "İçe Aktar", + "fontSize": "Yazı tipi boyutu", + "import": "İçe aktar", "moreOptions": "Daha fazla seçenek", "wordCount": "Kelime sayısı: {}", "charCount": "Karakter sayısı: {}", - "createdAt": "Oluşturulma tarihi: {}", + "createdAt": "Oluşturulma: {}", "deleteView": "Sil", - "duplicateView": "Kopyala" + "duplicateView": "Çoğalt", + "wordCountLabel": "Kelime sayısı: ", + "charCountLabel": "Karakter sayısı: ", + "createdAtLabel": "Oluşturulma: ", + "syncedAtLabel": "Senkronize edilme: ", + "saveAsNewPage": "Mesajları sayfaya ekle" }, "importPanel": { - "textAndMarkdown": "Metin & Markdown", - "documentFromV010": "v0.1.0 Belgesi", - "databaseFromV010": "v0.1.0 Veritabanı", + "textAndMarkdown": "Metin ve Markdown", + "documentFromV010": "v0.1.0'dan belge", + "databaseFromV010": "v0.1.0'dan veritabanı", + "notionZip": "Notion Dışa Aktarılmış Zip Dosyası", "csv": "CSV", "database": "Veritabanı" }, "disclosureAction": { - "rename": "Yeniden Adlandır", + "rename": "Yeniden adlandır", "delete": "Sil", - "duplicate": "Kopyala", - "unfavorite": "Favorilerden çıkar", + "duplicate": "Çoğalt", + "unfavorite": "Favorilerden kaldır", "favorite": "Favorilere ekle", "openNewTab": "Yeni sekmede aç", - "moveTo": "Taşı", - "addToFavorites": "Favorilere Ekle", - "copyLink": "Bağlantıyı Kopyala", - "changeIcon": "Simgeyi Değiştir", - "collapseAllPages": "Tüm alt sayfaları daralt" + "moveTo": "Şuraya taşı", + "addToFavorites": "Favorilere ekle", + "copyLink": "Bağlantıyı kopyala", + "changeIcon": "Simgeyi değiştir", + "collapseAllPages": "Tüm alt sayfaları daralt", + "movePageTo": "Sayfayı şuraya taşı", + "move": "Taşı" }, "blankPageTitle": "Boş sayfa", "newPageText": "Yeni sayfa", "newDocumentText": "Yeni belge", - "newGridText": "Yeni Kılavuz", + "newGridText": "Yeni ızgara", "newCalendarText": "Yeni takvim", "newBoardText": "Yeni pano", "chat": { "newChat": "Yapay Zeka Sohbeti", - "inputMessageHint": "AppFlowy Yapay Zekasına mesaj gönder", - "unsupportedCloudPrompt": "Bu özellik yalnızca AppFlowy Cloud kullanıldığında kullanılabilir", - "relatedQuestion": "İlgili", - "serverUnavailable": "Hizmet geçici olarak kullanılamıyor. Lütfen daha sonra tekrar deneyin.", - "aiServerUnavailable": "🌈 Üzgünüz! 🌈. Bir tek boynuzlu at yanıtımızı yedi. Lütfen tekrar deneyin!", - "clickToRetry": "Yeniden denemek için tıklayın", + "inputMessageHint": "@:appName Yapay Zekasına sorun", + "inputLocalAIMessageHint": "@:appName Yerel Yapay Zekasına sorun", + "unsupportedCloudPrompt": "Bu özellik yalnızca @:appName Cloud kullanırken kullanılabilir", + "relatedQuestion": "Önerilen", + "serverUnavailable": "Bağlantı kesildi. Lütfen internet bağlantınızı kontrol edin ve", + "aiServerUnavailable": "Yapay zeka hizmeti geçici olarak kullanılamıyor. Lütfen daha sonra tekrar deneyin.", + "retry": "Tekrar dene", + "clickToRetry": "Tekrar denemek için tıklayın", "regenerateAnswer": "Yeniden oluştur", - "question1": "Kanban'ı kullanarak görevleri yönetme", + "question1": "Görevleri yönetmek için Kanban nasıl kullanılır", "question2": "GTD yöntemini açıkla", - "question3": "Neden Rust kullanmalıyım?", - "question4": "Mutfağımdakilerle tarif", + "question3": "Neden Rust kullanmalı", + "question4": "Mutfağımdaki malzemelerle tarif", + "question5": "Sayfam için bir illüstrasyon oluştur", + "question6": "Önümüzdeki hafta için yapılacaklar listesi hazırla", "aiMistakePrompt": "Yapay zeka hata yapabilir. Önemli bilgileri kontrol edin.", - "chatWithFilePrompt": "Dosyayla sohbet etmek istiyor musunuz?", - "indexFileSuccess": "Dosyayı başarıyla indeksleme", - "indexingFile": "{} indeksleniyor" + "chatWithFilePrompt": "Dosya ile sohbet etmek ister misiniz?", + "indexFileSuccess": "Dosya başarıyla indekslendi", + "inputActionNoPages": "Sayfa sonucu yok", + "referenceSource": { + "zero": "0 kaynak bulundu", + "one": "{count} kaynak bulundu", + "other": "{count} kaynak bulundu" + }, + "clickToMention": "Bir sayfadan bahset", + "uploadFile": "PDF, metin veya markdown dosyaları ekle", + "questionDetail": "Merhaba {}! Size bugün nasıl yardımcı olabilirim?", + "indexingFile": "{} indeksleniyor", + "generatingResponse": "Yanıt oluşturuluyor", + "selectSources": "Kaynakları Seç", + "sourcesLimitReached": "En fazla 3 üst düzey belge ve alt öğelerini seçebilirsiniz", + "sourceUnsupported": "Şu anda veritabanlarıyla sohbet etmeyi desteklemiyoruz", + "regenerate": "Tekrar dene", + "addToPageButton": "Mesajı sayfaya ekle", + "addToPageTitle": "Mesajı şuraya ekle...", + "addToNewPage": "Yeni sayfa oluştur", + "addToNewPageName": "\"{}\" kaynağından çıkarılan mesajlar", + "addToNewPageSuccessToast": "Mesaj şuraya eklendi:", + "openPagePreviewFailedToast": "Sayfa açılamadı", + "changeFormat": { + "actionButton": "Biçimi değiştir", + "confirmButton": "Bu biçimle yeniden oluştur", + "textOnly": "Metin", + "imageOnly": "Sadece görsel", + "textAndImage": "Metin ve Görsel", + "text": "Paragraf", + "bullet": "Madde işaretli liste", + "number": "Numaralı liste", + "table": "Tablo", + "blankDescription": "Yanıt biçimi", + "defaultDescription": "Otomatik mod", + "textWithImageDescription": "@:chat.changeFormat.text ve görsel", + "numberWithImageDescription": "@:chat.changeFormat.number ve görsel", + "bulletWithImageDescription": "@:chat.changeFormat.bullet ve görsel", + "tableWithImageDescription": "@:chat.changeFormat.table ve görsel" + }, + "selectBanner": { + "saveButton": "Şuraya ekle …", + "selectMessages": "Mesajları seç", + "nSelected": "{} seçildi", + "allSelected": "Tümü seçildi" + } }, "trash": { "text": "Çöp Kutusu", @@ -194,11 +258,11 @@ "created": "Oluşturulma" }, "confirmDeleteAll": { - "title": "Çöp Kutusu'ndaki tüm sayfaları silmek istediğinizden emin misiniz?", - "caption": "Bu işlem geri alınamaz." + "title": "Çöp kutusundaki tüm sayfalar", + "caption": "Çöp kutusundaki her şeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "confirmRestoreAll": { - "title": "Çöp Kutusu'ndaki tüm sayfaları geri yüklemek istediğinizden emin misiniz?", + "title": "Çöp kutusundaki tüm sayfaları geri yükle", "caption": "Bu işlem geri alınamaz." }, "restorePage": { @@ -207,15 +271,15 @@ }, "mobile": { "actions": "Çöp Kutusu İşlemleri", - "empty": "Çöp Kutusu Boş", - "emptyDescription": "Silinmiş dosyanız yok", + "empty": "Çöp kutusunda sayfa veya alan yok", + "emptyDescription": "İhtiyacınız olmayan şeyleri Çöp Kutusuna taşıyın.", "isDeleted": "silindi", "isRestored": "geri yüklendi" }, "confirmDeleteTitle": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz?" }, "deletePagePrompt": { - "text": "Bu sayfa Çöp Kutusu'nda", + "text": "Bu sayfa Çöp Kutusunda", "restore": "Sayfayı geri yükle", "deletePermanent": "Kalıcı olarak sil", "deletePermanentDescription": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." @@ -223,32 +287,33 @@ "dialogCreatePageNameHint": "Sayfa adı", "questionBubble": { "shortcuts": "Kısayollar", - "whatsNew": "Yenilikler?", - "help": "Yardım & Destek", + "whatsNew": "Yenilikler", "markdown": "Markdown", "debug": { "name": "Hata Ayıklama Bilgisi", "success": "Hata ayıklama bilgisi panoya kopyalandı!", "fail": "Hata ayıklama bilgisi panoya kopyalanamadı" }, - "feedback": "Geri Bildirim" + "feedback": "Geri Bildirim", + "help": "Yardım ve Destek" }, "menuAppHeader": { "moreButtonToolTip": "Kaldır, yeniden adlandır ve daha fazlası...", - "addPageTooltip": "İçine hızlıca bir sayfa ekleyin", - "defaultNewPageName": "İsimsiz", - "renameDialog": "Yeniden Adlandır" + "addPageTooltip": "Hızlıca içeri sayfa ekle", + "defaultNewPageName": "Başlıksız", + "renameDialog": "Yeniden adlandır", + "pageNameSuffix": "Kopya" }, - "noPagesInside": "İçinde sayfa yok", + "noPagesInside": "İçeride sayfa yok", "toolbar": { - "undo": "Geri Al", + "undo": "Geri al", "redo": "Yinele", "bold": "Kalın", "italic": "İtalik", - "underline": "Altı Çizili", - "strike": "Üstü Çizili", - "numList": "Numaralı Liste", - "bulletList": "Madde İşaretli Liste", + "underline": "Altı çizili", + "strike": "Üstü çizili", + "numList": "Numaralı liste", + "bulletList": "Madde işaretli liste", "checkList": "Kontrol Listesi", "inlineCode": "Satır İçi Kod", "quote": "Alıntı Bloğu", @@ -259,64 +324,74 @@ "link": "Bağlantı" }, "tooltip": { - "lightMode": "Açık moda geç", - "darkMode": "Koyu moda geç", + "lightMode": "Aydınlık moda geç", + "darkMode": "Karanlık moda geç", "openAsPage": "Sayfa olarak aç", - "addNewRow": "Yeni bir satır ekleyin", + "addNewRow": "Yeni satır ekle", "openMenu": "Menüyü açmak için tıklayın", - "dragRow": "Satırı yeniden sıralamak için uzun basın", + "dragRow": "Satırı yeniden sıralamak için sürükleyin", "viewDataBase": "Veritabanını görüntüle", - "referencePage": "Bu {name} referans gösteriliyor", - "addBlockBelow": "Alta bir blok ekle", + "referencePage": "Bu {name} referans alındı", + "addBlockBelow": "Alta blok ekle", "aiGenerate": "Oluştur" }, "sideBar": { "closeSidebar": "Kenar çubuğunu kapat", "openSidebar": "Kenar çubuğunu aç", + "expandSidebar": "Tam sayfa olarak genişlet", "personal": "Kişisel", "private": "Özel", "workspace": "Çalışma Alanı", "favorites": "Favoriler", - "clickToHidePrivate": "Özel alanı gizle\nBurada oluşturduğunuz sayfalar yalnızca size görünür", - "clickToHideWorkspace": "Çalışma alanını gizle\nBurada oluşturduğunuz sayfalar tüm üyelere görünür", - "clickToHidePersonal": "Kişisel alanı gizle", - "clickToHideFavorites": "Favori alanı gizle", - "addAPage": "Sayfa ekle", + "clickToHidePrivate": "Özel alanı gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar yalnızca size görünür", + "clickToHideWorkspace": "Çalışma alanını gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar tüm üyelere görünür", + "clickToHidePersonal": "Kişisel alanı gizlemek için tıklayın", + "clickToHideFavorites": "Favoriler alanını gizlemek için tıklayın", + "addAPage": "Yeni sayfa ekle", "addAPageToPrivate": "Özel alana sayfa ekle", "addAPageToWorkspace": "Çalışma alanına sayfa ekle", "recent": "Son", "today": "Bugün", "thisWeek": "Bu hafta", "others": "Önceki favoriler", + "earlier": "Daha önce", "justNow": "az önce", "minutesAgo": "{count} dakika önce", - "lastViewed": "Son görüntülenme", - "favoriteAt": "Favorilere eklenme", - "emptyRecent": "Son Belge Yok", - "emptyRecentDescription": "Belgeleri görüntüledikçe, kolayca erişim için burada görünürler", - "emptyFavorite": "Favori Belge Yok", - "emptyFavoriteDescription": "Keşfetmeye başlayın ve belgeleri favori olarak işaretleyin. Hızlı erişim için burada listelenirler!", - "removePageFromRecent": "Bu sayfayı Son Kullanılanlar'dan kaldırmak ister misiniz?", + "lastViewed": "Son görüntüleme", + "favoriteAt": "Favorilere eklendi", + "emptyRecent": "Son Sayfa Yok", + "emptyRecentDescription": "Sayfaları görüntüledikçe, kolay erişim için burada listelenecekler.", + "emptyFavorite": "Favori Sayfa Yok", + "emptyFavoriteDescription": "Sayfaları favori olarak işaretleyin—hızlı erişim için burada listelenecekler!", + "removePageFromRecent": "Bu sayfayı Son'dan kaldır?", "removeSuccess": "Başarıyla kaldırıldı", "favoriteSpace": "Favoriler", "RecentSpace": "Son", "Spaces": "Alanlar", - "upgradeToPro": "Pro'ya Yükselt", - "upgradeToAIMax": "Sınırsız Yapay Zeka'nın Kilidini Aç", - "storageLimitDialogTitle": "Ücretsiz depolama alanınız tükendi. Sınırsız depolama alanının kilidini açmak için yükseltin", - "aiResponseLimitTitle": "Ücretsiz Yapay Zeka yanıtlarınız tükendi. Sınırsız yanıtların kilidini açmak için Pro Planına yükseltin veya bir Yapay Zeka eklentisi satın alın", - "aiResponseLimitDialogTitle": "Yapay Zeka Yanıtları sınırı aşıldı", - "aiResponseLimit": "Ücretsiz Yapay Zeka yanıtlarınız tükendi.\n\nDaha fazla Yapay Zeka yanıtı almak için Ayarlar -> Plan -> AI Max veya Pro Planına tıklayın", - "askOwnerToUpgradeToPro": "Çalışma alanınızın ücretsiz depolama alanı tükeniyor. Lütfen çalışma alanı sahibinden Pro Planına yükseltmesini isteyin", - "askOwnerToUpgradeToAIMax": "Çalışma alanınızın ücretsiz Yapay Zeka yanıtları tükeniyor. Lütfen çalışma alanı sahibinden planı yükseltmesini veya Yapay Zeka eklentileri satın almasını isteyin", + "upgradeToPro": "Pro'ya yükselt", + "upgradeToAIMax": "Sınırsız yapay zekayı aç", + "storageLimitDialogTitle": "Ücretsiz depolama alanınız bitti. Sınırsız depolama için yükseltin", + "storageLimitDialogTitleIOS": "Ücretsiz depolama alanınız bitti.", + "aiResponseLimitTitle": "Ücretsiz yapay zeka yanıtlarınız bitti. Sınırsız yanıt için Pro Plana yükseltin veya bir yapay zeka eklentisi satın alın", + "aiResponseLimitDialogTitle": "Yapay zeka yanıt limitine ulaşıldı", + "aiResponseLimit": "Ücretsiz yapay zeka yanıtlarınız bitti.\n\nDaha fazla yapay zeka yanıtı almak için Ayarlar -> Plan -> AI Max veya Pro Plan'a tıklayın", + "askOwnerToUpgradeToPro": "Çalışma alanınızın ücretsiz depolama alanı bitiyor. Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin", + "askOwnerToUpgradeToProIOS": "Çalışma alanınızın ücretsiz depolama alanı bitiyor.", + "askOwnerToUpgradeToAIMax": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitti. Lütfen çalışma alanı sahibinden planı yükseltmesini veya yapay zeka eklentileri satın almasını isteyin", + "askOwnerToUpgradeToAIMaxIOS": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitiyor.", + "purchaseAIMax": "Çalışma alanınızın yapay zeka görsel yanıtları bitti. Lütfen çalışma alanı sahibinden AI Max satın almasını isteyin", + "aiImageResponseLimit": "Yapay zeka görsel yanıtlarınız bitti.\n\nDaha fazla yapay zeka görsel yanıtı almak için Ayarlar -> Plan -> AI Max'a tıklayın", "purchaseStorageSpace": "Depolama Alanı Satın Al", - "purchaseAIResponse": "Satın Al", - "upgradeToAILocal": "Cihazınızda çevrimdışı Yapay Zeka" + "singleFileProPlanLimitationDescription": "Ücretsiz planda izin verilen maksimum dosya yükleme boyutunu aştınız. Daha büyük dosyalar yüklemek için lütfen Pro Plana yükseltin", + "purchaseAIResponse": "Satın Al ", + "askOwnerToUpgradeToLocalAI": "Çalışma alanı sahibinden Cihaz Üzerinde Yapay Zekayı etkinleştirmesini isteyin", + "upgradeToAILocal": "En üst düzey gizlilik için yerel modelleri cihazınızda çalıştırın", + "upgradeToAILocalDesc": "Yerel yapay zeka kullanarak PDF'lerle sohbet edin, yazılarınızı geliştirin ve tabloları otomatik doldurun" }, "notifications": { "export": { "markdown": "Not Markdown Olarak Dışa Aktarıldı", - "path": "Belgeler/flowy" + "path": "Documents/flowy" } }, "contactsPage": { @@ -337,25 +412,26 @@ "generate": "Oluştur", "esc": "ESC", "keep": "Sakla", - "tryAgain": "Tekrar Dene", + "tryAgain": "Tekrar dene", "discard": "Vazgeç", "replace": "Değiştir", - "insertBelow": "Alta Ekle", - "insertAbove": "Üste Ekle", + "insertBelow": "Alta ekle", + "insertAbove": "Üste ekle", "upload": "Yükle", "edit": "Düzenle", "delete": "Sil", - "duplicate": "Kopyala", + "copy": "Kopyala", + "duplicate": "Çoğalt", "putback": "Geri Koy", "update": "Güncelle", "share": "Paylaş", - "removeFromFavorites": "Favorilerden çıkar", - "removeFromRecent": "Son Kullanılanlar'dan kaldır", + "removeFromFavorites": "Favorilerden kaldır", + "removeFromRecent": "Son'dan kaldır", "addToFavorites": "Favorilere ekle", "favoriteSuccessfully": "Favorilere eklendi", - "unfavoriteSuccessfully": "Favorilerden çıkarıldı", - "duplicateSuccessfully": "Başarıyla kopyalandı", - "rename": "Yeniden Adlandır", + "unfavoriteSuccessfully": "Favorilerden kaldırıldı", + "duplicateSuccessfully": "Başarıyla çoğaltıldı", + "rename": "Yeniden adlandır", "helpCenter": "Yardım Merkezi", "add": "Ekle", "yes": "Evet", @@ -365,50 +441,118 @@ "dontRemove": "Kaldırma", "copyLink": "Bağlantıyı Kopyala", "align": "Hizala", - "login": "Giriş Yap", - "logout": "Çıkış Yap", - "deleteAccount": "Hesabı Sil", + "login": "Giriş yap", + "logout": "Çıkış yap", + "deleteAccount": "Hesabı sil", "back": "Geri", - "signInGoogle": "Google ile Giriş Yap", - "signInGithub": "Github ile Giriş Yap", - "signInDiscord": "Discord ile Giriş Yap", - "more": "Daha Fazla", + "signInGoogle": "Google ile devam et", + "signInGithub": "GitHub ile devam et", + "signInDiscord": "Discord ile devam et", + "more": "Daha fazla", "create": "Oluştur", "close": "Kapat", - "next": "Sonraki", - "previous": "Önceki", + "next": "İleri", + "previous": "Geri", "submit": "Gönder", "download": "İndir", - "backToHome": "Anasayfaya dön", - "gotIt": "Anladım" + "backToHome": "Ana Sayfaya Dön", + "viewing": "Görüntüleme", + "editing": "Düzenleme", + "gotIt": "Anladım", + "retry": "Tekrar dene", + "uploadFailed": "Yükleme başarısız.", + "copyLinkOriginal": "Orijinal bağlantıyı kopyala" }, "label": { "welcome": "Hoş Geldiniz!", "firstName": "Ad", "middleName": "İkinci Ad", "lastName": "Soyad", - "stepX": "{X}. Adım" + "stepX": "Adım {X}" }, "oAuth": { "err": { "failedTitle": "Hesabınıza bağlanılamıyor.", - "failedMsg": "Lütfen tarayıcınızda giriş işlemini tamamladığınızdan emin olun." + "failedMsg": "Lütfen tarayıcınızda oturum açma işlemini tamamladığınızdan emin olun." }, "google": { - "title": "GOOGLE GİRİŞİ", - "instruction1": "Google Kişilerinizi içe aktarmak için, bu uygulamayı web tarayıcınızdan yetkilendirmeniz gerekecek.", - "instruction2": "Simgeye tıklayarak veya metni seçerek bu kodu panoya kopyalayın:", + "title": "GOOGLE İLE GİRİŞ", + "instruction1": "Google Kişilerinizi içe aktarmak için, bu uygulamaya web tarayıcınızı kullanarak yetki vermeniz gerekecek.", + "instruction2": "Simgeye tıklayarak veya metni seçerek bu kodu panonuza kopyalayın:", "instruction3": "Web tarayıcınızda aşağıdaki bağlantıya gidin ve yukarıdaki kodu girin:", - "instruction4": "Kayıt işlemini tamamladığınızda aşağıdaki butona basın:" + "instruction4": "Kaydı tamamladığınızda aşağıdaki düğmeye basın:" } }, "settings": { "title": "Ayarlar", "popupMenuItem": { "settings": "Ayarlar", + "members": "Üyeler", "trash": "Çöp Kutusu", "helpAndSupport": "Yardım ve Destek" }, + "sites": { + "title": "Siteler", + "namespaceTitle": "Alan Adı", + "namespaceDescription": "Alan adınızı ve ana sayfanızı yönetin", + "namespaceHeader": "Alan Adı", + "homepageHeader": "Ana Sayfa", + "updateNamespace": "Alan adını güncelle", + "removeHomepage": "Ana sayfayı kaldır", + "selectHomePage": "Bir sayfa seç", + "clearHomePage": "Bu alan adı için ana sayfayı temizle", + "customUrl": "Özel URL", + "namespace": { + "description": "Bu değişiklik, bu alan adında yayınlanan tüm canlı sayfalara uygulanacak", + "tooltip": "Uygunsuz alan adlarını kaldırma hakkını saklı tutarız", + "updateExistingNamespace": "Mevcut alan adını güncelle", + "upgradeToPro": "Ana sayfa ayarlamak için Pro Plana yükseltin", + "redirectToPayment": "Ödeme sayfasına yönlendiriliyor...", + "onlyWorkspaceOwnerCanSetHomePage": "Yalnızca çalışma alanı sahibi ana sayfa ayarlayabilir", + "pleaseAskOwnerToSetHomePage": "Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin" + }, + "publishedPage": { + "title": "Tüm yayınlanan sayfalar", + "description": "Yayınlanan sayfalarınızı yönetin", + "page": "Sayfa", + "pathName": "Yol adı", + "date": "Yayınlanma tarihi", + "emptyHinText": "Bu çalışma alanında yayınlanmış sayfanız yok", + "noPublishedPages": "Yayınlanmış sayfa yok", + "settings": "Yayın ayarları", + "clickToOpenPageInApp": "Sayfayı uygulamada aç", + "clickToOpenPageInBrowser": "Sayfayı tarayıcıda aç" + }, + "error": { + "failedToGeneratePaymentLink": "Pro Plan için ödeme bağlantısı oluşturulamadı", + "failedToUpdateNamespace": "Alan adı güncellenemedi", + "proPlanLimitation": "Alan adını güncellemek için Pro Plana yükseltmeniz gerekiyor", + "namespaceAlreadyInUse": "Bu alan adı zaten alınmış, lütfen başka bir tane deneyin", + "invalidNamespace": "Geçersiz alan adı, lütfen başka bir tane deneyin", + "namespaceLengthAtLeast2Characters": "Alan adı en az 2 karakter uzunluğunda olmalıdır", + "onlyWorkspaceOwnerCanUpdateNamespace": "Alan adını yalnızca çalışma alanı sahibi güncelleyebilir", + "onlyWorkspaceOwnerCanRemoveHomepage": "Ana sayfayı yalnızca çalışma alanı sahibi kaldırabilir", + "setHomepageFailed": "Ana sayfa ayarlanamadı", + "namespaceTooLong": "Alan adı çok uzun, lütfen başka bir tane deneyin", + "namespaceTooShort": "Alan adı çok kısa, lütfen başka bir tane deneyin", + "namespaceIsReserved": "Bu alan adı rezerve edilmiş, lütfen başka bir tane deneyin", + "updatePathNameFailed": "Yol adı güncellenemedi", + "removeHomePageFailed": "Ana sayfa kaldırılamadı", + "publishNameContainsInvalidCharacters": "Yol adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", + "publishNameTooShort": "Yol adı çok kısa, lütfen başka bir tane deneyin", + "publishNameTooLong": "Yol adı çok uzun, lütfen başka bir tane deneyin", + "publishNameAlreadyInUse": "Bu yol adı zaten kullanımda, lütfen başka bir tane deneyin", + "namespaceContainsInvalidCharacters": "Alan adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", + "publishPermissionDenied": "Yayın ayarlarını yalnızca çalışma alanı sahibi veya sayfa yayıncısı yönetebilir", + "publishNameCannotBeEmpty": "Yol adı boş olamaz, lütfen başka bir tane deneyin" + }, + "success": { + "namespaceUpdated": "Alan adı başarıyla güncellendi", + "setHomepageSuccess": "Ana sayfa başarıyla ayarlandı", + "updatePathNameSuccess": "Yol adı başarıyla güncellendi", + "removeHomePageSuccess": "Ana sayfa başarıyla kaldırıldı" + } + }, "accountPage": { "menuLabel": "Hesabım", "title": "Hesabım", @@ -424,28 +568,28 @@ }, "login": { "title": "Hesap girişi", - "loginLabel": "Giriş Yap", - "logoutLabel": "Çıkış Yap" + "loginLabel": "Giriş yap", + "logoutLabel": "Çıkış yap" } }, "workspacePage": { "menuLabel": "Çalışma Alanı", "title": "Çalışma Alanı", - "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih/saat formatını ve dilini özelleştirin.", + "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih/saat biçimini ve dilini özelleştirin.", "workspaceName": { "title": "Çalışma alanı adı" }, "workspaceIcon": { "title": "Çalışma alanı simgesi", - "description": "Çalışma alanınız için bir resim yükleyin veya bir emoji kullanın. Simge, kenar çubuğunuzda ve bildirimlerinizde görünecektir." + "description": "Çalışma alanınız için bir resim yükleyin veya emoji kullanın. Simge, kenar çubuğunuzda ve bildirimlerinizde görünecektir." }, "appearance": { "title": "Görünüm", - "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarihini, saatini ve dilini özelleştirin.", + "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", "options": { "system": "Otomatik", - "light": "Açık", - "dark": "Koyu" + "light": "Aydınlık", + "dark": "Karanlık" } }, "resetCursorColor": { @@ -456,10 +600,13 @@ "title": "Belge seçim rengini sıfırla", "description": "Seçim rengini sıfırlamak istediğinizden emin misiniz?" }, + "resetWidth": { + "resetSuccess": "Belge genişliği başarıyla sıfırlandı" + }, "theme": { "title": "Tema", "description": "Önceden ayarlanmış bir tema seçin veya kendi özel temanızı yükleyin.", - "uploadCustomThemeTooltip": "Özel bir tema yükle" + "uploadCustomThemeTooltip": "Özel tema yükle" }, "workspaceFont": { "title": "Çalışma alanı yazı tipi", @@ -479,14 +626,14 @@ }, "dateTime": { "title": "Tarih ve saat", - "example": "{} tarihinde {} ({})", - "24HourTime": "24 saatlik zaman", + "example": "{} {} ({})", + "24HourTime": "24 saat biçimi", "dateFormat": { - "label": "Tarih formatı", + "label": "Tarih biçimi", "local": "Yerel", "us": "ABD", "iso": "ISO", - "friendly": "Dostça", + "friendly": "Kullanıcı dostu", "dmy": "G/A/Y" } }, @@ -495,62 +642,63 @@ }, "deleteWorkspacePrompt": { "title": "Çalışma alanını sil", - "content": "Bu çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfalar yayınlanmamış olacaktır." + "content": "Bu çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır." }, "leaveWorkspacePrompt": { - "title": "Çalışma alanından çık", - "content": "Bu çalışma alanından çıkmak istediğinizden emin misiniz? İçindeki tüm sayfalara ve verilere erişiminizi kaybedeceksiniz.", - "success": "Çalışma alanından başarıyla ayrıldınız." + "title": "Çalışma alanından ayrıl", + "content": "Bu çalışma alanından ayrılmak istediğinizden emin misiniz? İçindeki tüm sayfalara ve verilere erişiminizi kaybedeceksiniz.", + "success": "Çalışma alanından başarıyla ayrıldınız.", + "fail": "Çalışma alanından ayrılınamadı." }, "manageWorkspace": { "title": "Çalışma alanını yönet", - "leaveWorkspace": "Çalışma alanından çık", + "leaveWorkspace": "Çalışma alanından ayrıl", "deleteWorkspace": "Çalışma alanını sil" } }, "manageDataPage": { - "menuLabel": "Verileri Yönet", - "title": "Verileri Yönet", - "description": "Veri yerel depolamayı yönetin veya mevcut verilerinizi @:appName'a aktarın.", + "menuLabel": "Verileri yönet", + "title": "Verileri yönet", + "description": "Yerel depolama verilerini yönetin veya mevcut verilerinizi @:appName'e aktarın.", "dataStorage": { "title": "Dosya depolama konumu", "tooltip": "Dosyalarınızın depolandığı konum", "actions": { "change": "Yolu değiştir", "open": "Klasörü aç", - "openTooltip": "Geçerli veri klasörü konumunu aç", + "openTooltip": "Mevcut veri klasörü konumunu aç", "copy": "Yolu kopyala", "copiedHint": "Yol kopyalandı!", "resetTooltip": "Varsayılan konuma sıfırla" }, "resetDialog": { "title": "Emin misiniz?", - "description": "Yolu varsayılan veri konumuna sıfırlamak verilerinizi silmez. Geçerli verilerinizi yeniden almak istiyorsanız, önce geçerli konumunuzun yolunu kopyalamanız gerekir." + "description": "Yolu varsayılan veri konumuna sıfırlamak verilerinizi silmeyecektir. Mevcut verilerinizi yeniden içe aktarmak istiyorsanız, önce mevcut konumunuzun yolunu kopyalamalısınız." } }, "importData": { - "title": "Veri al", - "tooltip": "@:appName yedeklerinden/veri klasörlerinden veri al", - "description": "Harici bir @:appName veri klasöründen veri kopyalayın", + "title": "Veri içe aktar", + "tooltip": "@:appName yedeklerinden/veri klasörlerinden veri içe aktar", + "description": "Harici bir @:appName veri klasöründen veri kopyala", "action": "Dosyaya göz at" }, "encryption": { "title": "Şifreleme", "tooltip": "Verilerinizin nasıl depolandığını ve şifrelendiğini yönetin", "descriptionNoEncryption": "Şifrelemeyi açmak tüm verileri şifreleyecektir. Bu işlem geri alınamaz.", - "descriptionEncrypted": "Verileriniz şifrelendi.", + "descriptionEncrypted": "Verileriniz şifrelenmiş.", "action": "Verileri şifrele", "dialog": { - "title": "Tüm verilerinizi şifrelemek ister misiniz?", - "description": "Tüm verilerinizi şifrelemek verilerinizi güvende ve emniyette tutacak. Bu işlem geri alınamaz. Devam etmek istediğinizden emin misiniz?" + "title": "Tüm verileriniz şifrelensin mi?", + "description": "Tüm verilerinizi şifrelemek, verilerinizi güvenli ve emniyetli tutacaktır. Bu işlem GERİ ALINAMAZ. Devam etmek istediğinizden emin misiniz?" } }, "cache": { "title": "Önbelleği temizle", - "description": "Görüntülerin yüklenmemesinde veya yazı tiplerinin düzgün görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleğinizi temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmaz.", + "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", "dialog": { "title": "Önbelleği temizle", - "description": "Görüntülerin yüklenmemesinde veya yazı tiplerinin düzgün görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleğinizi temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmaz.", + "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", "successHint": "Önbellek temizlendi!" } }, @@ -566,37 +714,37 @@ "editBindingHint": "Yeni bağlama girin", "searchHint": "Ara", "actions": { - "resetDefault": "Varsayılanı sıfırla" + "resetDefault": "Varsayılana sıfırla" }, "errorPage": { "message": "Kısayollar yüklenemedi: {}", - "howToFix": "Lütfen tekrar deneyin, sorun devam ederse lütfen GitHub'da bize ulaşın." + "howToFix": "Lütfen tekrar deneyin, sorun devam ederse GitHub üzerinden bize ulaşın." }, "resetDialog": { "title": "Kısayolları sıfırla", - "description": "Bu, tüm tuş bağlamalarınızı varsayılana sıfırlayacaktır, bunu daha sonra geri alamazsınız, devam etmek istediğinizden emin misiniz?", + "description": "Bu işlem tüm tuş bağlamalarınızı varsayılana sıfırlayacak, daha sonra geri alamazsınız, devam etmek istediğinizden emin misiniz?", "buttonLabel": "Sıfırla" }, "conflictDialog": { "title": "{} şu anda kullanımda", - "descriptionPrefix": "Bu tuş bağlaması şu anda", - "descriptionSuffix": "tarafından kullanılıyor. Bu tuş bağlamasını değiştirirseniz, {}'dan kaldırılacaktır.", - "confirmLabel": "Devam Et" + "descriptionPrefix": "Bu tuş bağlaması şu anda ", + "descriptionSuffix": " tarafından kullanılıyor. Bu tuş bağlamasını değiştirirseniz, {} üzerinden kaldırılacak.", + "confirmLabel": "Devam et" }, "editTooltip": "Tuş bağlamasını düzenlemeye başlamak için basın", "keybindings": { - "toggleToDoList": "Yapılacaklar listesini değiştir", + "toggleToDoList": "Yapılacaklar listesini aç/kapat", "insertNewParagraphInCodeblock": "Yeni paragraf ekle", "pasteInCodeblock": "Kod bloğuna yapıştır", "selectAllCodeblock": "Tümünü seç", "indentLineCodeblock": "Satır başına iki boşluk ekle", - "outdentLineCodeblock": "Satır başındaki iki boşluğu sil", - "twoSpacesCursorCodeblock": "İmlece iki boşluk ekle", + "outdentLineCodeblock": "Satır başından iki boşluk sil", + "twoSpacesCursorCodeblock": "İmleç konumuna iki boşluk ekle", "copy": "Seçimi kopyala", - "paste": "İçeriğe yapıştır", + "paste": "İçeriği yapıştır", "cut": "Seçimi kes", "alignLeft": "Metni sola hizala", - "alignCenter": "Metni ortaya hizala", + "alignCenter": "Metni ortala", "alignRight": "Metni sağa hizala", "undo": "Geri al", "redo": "Yinele", @@ -604,9 +752,9 @@ "backspace": "Sil", "deleteLeftWord": "Sol kelimeyi sil", "deleteLeftSentence": "Sol cümleyi sil", - "delete": "Sağ karakteri sil", - "deleteMacOS": "Sol karakteri sil", - "deleteRightWord": "Sağ kelimeyi sil", + "delete": "Sağdaki karakteri sil", + "deleteMacOS": "Soldaki karakteri sil", + "deleteRightWord": "Sağdaki kelimeyi sil", "moveCursorLeft": "İmleci sola taşı", "moveCursorBeginning": "İmleci başa taşı", "moveCursorLeftWord": "İmleci bir kelime sola taşı", @@ -616,54 +764,54 @@ "moveCursorRight": "İmleci sağa taşı", "moveCursorEnd": "İmleci sona taşı", "moveCursorRightWord": "İmleci bir kelime sağa taşı", - "moveCursorRightSelect": "Seç ve imleci bir sağa taşı", + "moveCursorRightSelect": "Seç ve imleci sağa taşı", "moveCursorEndSelect": "Seç ve imleci sona taşı", "moveCursorRightWordSelect": "Seç ve imleci bir kelime sağa taşı", "moveCursorUp": "İmleci yukarı taşı", - "moveCursorTopSelect": "Seç ve imleci üste taşı", - "moveCursorTop": "İmleci üste taşı", + "moveCursorTopSelect": "Seç ve imleci en üste taşı", + "moveCursorTop": "İmleci en üste taşı", "moveCursorUpSelect": "Seç ve imleci yukarı taşı", - "moveCursorBottomSelect": "Seç ve imleci alta taşı", - "moveCursorBottom": "İmleci alta taşı", + "moveCursorBottomSelect": "Seç ve imleci en alta taşı", + "moveCursorBottom": "İmleci en alta taşı", "moveCursorDown": "İmleci aşağı taşı", "moveCursorDownSelect": "Seç ve imleci aşağı taşı", - "home": "Üste kaydır", - "end": "Alta kaydır", - "toggleBold": "Kalınlığı değiştir", - "toggleItalic": "İtalikliği değiştir", - "toggleUnderline": "Altı çiziliyi değiştir", - "toggleStrikethrough": "Üstü çiziliyi değiştir", - "toggleCode": "Satır içi kodu değiştir", - "toggleHighlight": "Vurgulamayı değiştir", + "home": "En üste kaydır", + "end": "En alta kaydır", + "toggleBold": "Kalın yazıyı aç/kapat", + "toggleItalic": "İtalik yazıyı aç/kapat", + "toggleUnderline": "Altı çizili yazıyı aç/kapat", + "toggleStrikethrough": "Üstü çizili yazıyı aç/kapat", + "toggleCode": "Satır içi kodu aç/kapat", + "toggleHighlight": "Vurgulamayı aç/kapat", "showLinkMenu": "Bağlantı menüsünü göster", "openInlineLink": "Satır içi bağlantıyı aç", "openLinks": "Seçili tüm bağlantıları aç", - "indent": "Girinti", - "outdent": "Girintiyi kaldır", - "exit": "Düzenlemeyi çık", + "indent": "Girinti ekle", + "outdent": "Girintiyi azalt", + "exit": "Düzenlemeden çık", "pageUp": "Bir sayfa yukarı kaydır", "pageDown": "Bir sayfa aşağı kaydır", "selectAll": "Tümünü seç", - "pasteWithoutFormatting": "İçeriği biçimlendirmeden yapıştır", + "pasteWithoutFormatting": "İçeriği biçimlendirme olmadan yapıştır", "showEmojiPicker": "Emoji seçiciyi göster", "enterInTableCell": "Tabloda satır sonu ekle", - "leftInTableCell": "Tabloda bir hücre sola taşı", - "rightInTableCell": "Tabloda bir hücre sağa taşı", - "upInTableCell": "Tabloda bir hücre yukarı taşı", - "downInTableCell": "Tabloda bir hücre aşağı", - "tabInTableCell": "Tabloda bir sonraki kullanılabilir hücreye git", + "leftInTableCell": "Tabloda bir hücre sola git", + "rightInTableCell": "Tabloda bir hücre sağa git", + "upInTableCell": "Tabloda bir hücre yukarı git", + "downInTableCell": "Tabloda bir hücre aşağı git", + "tabInTableCell": "Tabloda sonraki kullanılabilir hücreye git", "shiftTabInTableCell": "Tabloda önceki kullanılabilir hücreye git", - "backSpaceInTableCell": "Hücrenin başına dur" + "backSpaceInTableCell": "Hücrenin başında dur" }, "commands": { "codeBlockNewParagraph": "Kod bloğunun yanına yeni bir paragraf ekle", - "codeBlockIndentLines": "Kod bloğunda satır başında iki boşluk ekleyin", - "codeBlockOutdentLines": "Kod bloğundaki satır başındaki iki boşluğu silin", - "codeBlockAddTwoSpaces": "Kod bloğunda imleç konumuna iki boşluk ekleyin", - "codeBlockSelectAll": "Kod bloğunun içindeki tüm içeriği seçin", - "codeBlockPasteText": "Kod bloğuna metin yapıştırın", + "codeBlockIndentLines": "Kod bloğunda satır başına iki boşluk ekle", + "codeBlockOutdentLines": "Kod bloğunda satır başından iki boşluk sil", + "codeBlockAddTwoSpaces": "Kod bloğunda imleç konumuna iki boşluk ekle", + "codeBlockSelectAll": "Kod bloğu içindeki tüm içeriği seç", + "codeBlockPasteText": "Kod bloğuna metin yapıştır", "textAlignLeft": "Metni sola hizala", - "textAlignCenter": "Metni ortaya hizala", + "textAlignCenter": "Metni ortala", "textAlignRight": "Metni sağa hizala" }, "couldNotLoadErrorMsg": "Kısayollar yüklenemedi, tekrar deneyin", @@ -673,28 +821,35 @@ "title": "Yapay Zeka Ayarları", "menuLabel": "Yapay Zeka Ayarları", "keys": { - "enableAISearchTitle": "Yapay Zeka Araması", - "aiSettingsDescription": "@:appName'da kullanılan Yapay Zeka modellerini seçin veya yapılandırın. En iyi performans için varsayılan model seçeneklerini kullanmanızı öneririz", - "loginToEnableAIFeature": "Yapay zeka özellikleri yalnızca @:appName Cloud ile giriş yaptıktan sonra etkinleştirilir. Bir @:appName hesabınız yoksa, kaydolmak için 'Hesabım'a gidin", + "enableAISearchTitle": "Yapay Zeka Arama", + "aiSettingsDescription": "AppFlowy Yapay Zeka'yı güçlendirmek için tercih ettiğiniz modeli seçin. Şu anda GPT 4-o, Claude 3,5, Llama 3.1 ve Mistral 7B içerir", + "loginToEnableAIFeature": "Yapay Zeka özellikleri yalnızca @:appName Cloud ile giriş yaptıktan sonra etkinleştirilir. Bir @:appName hesabınız yoksa, kaydolmak için 'Hesabım'a gidin", "llmModel": "Dil Modeli", "llmModelType": "Dil Modeli Türü", - "downloadLLMPrompt": "{}'ı indir", - "downloadLLMPromptDetail": "{} yerel modelini indirmek {}'a kadar depolama alanı kaplayacaktır. Devam etmek istiyor musunuz?", - "downloadAIModelButton": "Yapay Zeka modelini indir", + "downloadLLMPrompt": "{} İndir", + "downloadAppFlowyOfflineAI": "Yapay Zeka çevrimdışı paketini indirmek, Yapay Zeka'nın cihazınızda çalışmasını sağlayacak. Devam etmek istiyor musunuz?", + "downloadLLMPromptDetail": "{} yerel modelini indirmek {} depolama alanı kullanacak. Devam etmek istiyor musunuz?", + "downloadBigFilePrompt": "İndirmenin tamamlanması yaklaşık 10 dakika sürebilir", + "downloadAIModelButton": "İndir", "downloadingModel": "İndiriliyor", "localAILoaded": "Yerel Yapay Zeka Modeli başarıyla eklendi ve kullanıma hazır", - "localAIStart": "Yerel Yapay Zeka Sohbeti başlıyor...", - "localAILoading": "Yerel Yapay Zeka Sohbeti Modeli yükleniyor...", + "localAIStart": "Yerel Yapay Zeka Sohbeti başlatılıyor...", + "localAILoading": "Yerel Yapay Zeka Sohbet Modeli yükleniyor...", "localAIStopped": "Yerel Yapay Zeka durduruldu", "failToLoadLocalAI": "Yerel Yapay Zeka başlatılamadı", "restartLocalAI": "Yerel Yapay Zeka'yı Yeniden Başlat", + "disableLocalAITitle": "Yerel Yapay Zeka'yı devre dışı bırak", + "disableLocalAIDescription": "Yerel Yapay Zeka'yı devre dışı bırakmak istiyor musunuz?", "localAIToggleTitle": "Yerel Yapay Zeka'yı etkinleştirmek veya devre dışı bırakmak için değiştirin", - "offlineAIDownload2": "indir", - "activeOfflineAI": "Aktif", + "offlineAIInstruction1": "Çevrimdışı Yapay Zeka'yı etkinleştirmek için", + "offlineAIInstruction2": "talimatları", + "offlineAIInstruction3": "takip edin.", + "offlineAIDownload1": "AppFlowy Yapay Zeka'yı henüz indirmediyseniz, lütfen", + "offlineAIDownload2": "indirin", + "offlineAIDownload3": "önce", + "activeOfflineAI": "Etkin", "downloadOfflineAI": "İndir", - "openModelDirectory": "Klasörü aç", - "disableLocalAIDialog": "Yerel Yapay Zeka'yı devre dışı bırakmak istiyor musunuz?", - "fetchLocalModel": "Yerel model yapılandırmasını getir" + "openModelDirectory": "Klasörü aç" } }, "planPage": { @@ -711,11 +866,11 @@ "aiResponseUsage": "{} / {}", "unlimitedAILabel": "Sınırsız yanıt", "proBadge": "Pro", - "aiMaxBadge": "AI Max", - "aiOnDeviceBadge": "AI Cihaz Üzerinde", + "aiMaxBadge": "Yapay Zeka Max", + "aiOnDeviceBadge": "Mac için Cihaz Üzerinde Yapay Zeka", "memberProToggle": "Daha fazla üye ve sınırsız Yapay Zeka", - "aiMaxToggle": "Sınırsız Yapay Zeka yanıtı", - "aiOnDeviceToggle": "Üstün gizlilik için cihaz üzerinde Yapay Zeka", + "aiMaxToggle": "Sınırsız Yapay Zeka ve gelişmiş modellere erişim", + "aiOnDeviceToggle": "Maksimum gizlilik için yerel Yapay Zeka", "aiCredit": { "title": "@:appName Yapay Zeka Kredisi Ekle", "price": "{}", @@ -726,13 +881,13 @@ "infoItemTwo": "Çalışma alanı başına 1.000 yanıt" }, "currentPlan": { - "bannerLabel": "Geçerli plan", + "bannerLabel": "Mevcut plan", "freeTitle": "Ücretsiz", "proTitle": "Pro", "teamTitle": "Takım", - "freeInfo": "Bireyler veya en fazla 3 üyeden oluşan küçük ekipler için mükemmel.", - "proInfo": "En fazla 10 üyeden oluşan küçük ve orta ölçekli ekipler için mükemmel.", - "teamInfo": "Üretken ve iyi organize olmuş tüm ekipler için mükemmel.", + "freeInfo": "2 üyeye kadar bireyler için her şeyi düzenlemek için mükemmel", + "proInfo": "10 üyeye kadar küçük ve orta ölçekli takımlar için mükemmel.", + "teamInfo": "Tüm üretken ve iyi organize edilmiş takımlar için mükemmel.", "upgrade": "Planı değiştir", "canceledInfo": "Planınız iptal edildi, {} tarihinde Ücretsiz plana düşürüleceksiniz." }, @@ -741,24 +896,23 @@ "addLabel": "Ekle", "activeLabel": "Eklendi", "aiMax": { - "title": "AI Max", - "description": "Sınırsız Yapay Zeka'nın kilidini aç", + "title": "Yapay Zeka Max", + "description": "Gelişmiş Yapay Zeka modelleri tarafından desteklenen sınırsız Yapay Zeka yanıtları ve ayda 50 Yapay Zeka görüntüsü", "price": "{}", - "priceInfo": "kullanıcı başına aylık", - "billingInfo": "yıllık faturalandırılır veya {} aylık faturalandırılır" + "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma" }, "aiOnDevice": { - "title": "AI Cihaz Üzerinde", - "description": "Cihazınızda çevrimdışı Yapay Zeka", + "title": "Mac için Cihaz Üzerinde Yapay Zeka", + "description": "Mistral 7B, LLAMA 3 ve daha fazla yerel modeli makinenizde çalıştırın", "price": "{}", - "priceInfo": "kullanıcı başına aylık", - "billingInfo": "yıllık faturalandırılır veya {} aylık faturalandırılır" + "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma", + "recommend": "M1 veya daha yenisi önerilir" } }, "deal": { "bannerLabel": "Yeni yıl fırsatı!", - "title": "Ekibinizi büyütün!", - "info": "Yükseltin ve Pro ve Takım planlarında %10 indirim kazanın! @:appName Yapay Zeka dahil güçlü yeni özelliklerle çalışma alanı üretkenliğinizi artırın.", + "title": "Takımınızı büyütün!", + "info": "Yükseltin ve Pro ve Takım planlarında %10 indirim kazanın! @:appName Yapay Zeka dahil güçlü yeni özelliklerle çalışma alanı verimliliğinizi artırın.", "viewPlans": "Planları görüntüle" } } @@ -775,7 +929,7 @@ "periodButtonLabel": "Dönemi düzenle" }, "paymentDetails": { - "title": "Ödeme ayrıntıları", + "title": "Ödeme detayları", "methodLabel": "Ödeme yöntemi", "methodButtonLabel": "Yöntemi düzenle" }, @@ -785,122 +939,125 @@ "removeLabel": "Kaldır", "renewLabel": "Yenile", "aiMax": { - "label": "AI Max", - "description": "Sınırsız Yapay Zeka'nın ve gelişmiş modellerin kilidini aç", - "activeDescription": "Sonraki fatura {} tarihinde ödenecek", - "canceledDescription": "AI Max {} tarihine kadar kullanılabilecek" + "label": "Yapay Zeka Max", + "description": "Sınırsız Yapay Zeka ve gelişmiş modellerin kilidini açın", + "activeDescription": "Sonraki fatura tarihi: {}", + "canceledDescription": "Yapay Zeka Max {} tarihine kadar kullanılabilir olacak" }, "aiOnDevice": { - "label": "AI Cihaz Üzerinde", - "description": "Cihazınızda çevrimdışı sınırsız Yapay Zeka'nın kilidini aç", - "activeDescription": "Sonraki fatura {} tarihinde ödenecek", - "canceledDescription": "AI Cihaz Üzerinde {} tarihine kadar kullanılabilecek" + "label": "Mac için Cihaz Üzerinde Yapay Zeka", + "description": "Cihazınızda sınırsız Cihaz Üzerinde Yapay Zeka'nın kilidini açın", + "activeDescription": "Sonraki fatura tarihi: {}", + "canceledDescription": "Mac için Cihaz Üzerinde Yapay Zeka {} tarihine kadar kullanılabilir olacak" }, "removeDialog": { - "title": "{}'ı kaldır", - "description": "{plan}'ı kaldırmak istediğinizden emin misiniz? {plan}'ın özelliklerine ve avantajlarına hemen erişiminizi kaybedeceksiniz." + "title": "{} Kaldır", + "description": "{plan} planını kaldırmak istediğinizden emin misiniz? {plan} planının özelliklerine ve avantajlarına erişiminizi hemen kaybedeceksiniz." } }, - "currentPeriodBadge": "GEÇERLİ", + "currentPeriodBadge": "MEVCUT", "changePeriod": "Dönemi değiştir", "planPeriod": "{} dönemi", "monthlyInterval": "Aylık", - "monthlyPriceInfo": "koltuk başına aylık faturalandırılır", + "monthlyPriceInfo": "koltuk başına aylık faturalandırma", "annualInterval": "Yıllık", - "annualPriceInfo": "koltuk başına yıllık faturalandırılır" + "annualPriceInfo": "koltuk başına yıllık faturalandırma" }, "comparePlanDialog": { - "title": "Planları karşılaştır ve seç", + "title": "Plan karşılaştır ve seç", "planFeatures": "Plan\nÖzellikleri", - "current": "Geçerli", + "current": "Mevcut", "actions": { "upgrade": "Yükselt", "downgrade": "Düşür", - "current": "Geçerli" + "current": "Mevcut" }, "freePlan": { "title": "Ücretsiz", - "description": "Bireyler ve küçük grupların her şeyi organize etmesi için", + "description": "2 üyeye kadar bireyler için her şeyi düzenlemek için", "price": "{}", - "priceInfo": "sonsuza kadar ücretsiz" + "priceInfo": "Sonsuza kadar ücretsiz" }, "proPlan": { "title": "Pro", - "description": "Küçük ekiplerin projeleri ve ekip bilgisini yönetmesi için", + "description": "Küçük takımların projeleri ve takım bilgisini yönetmesi için", "price": "{}", - "priceInfo": "kullanıcı başına aylık \nyıllık faturalandırılır\n\n{} aylık faturalandırılır" + "priceInfo": "Kullanıcı başına aylık \nyıllık faturalandırma\n\n{} aylık faturalandırma" }, "planLabels": { "itemOne": "Çalışma Alanları", "itemTwo": "Üyeler", "itemThree": "Depolama", - "itemFour": "Gerçek zamanlı iş birliği", + "itemFour": "Gerçek zamanlı işbirliği", "itemFive": "Mobil uygulama", "itemSix": "Yapay Zeka Yanıtları", - "tooltipSix": "Ömür boyu, yanıt sayısının asla sıfırlanmayacağı anlamına gelir", - "tooltipSeven": "Çalışma alanınız için URL'nin bir bölümünü özelleştirmenize olanak tanır", - "itemSeven": "Özel ad alanı" + "itemFileUpload": "Dosya yüklemeleri", + "customNamespace": "Özel alan adı", + "tooltipSix": "Ömür boyu demek, yanıt sayısının asla sıfırlanmayacağı anlamına gelir", + "intelligentSearch": "Akıllı arama", + "tooltipSeven": "Çalışma alanınızın URL'sinin bir kısmını özelleştirmenize olanak tanır", + "customNamespaceTooltip": "Özel yayınlanmış site URL'si" }, "freeLabels": { - "itemOne": "çalışma alanı başına ücretlendirilir", - "itemTwo": "en fazla 3", + "itemOne": "Çalışma alanı başına ücretlendirilir", + "itemTwo": "2'ye kadar", "itemThree": "5 GB", "itemFour": "evet", "itemFive": "evet", "itemSix": "10 ömür boyu", "itemFileUpload": "7 MB'a kadar", - "itemSeven": " " + "intelligentSearch": "Akıllı arama" }, "proLabels": { - "itemOne": "çalışma alanı başına ücretlendirilir", - "itemTwo": "en fazla 10", - "itemThree": "sınırsız", + "itemOne": "Çalışma alanı başına ücretlendirilir", + "itemTwo": "10'a kadar", + "itemThree": "Sınırsız", "itemFour": "evet", "itemFive": "evet", - "itemSix": "sınırsız", + "itemSix": "Sınırsız", "itemFileUpload": "Sınırsız", - "itemSeven": " " + "intelligentSearch": "Akıllı arama" }, "paymentSuccess": { "title": "Artık {} planındasınız!", - "description": "Ödemeniz başarıyla işlendi ve planınız @:appName {} olarak yükseltildi. Plan ayrıntılarınızı Plan sayfasında görüntüleyebilirsiniz" + "description": "Ödemeniz başarıyla işleme alındı ve planınız @:appName {}'e yükseltildi. Plan detaylarınızı Plan sayfasında görüntüleyebilirsiniz" }, "downgradeDialog": { "title": "Planınızı düşürmek istediğinizden emin misiniz?", - "description": "Planınızı düşürmek sizi Ücretsiz plana geri döndürecektir. Üyeler bu çalışma alanına erişimini kaybedebilir ve Ücretsiz planın depolama sınırlarını karşılamak için alan boşaltmanız gerekebilir.", + "description": "Planınızı düşürmek sizi Ücretsiz plana geri döndürecek. Üyeler bu çalışma alanına erişimlerini kaybedebilir ve Ücretsiz planın depolama sınırlarına uymak için alan açmanız gerekebilir.", "downgradeLabel": "Planı düşür" } }, "cancelSurveyDialog": { - "title": "Gittiğinizi görmek üzücü", - "description": "Gittiğinizi görmek üzücü. @:appName'ı geliştirmemize yardımcı olmak için geri bildirimlerinizi duymak isteriz. Lütfen birkaç soruya cevaplamak için bir dakikanızı ayırın.", + "title": "Gitmenize üzüldük", + "description": "Gitmenize üzüldük. @:appName'i geliştirmemize yardımcı olmak için geri bildiriminizi duymak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", "commonOther": "Diğer", - "otherHint": "Cevabınızı buraya yazın", + "otherHint": "Yanıtınızı buraya yazın", "questionOne": { - "question": "AppFlowy Pro aboneliğinizi iptal etmenize ne sebep oldu?", + "question": "@:appName Pro aboneliğinizi iptal etmenize ne sebep oldu?", "answerOne": "Maliyet çok yüksek", "answerTwo": "Özellikler beklentileri karşılamadı", "answerThree": "Daha iyi bir alternatif buldum", - "answerFour": "Gideri haklı çıkarmak için yeterince kullanmadım", + "answerFour": "Maliyeti haklı çıkaracak kadar kullanmadım", "answerFive": "Hizmet sorunu veya teknik zorluklar" }, "questionTwo": { - "question": "Gelecekte AppFlowy Pro'ya yeniden abone olmayı ne kadar olası görüyorsunuz?", - "answerOne": "Çok olası", - "answerTwo": "Biraz olası", + "question": "Gelecekte @:appName Pro'ya yeniden abone olma olasılığınız nedir?", + "answerOne": "Çok muhtemel", + "answerTwo": "Biraz muhtemel", "answerThree": "Emin değilim", - "answerFour": "Olası değil", - "answerFive": "Hiç olası değil" + "answerFour": "Muhtemel değil", + "answerFive": "Hiç muhtemel değil" }, "questionThree": { "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", - "answerOne": "Çok kullanıcılı iş birliği", - "answerTwo": "Daha uzun süreli sürüm geçmişi", - "answerThree": "Sınırsız Yapay Zeka yanıtı", + "answerOne": "Çoklu kullanıcı işbirliği", + "answerTwo": "Daha uzun süreli versiyon geçmişi", + "answerThree": "Sınırsız Yapay Zeka yanıtları", "answerFour": "Yerel Yapay Zeka modellerine erişim" }, "questionFour": { - "question": "AppFlowy ile genel deneyiminizi nasıl tanımlarsınız?", + "question": "@:appName ile genel deneyiminizi nasıl tanımlarsınız?", "answerOne": "Harika", "answerTwo": "İyi", "answerThree": "Ortalama", @@ -909,7 +1066,8 @@ } }, "common": { - "uploadingFile": "Dosya yükleniyor. Lütfen uygulamadan ayrılmayın", + "uploadingFile": "Dosya yükleniyor. Lütfen uygulamadan çıkmayın", + "uploadNotionSuccess": "Notion zip dosyanız başarıyla yüklendi. İçe aktarma tamamlandığında bir onay e-postası alacaksınız", "reset": "Sıfırla" }, "menu": { @@ -921,46 +1079,51 @@ "open": "Ayarları Aç", "logout": "Çıkış Yap", "logoutPrompt": "Çıkış yapmak istediğinizden emin misiniz?", - "selfEncryptionLogoutPrompt": "Çıkış yapmak istediğinizden emin misiniz? Lütfen şifreleme anahtarını kopyaladığınızdan emin olun", + "selfEncryptionLogoutPrompt": "Çıkış yapmak istediğinizden emin misiniz? Lütfen şifreleme anahtarınızı kopyaladığınızdan emin olun", "syncSetting": "Senkronizasyon Ayarı", "cloudSettings": "Bulut Ayarları", "enableSync": "Senkronizasyonu etkinleştir", + "enableSyncLog": "Senkronizasyon günlüğünü etkinleştir", + "enableSyncLogWarning": "Senkronizasyon sorunlarını teşhis etmeye yardımcı olduğunuz için teşekkür ederiz. Bu, belge düzenlemelerinizi yerel bir dosyaya kaydedecek. Lütfen etkinleştirdikten sonra uygulamayı kapatıp yeniden açın", "enableEncrypt": "Verileri şifrele", "cloudURL": "Temel URL", + "webURL": "Web URL'si", "invalidCloudURLScheme": "Geçersiz Şema", "cloudServerType": "Bulut sunucusu", - "cloudServerTypeTip": "Lütfen bulut sunucusunu değiştirdikten sonra geçerli hesabınızdan çıkış yapabileceğinizi unutmayın", + "cloudServerTypeTip": "Lütfen bulut sunucusunu değiştirdikten sonra mevcut hesabınızdan çıkış yapabileceğinizi unutmayın", "cloudLocal": "Yerel", - "cloudAppFlowy": "@:appName Cloud Beta", - "cloudAppFlowySelfHost": "@:appName Cloud Kendi Sunucunuzda", - "appFlowyCloudUrlCanNotBeEmpty": "Bulut URL'si boş bırakılamaz", - "clickToCopy": "Kopyalamak için tıklayın", - "selfHostStart": "Bir sunucunuz yoksa, kendi sunucunuzu nasıl kuracağınız konusunda rehberlik için", - "selfHostContent": "belgeye", - "selfHostEnd": "başvurabilirsiniz", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName Cloud Kendi Kendine Barındırma", + "appFlowyCloudUrlCanNotBeEmpty": "Bulut URL'si boş olamaz", + "clickToCopy": "Panoya kopyala", + "selfHostStart": "Bir sunucunuz yoksa, lütfen", + "selfHostContent": "belgesine", + "selfHostEnd": "bakarak kendi sunucunuzu nasıl barındıracağınızı öğrenin", "pleaseInputValidURL": "Lütfen geçerli bir URL girin", + "changeUrl": "Kendi kendine barındırılan URL'yi {} olarak değiştir", "cloudURLHint": "Sunucunuzun temel URL'sini girin", + "webURLHint": "Web sunucunuzun temel URL'sini girin", "cloudWSURL": "Websocket URL'si", "cloudWSURLHint": "Sunucunuzun websocket adresini girin", "restartApp": "Yeniden Başlat", - "restartAppTip": "Değişikliklerin etkili olması için uygulamayı yeniden başlatın. Lütfen bunun geçerli hesabınızdan çıkış yapabileceğini unutmayın.", - "changeServerTip": "Sunucuyu değiştirdikten sonra, değişikliklerin etkili olması için yeniden başlat düğmesine tıklamanız gerekir", - "enableEncryptPrompt": "Verilerinizi korumak için şifrelemeyi etkinleştirin. Bu işlem geri alınamaz, bu yüzden şifreleme anahtarınızı güvenli bir yerde saklamanız çok önemlidir. Kopyalamak için tıklayın", - "inputEncryptPrompt": "Lütfen şifreleme anahtarınızı girin", + "restartAppTip": "Değişikliklerin etkili olması için uygulamayı yeniden başlatın. Bu işlemin mevcut hesabınızdan çıkış yapabileceğini unutmayın.", + "changeServerTip": "Sunucuyu değiştirdikten sonra, değişikliklerin etkili olması için yeniden başlat düğmesine tıklamalısınız", + "enableEncryptPrompt": "Verilerinizi bu anahtar ile güvence altına almak için şifrelemeyi etkinleştirin. Güvenli bir şekilde saklayın; etkinleştirildikten sonra kapatılamaz. Kaybedilirse, verileriniz kurtarılamaz hale gelir. Kopyalamak için tıklayın", + "inputEncryptPrompt": "Lütfen şifreleme anahtarlarını girin", "clickToCopySecret": "Anahtarı kopyalamak için tıklayın", "configServerSetting": "Sunucu ayarlarınızı yapılandırın", - "configServerGuide": "`Hızlı Başlangıç`ı seçtikten sonra, kendi sunucunuzu yapılandırmak için `Ayarlar` ve ardından \"Bulut Ayarları\"na gidin.", + "configServerGuide": "'Hızlı Başlangıç'ı seçtikten sonra, 'Ayarlar'a ve ardından \"Bulut Ayarları\"na giderek kendi kendine barındırılan sunucunuzu yapılandırın.", "inputTextFieldHint": "Anahtarınız", "historicalUserList": "Kullanıcı giriş geçmişi", - "historicalUserListTooltip": "Bu liste anonim hesaplarınızı görüntüler. Ayrıntılarını görüntülemek için bir hesaba tıklayabilirsiniz. Anonim hesaplar 'Başlayın' düğmesine tıklanarak oluşturulur", + "historicalUserListTooltip": "Bu liste anonim hesaplarınızı gösterir. Detaylarını görüntülemek için bir hesaba tıklayabilirsiniz. Anonim hesaplar 'Başla' düğmesine tıklanarak oluşturulur", "openHistoricalUser": "Anonim hesabı açmak için tıklayın", - "customPathPrompt": "@:appName veri klasörünü Google Drive gibi bulutla senkronize edilmiş bir klasörde saklamak risk oluşturabilir. Bu klasördeki veritabanına aynı anda birden fazla konumdan erişilir veya değiştirilirse, senkronizasyon çakışmaları ve olası veri bozulmasıyla sonuçlanabilir", - "importAppFlowyData": "Harici @:appName Klasöründen Veri Al", - "importingAppFlowyDataTip": "Veri aktarımı devam ediyor. Lütfen uygulamayı kapatmayın", - "importAppFlowyDataDescription": "Harici bir @:appName veri klasöründen veri kopyalayın ve geçerli AppFlowy veri klasörüne aktarın", - "importSuccess": "@:appName veri klasörü başarıyla alındı", - "importFailed": "@:appName veri klasörü alınamadı", - "importGuide": "Daha fazla bilgi için lütfen referans belgeyi kontrol edin" + "customPathPrompt": "@:appName veri klasörünü Google Drive gibi bulut senkronizasyonlu bir klasörde saklamak risk oluşturabilir. Bu klasördeki veritabanına aynı anda birden fazla konumdan erişilir veya değiştirilirse, senkronizasyon çakışmaları ve olası veri bozulmaları meydana gelebilir", + "importAppFlowyData": "Harici @:appName Klasöründen Veri İçe Aktar", + "importingAppFlowyDataTip": "Veri içe aktarma devam ediyor. Lütfen uygulamayı kapatmayın", + "importAppFlowyDataDescription": "Harici bir @:appName veri klasöründen veri kopyalayın ve mevcut AppFlowy veri klasörüne aktarın", + "importSuccess": "@:appName veri klasörü başarıyla içe aktarıldı", + "importFailed": "@:appName veri klasörünün içe aktarılması başarısız oldu", + "importGuide": "Daha fazla detay için lütfen referans belgeyi kontrol edin" }, "notifications": { "enableNotifications": { @@ -968,7 +1131,7 @@ "hint": "Yerel bildirimlerin görünmesini durdurmak için kapatın." }, "showNotificationsIcon": { - "label": "Bildirimler simgesini göster", + "label": "Bildirim simgesini göster", "hint": "Kenar çubuğundaki bildirim simgesini gizlemek için kapatın." }, "archiveNotifications": { @@ -981,17 +1144,30 @@ }, "action": { "markAsRead": "Okundu olarak işaretle", - "archive": "Arşiv" + "multipleChoice": "Daha fazla seç", + "archive": "Arşivle" }, "settings": { "settings": "Ayarlar", "markAllAsRead": "Tümünü okundu olarak işaretle", "archiveAll": "Tümünü arşivle" }, + "emptyInbox": { + "title": "Gelen Kutusu Boş!", + "description": "Burada bildirim almak için hatırlatıcılar ayarlayın." + }, + "emptyUnread": { + "title": "Okunmamış bildirim yok", + "description": "Hepsini okudunuz!" + }, + "emptyArchived": { + "title": "Arşivlenmiş öğe yok", + "description": "Arşivlenen bildirimler burada görünecek." + }, "tabs": { - "inbox": "Gelen kutusu", + "inbox": "Gelen Kutusu", "unread": "Okunmamış", - "archived": "Arşivlendi" + "archived": "Arşivlenmiş" }, "refreshSuccess": "Bildirimler başarıyla yenilendi", "titles": { @@ -1008,21 +1184,24 @@ }, "themeMode": { "label": "Tema Modu", - "light": "Açık Mod", - "dark": "Koyu Mod", - "system": "Sisteme Uyarla" + "light": "Aydınlık Mod", + "dark": "Karanlık Mod", + "system": "Sisteme Uyum Sağla" }, - "fontScaleFactor": "Yazı Tipi Ölçeklendirme Faktörü", + "fontScaleFactor": "Yazı Tipi Ölçek Faktörü", + "displaySize": "Görüntüleme Boyutu", "documentSettings": { "cursorColor": "Belge imleç rengi", "selectionColor": "Belge seçim rengi", - "pickColor": "Bir renk seçin", + "width": "Belge genişliği", + "changeWidth": "Değiştir", + "pickColor": "Bir renk seç", "colorShade": "Renk tonu", "opacity": "Opaklık", - "hexEmptyError": "Hex rengi boş bırakılamaz", - "hexLengthError": "Hex değeri 6 haneli olmalıdır", - "hexInvalidError": "Geçersiz hex değeri", - "opacityEmptyError": "Opaklık boş bırakılamaz", + "hexEmptyError": "Hex renk boş olamaz", + "hexLengthError": "Hex renk 6 haneli olmalıdır", + "hexInvalidError": "Geçersiz hex renk", + "opacityEmptyError": "Opaklık boş olamaz", "opacityRangeError": "Opaklık 1 ile 100 arasında olmalıdır", "app": "Uygulama", "flowy": "Flowy", @@ -1030,119 +1209,125 @@ }, "layoutDirection": { "label": "Düzen Yönü", - "hint": "Ekranınızdaki içeriğin akışını soldan sağa veya sağdan sola doğru kontrol edin.", - "ltr": "LTR", - "rtl": "RTL" + "hint": "Ekranınızdaki içeriğin akışını soldan sağa veya sağdan sola olarak kontrol edin.", + "ltr": "Soldan Sağa", + "rtl": "Sağdan Sola" }, "textDirection": { "label": "Varsayılan Metin Yönü", - "hint": "Metnin varsayılan olarak soldan mı yoksa sağdan mı başlaması gerektiğini belirtin.", - "ltr": "LTR", - "rtl": "RTL", + "hint": "Metnin varsayılan olarak soldan mı yoksa sağdan mı başlayacağını belirtin.", + "ltr": "Soldan Sağa", + "rtl": "Sağdan Sola", "auto": "OTOMATİK", - "fallback": "Düzen yönüyle aynı" + "fallback": "Düzen yönü ile aynı" }, "themeUpload": { "button": "Yükle", - "uploadTheme": "Temayı yükle", + "uploadTheme": "Tema yükle", "description": "Aşağıdaki düğmeyi kullanarak kendi @:appName temanızı yükleyin.", - "loading": "Lütfen temanızı doğrularken ve yüklerken bekleyin...", + "loading": "Temanızı doğrulayıp yüklerken lütfen bekleyin...", "uploadSuccess": "Temanız başarıyla yüklendi", - "deletionFailure": "Temayı silinemedi. Manuel olarak silmeyi deneyin.", - "filePickerDialogTitle": ".flowy_plugin dosyası seçin", + "deletionFailure": "Tema silinemedi. Manuel olarak silmeyi deneyin.", + "filePickerDialogTitle": "Bir .flowy_plugin dosyası seçin", "urlUploadFailure": "URL açılamadı: {}" }, "theme": "Tema", "builtInsLabel": "Yerleşik Temalar", "pluginsLabel": "Eklentiler", "dateFormat": { - "label": "Tarih formatı", + "label": "Tarih biçimi", "local": "Yerel", "us": "ABD", "iso": "ISO", - "friendly": "Dostça", + "friendly": "Kullanıcı dostu", "dmy": "G/A/Y" }, "timeFormat": { - "label": "Saat formatı", - "twelveHour": "12 saatlik", - "twentyFourHour": "24 saatlik" + "label": "Saat biçimi", + "twelveHour": "12 saat", + "twentyFourHour": "24 saat" }, "showNamingDialogWhenCreatingPage": "Sayfa oluştururken adlandırma iletişim kutusunu göster", - "enableRTLToolbarItems": "RTL araç çubuğu öğelerini etkinleştir", + "enableRTLToolbarItems": "Sağdan sola araç çubuğu öğelerini etkinleştir", "members": { - "title": "Üye Ayarları", - "inviteMembers": "Üye Davet Et", + "title": "Üye ayarları", + "inviteMembers": "Üye davet et", "inviteHint": "E-posta ile davet et", - "sendInvite": "Davetiye Gönder", - "copyInviteLink": "Davetiye Bağlantısını Kopyala", + "sendInvite": "Davet gönder", + "copyInviteLink": "Davet bağlantısını kopyala", "label": "Üyeler", "user": "Kullanıcı", "role": "Rol", "removeFromWorkspace": "Çalışma Alanından Kaldır", + "removeFromWorkspaceSuccess": "Çalışma alanından başarıyla kaldırıldı", + "removeFromWorkspaceFailed": "Çalışma alanından kaldırma başarısız oldu", "owner": "Sahip", "guest": "Misafir", "member": "Üye", "memberHintText": "Bir üye sayfaları okuyabilir ve düzenleyebilir", "guestHintText": "Bir Misafir okuyabilir, tepki verebilir, yorum yapabilir ve izin verilen belirli sayfaları düzenleyebilir.", - "emailInvalidError": "Geçersiz e-posta, lütfen kontrol edin ve tekrar deneyin", + "emailInvalidError": "Geçersiz e-posta, lütfen kontrol edip tekrar deneyin", "emailSent": "E-posta gönderildi, lütfen gelen kutunuzu kontrol edin", - "members": "üyeler", + "members": "üye", "membersCount": { "zero": "{} üye", "one": "{} üye", "other": "{} üye" }, - "memberLimitExceeded": "Üye sınırı aşıldı, daha fazla üye davet etmek için lütfen ", + "inviteFailedDialogTitle": "Davet gönderilemedi", + "inviteFailedMemberLimit": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen yükseltin.", + "inviteFailedMemberLimitMobile": "Çalışma alanınız üye sınırına ulaştı.", + "memberLimitExceeded": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen ", "memberLimitExceededUpgrade": "yükseltin", - "memberLimitExceededPro": "Üye sınırı aşıldı, daha fazla üyeye ihtiyacınız varsa lütfen ", - "memberLimitExceededProContact": "support@appflowy.io ile iletişime geçin", + "memberLimitExceededPro": "Üye sınırına ulaşıldı, daha fazla üye gerekiyorsa ", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Üye eklenemedi", "addMemberSuccess": "Üye başarıyla eklendi", "removeMember": "Üyeyi Kaldır", "areYouSureToRemoveMember": "Bu üyeyi kaldırmak istediğinizden emin misiniz?", - "inviteMemberSuccess": "Davetiye başarıyla gönderildi", + "inviteMemberSuccess": "Davet başarıyla gönderildi", "failedToInviteMember": "Üye davet edilemedi", - "workspaceMembersError": "Oops, bir şeyler ters gitti" + "workspaceMembersError": "Hay aksi, bir şeyler yanlış gitti", + "workspaceMembersErrorDescription": "Şu anda üye listesini yükleyemedik. Lütfen daha sonra tekrar deneyin" } }, "files": { "copy": "Kopyala", - "defaultLocation": "Dosyaları ve verileri okuma konumu", + "defaultLocation": "Dosyaları ve veri depolama konumunu oku", "exportData": "Verilerinizi dışa aktarın", - "doubleTapToCopy": "Yolu kopyalamak için iki kez dokunun", + "doubleTapToCopy": "Yolu kopyalamak için çift dokunun", "restoreLocation": "@:appName varsayılan yoluna geri yükle", "customizeLocation": "Başka bir klasör aç", "restartApp": "Değişikliklerin etkili olması için lütfen uygulamayı yeniden başlatın.", "exportDatabase": "Veritabanını dışa aktar", "selectFiles": "Dışa aktarılması gereken dosyaları seçin", "selectAll": "Tümünü seç", - "deselectAll": "Tüm seçimleri kaldır", - "createNewFolder": "Yeni bir klasör oluştur", - "createNewFolderDesc": "Verilerinizi nerede saklamak istediğinizi bize bildirin", - "defineWhereYourDataIsStored": "Verilerinizin nerede saklandığını tanımlayın", + "deselectAll": "Tüm seçimi kaldır", + "createNewFolder": "Yeni klasör oluştur", + "createNewFolderDesc": "Verilerinizi nerede saklamak istediğinizi bize söyleyin", + "defineWhereYourDataIsStored": "Verilerinizin nerede saklanacağını tanımlayın", "open": "Aç", - "openFolder": "Mevcut bir klasörü aç", - "openFolderDesc": "Mevcut @:appName klasörünüze okuyun ve yazın", + "openFolder": "Mevcut bir klasör aç", + "openFolderDesc": "Mevcut @:appName klasörünüzü okuyun ve yazın", "folderHintText": "klasör adı", - "location": "Yeni bir klasör oluşturuluyor", + "location": "Yeni klasör oluşturma", "locationDesc": "@:appName veri klasörünüz için bir ad seçin", - "browser": "Gözat", + "browser": "Göz at", "create": "Oluştur", "set": "Ayarla", "folderPath": "Klasörünüzü saklamak için yol", - "locationCannotBeEmpty": "Yol boş bırakılamaz", + "locationCannotBeEmpty": "Yol boş olamaz", "pathCopiedSnackbar": "Dosya depolama yolu panoya kopyalandı!", "changeLocationTooltips": "Veri dizinini değiştir", "change": "Değiştir", "openLocationTooltips": "Başka bir veri dizini aç", - "openCurrentDataFolder": "Geçerli veri dizinini aç", - "recoverLocationTooltips": "@:appName'nin varsayılan veri dizinine sıfırla", + "openCurrentDataFolder": "Mevcut veri dizinini aç", + "recoverLocationTooltips": "@:appName'in varsayılan veri dizinine sıfırla", "exportFileSuccess": "Dosya başarıyla dışa aktarıldı!", "exportFileFail": "Dosya dışa aktarılamadı!", - "export": "Dışa Aktar", + "export": "Dışa aktar", "clearCache": "Önbelleği temizle", - "clearCacheDesc": "Görüntülerin yüklenmemesinde veya yazı tiplerinin düzgün görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleğinizi temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmaz.", + "clearCacheDesc": "Resimlerin yüklenmemesi veya yazı tiplerinin doğru görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleği temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmayacaktır.", "areYouSureToClearCache": "Önbelleği temizlemek istediğinizden emin misiniz?", "clearCacheSuccess": "Önbellek başarıyla temizlendi!" }, @@ -1150,26 +1335,25 @@ "name": "Ad", "email": "E-posta", "tooltipSelectIcon": "Simge seç", - "selectAnIcon": "Bir simge seçin", - "pleaseInputYourOpenAIKey": "Lütfen Yapay Zeka anahtarınızı girin", - "clickToLogout": "Geçerli kullanıcıdan çıkış yapmak için tıklayın", - "pleaseInputYourStabilityAIKey": "Lütfen Stability Yapay Zeka anahtarınızı girin" + "selectAnIcon": "Bir simge seç", + "pleaseInputYourOpenAIKey": "lütfen Yapay Zeka anahtarınızı girin", + "clickToLogout": "Mevcut kullanıcının oturumunu kapatmak için tıklayın" }, "mobile": { "personalInfo": "Kişisel Bilgiler", "username": "Kullanıcı Adı", - "usernameEmptyError": "Kullanıcı adı boş bırakılamaz", + "usernameEmptyError": "Kullanıcı adı boş olamaz", "about": "Hakkında", - "pushNotifications": "Anında Bildirimler", + "pushNotifications": "Anlık Bildirimler", "support": "Destek", "joinDiscord": "Discord'da bize katılın", "privacyPolicy": "Gizlilik Politikası", "userAgreement": "Kullanıcı Sözleşmesi", "termsAndConditions": "Şartlar ve Koşullar", "userprofileError": "Kullanıcı profili yüklenemedi", - "userprofileErrorDescription": "Lütfen sorunun devam edip etmediğini kontrol etmek için çıkış yapıp tekrar giriş yapmayı deneyin.", - "selectLayout": "Düzen seçin", - "selectStartingDay": "Başlangıç gününü seçin", + "userprofileErrorDescription": "Lütfen sorunun devam edip etmediğini kontrol etmek için oturumu kapatıp yeniden giriş yapmayı deneyin.", + "selectLayout": "Düzen seç", + "selectStartingDay": "Başlangıç gününü seç", "version": "Sürüm" } }, @@ -1177,18 +1361,18 @@ "deleteView": "Bu görünümü silmek istediğinizden emin misiniz?", "createView": "Yeni", "title": { - "placeholder": "İsimsiz" + "placeholder": "Başlıksız" }, "settings": { "filter": "Filtre", "sort": "Sırala", - "sortBy": "Şuna göre sırala", + "sortBy": "Sıralama ölçütü", "properties": "Özellikler", "reorderPropertiesTooltip": "Özellikleri yeniden sıralamak için sürükleyin", "group": "Grupla", "addFilter": "Filtre Ekle", "deleteFilter": "Filtreyi sil", - "filterBy": "Şuna göre filtrele...", + "filterBy": "Filtreleme ölçütü", "typeAValue": "Bir değer yazın...", "layout": "Düzen", "databaseLayout": "Düzen", @@ -1201,103 +1385,113 @@ "boardSettings": "Pano ayarları", "calendarSettings": "Takvim ayarları", "createView": "Yeni görünüm", - "duplicateView": "Görünümü kopyala", + "duplicateView": "Görünümü çoğalt", "deleteView": "Görünümü sil", "numberOfVisibleFields": "{} gösteriliyor" }, "filter": { - "addFilter": "Filtre ekle" + "empty": "Aktif filtre yok", + "addFilter": "Filtre ekle", + "cannotFindCreatableField": "Filtrelenecek uygun bir alan bulunamadı", + "conditon": "Koşul", + "where": "Koşul" }, "textFilter": { "contains": "İçerir", "doesNotContain": "İçermez", - "endsWith": "Şununla biter", - "startWith": "Şununla başlar", - "is": "Şudur", - "isNot": "Şu değildir", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil", + "endsWith": "İle biter", + "startWith": "İle başlar", + "is": "Eşittir", + "isNot": "Eşit değildir", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir", "choicechipPrefix": { "isNot": "Değil", - "startWith": "Şununla başlar", - "endWith": "Şununla biter", - "isEmpty": "boş", - "isNotEmpty": "boş değil" + "startWith": "İle başlar", + "endWith": "İle biter", + "isEmpty": "boştur", + "isNotEmpty": "boş değildir" } }, "checkboxFilter": { "isChecked": "İşaretli", "isUnchecked": "İşaretsiz", "choicechipPrefix": { - "is": "işaretli" + "is": "eşittir" } }, "checklistFilter": { - "isComplete": "tamamlandı", - "isIncomplted": "tamamlanmadı" + "isComplete": "Tamamlandı", + "isIncomplted": "Tamamlanmadı" }, "selectOptionFilter": { - "is": "Şudur", - "isNot": "Şu değildir", + "is": "Eşittir", + "isNot": "Eşit değildir", "contains": "İçerir", "doesNotContain": "İçermez", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" }, "dateFilter": { - "is": "Şudur", - "before": "Şundan önce", - "after": "Şundan sonra", - "onOrBefore": "Şunda veya önce", - "onOrAfter": "Şunda veya sonra", - "between": "Şunlar arasında", - "empty": "Boş", - "notEmpty": "Boş değil", + "is": "Tarihinde", + "before": "Öncesinde", + "after": "Sonrasında", + "onOrBefore": "Tarihinde veya öncesinde", + "onOrAfter": "Tarihinde veya sonrasında", + "between": "Arasında", + "empty": "Boştur", + "notEmpty": "Boş değildir", + "startDate": "Başlangıç tarihi", + "endDate": "Bitiş tarihi", "choicechipPrefix": { "before": "Önce", "after": "Sonra", - "onOrBefore": "Şunda veya önce", - "onOrAfter": "Şunda veya sonra", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "between": "Arasında", + "onOrBefore": "Tarihinde veya önce", + "onOrAfter": "Tarihinde veya sonra", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" } }, "numberFilter": { "equal": "Eşittir", "notEqual": "Eşit değildir", - "lessThan": "Şundan küçüktür", - "greaterThan": "Şundan büyüktür", - "lessThanOrEqualTo": "Şundan küçük veya eşittir", - "greaterThanOrEqualTo": "Şundan büyük veya eşittir", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "lessThan": "Küçüktür", + "greaterThan": "Büyüktür", + "lessThanOrEqualTo": "Küçük veya eşittir", + "greaterThanOrEqualTo": "Büyük veya eşittir", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" }, "field": { - "hide": "Gizle", - "show": "Göster", - "insertLeft": "Sola Ekle", - "insertRight": "Sağa Ekle", - "duplicate": "Kopyala", + "label": "Özellik", + "hide": "Özelliği gizle", + "show": "Özelliği göster", + "insertLeft": "Sola ekle", + "insertRight": "Sağa ekle", + "duplicate": "Çoğalt", "delete": "Sil", "wrapCellContent": "Metni kaydır", "clear": "Hücreleri temizle", + "switchPrimaryFieldTooltip": "Birincil alanın alan türü değiştirilemez", "textFieldName": "Metin", - "checkboxFieldName": "Onay Kutusu", + "checkboxFieldName": "Onay kutusu", "dateFieldName": "Tarih", - "updatedAtFieldName": "Son Değiştirilme", - "createdAtFieldName": "Oluşturulma Tarihi", + "updatedAtFieldName": "Son değiştirilme", + "createdAtFieldName": "Oluşturulma tarihi", "numberFieldName": "Sayılar", - "singleSelectFieldName": "Seçenek", - "multiSelectFieldName": "Çoklu Seçenek", + "singleSelectFieldName": "Seçim", + "multiSelectFieldName": "Çoklu seçim", "urlFieldName": "URL", - "checklistFieldName": "Kontrol Listesi", + "checklistFieldName": "Kontrol listesi", "relationFieldName": "İlişki", "summaryFieldName": "Yapay Zeka Özeti", - "timeFieldName": "Zaman", - "translateFieldName": "Yapay Zeka Çevirisi", - "translateTo": "Şuna çevir", - "numberFormat": "Sayı formatı", - "dateFormat": "Tarih formatı", + "timeFieldName": "Saat", + "mediaFieldName": "Dosyalar ve medya", + "translateFieldName": "Yapay Zeka Çeviri", + "translateTo": "Şu dile çevir", + "numberFormat": "Sayı biçimi", + "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", "dateFormatFriendly": "Ay Gün, Yıl", @@ -1305,15 +1499,15 @@ "dateFormatLocal": "Ay/Gün/Yıl", "dateFormatUS": "Yıl/Ay/Gün", "dateFormatDayMonthYear": "Gün/Ay/Yıl", - "timeFormat": "Saat formatı", - "invalidTimeFormat": "Geçersiz format", + "timeFormat": "Saat biçimi", + "invalidTimeFormat": "Geçersiz biçim", "timeFormatTwelveHour": "12 saat", "timeFormatTwentyFourHour": "24 saat", "clearDate": "Tarihi temizle", "dateTime": "Tarih saat", "startDateTime": "Başlangıç tarih saati", "endDateTime": "Bitiş tarih saati", - "failedToLoadDate": "Tarih bilgisi yüklenemedi", + "failedToLoadDate": "Tarih değeri yüklenemedi", "selectTime": "Saat seç", "selectDate": "Tarih seç", "visibility": "Görünürlük", @@ -1324,15 +1518,16 @@ "addOption": "Seçenek ekle", "editProperty": "Özelliği düzenle", "newProperty": "Yeni özellik", - "deleteFieldPromptMessage": "Emin misiniz? Bu özellik silinecek", + "openRowDocument": "Sayfa olarak aç", + "deleteFieldPromptMessage": "Emin misiniz? Bu özellik ve tüm verileri silinecek", "clearFieldPromptMessage": "Emin misiniz? Bu sütundaki tüm hücreler boşaltılacak", - "newColumn": "Yeni Sütun", - "format": "Format", - "reminderOnDateTooltip": "Bu hücrenin planlanmış bir hatırlatıcısı var", + "newColumn": "Yeni sütun", + "format": "Biçim", + "reminderOnDateTooltip": "Bu hücrede planlanmış bir hatırlatıcı var", "optionAlreadyExist": "Seçenek zaten mevcut" }, "rowPage": { - "newField": "Yeni bir alan ekle", + "newField": "Yeni alan ekle", "fieldDragElementTooltip": "Menüyü açmak için tıklayın", "showHiddenFields": { "one": "{count} gizli alanı göster", @@ -1345,36 +1540,42 @@ "other": "{count} gizli alanı gizle" }, "openAsFullPage": "Tam sayfa olarak aç", - "moreRowActions": "Daha fazla satır eylemi" + "moreRowActions": "Daha fazla satır işlemi" }, "sort": { "ascending": "Artan", "descending": "Azalan", - "by": "Şuna göre", + "by": "Göre", "empty": "Aktif sıralama yok", - "cannotFindCreatableField": "Sıralama yapmak için uygun bir alan bulunamadı", + "cannotFindCreatableField": "Sıralanacak uygun bir alan bulunamadı", "deleteAllSorts": "Tüm sıralamaları sil", - "addSort": "Yeni sıralama ekle", - "removeSorting": "Sıralamayı kaldırmak ister misiniz?", - "fieldInUse": "Zaten bu alana göre sıralama yapıyorsunuz" + "addSort": "Sıralama ekle", + "sortsActive": "Sıralama yaparken {intention} yapılamaz", + "removeSorting": "Bu görünümdeki tüm sıralamaları kaldırıp devam etmek istiyor musunuz?", + "fieldInUse": "Bu alana göre zaten sıralama yapıyorsunuz" }, "row": { - "duplicate": "Kopyala", + "label": "Satır", + "duplicate": "Çoğalt", "delete": "Sil", - "titlePlaceholder": "İsimsiz", + "titlePlaceholder": "Başlıksız", "textPlaceholder": "Boş", "copyProperty": "Özellik panoya kopyalandı", "count": "Sayı", "newRow": "Yeni satır", - "action": "Eylem", - "add": "Alta eklemek için tıklayın", + "loadMore": "Daha fazla yükle", + "action": "İşlem", + "add": "Aşağıya eklemek için tıklayın", "drag": "Taşımak için sürükleyin", - "deleteRowPrompt": "Bu satırı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz", - "deleteCardPrompt": "Bu kartı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz", + "deleteRowPrompt": "Bu satırı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "deleteCardPrompt": "Bu kartı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "dragAndClick": "Taşımak için sürükleyin, menüyü açmak için tıklayın", "insertRecordAbove": "Üste kayıt ekle", "insertRecordBelow": "Alta kayıt ekle", - "noContent": "İçerik yok" + "noContent": "İçerik yok", + "reorderRowDescription": "satırı yeniden sırala", + "createRowAboveDescription": "üste bir satır oluştur", + "createRowBelowDescription": "alta bir satır ekle" }, "selectOption": { "create": "Oluştur", @@ -1383,15 +1584,15 @@ "lightPinkColor": "Açık Pembe", "orangeColor": "Turuncu", "yellowColor": "Sarı", - "limeColor": "Limoni", + "limeColor": "Limon", "greenColor": "Yeşil", - "aquaColor": "Su yeşili", + "aquaColor": "Su Mavisi", "blueColor": "Mavi", "deleteTag": "Etiketi sil", "colorPanelTitle": "Renk", - "panelTitle": "Bir seçenek seçin veya yeni bir tane oluşturun", + "panelTitle": "Bir seçenek seçin veya oluşturun", "searchOption": "Bir seçenek arayın", - "searchOrCreateOption": "Bir seçenek arayın veya yeni bir tane oluşturun", + "searchOrCreateOption": "Bir seçenek arayın veya oluşturun", "createNew": "Yeni oluştur", "orSelectOne": "Veya bir seçenek seçin", "typeANewOption": "Yeni bir seçenek yazın", @@ -1399,7 +1600,7 @@ }, "checklist": { "taskHint": "Görev açıklaması", - "addNew": "Yeni bir görev ekle", + "addNew": "Yeni görev ekle", "submitNewTask": "Oluştur", "hideComplete": "Tamamlanan görevleri gizle", "showComplete": "Tüm görevleri göster" @@ -1407,43 +1608,51 @@ "url": { "launch": "Bağlantıyı tarayıcıda aç", "copy": "Bağlantıyı panoya kopyala", - "textFieldHint": "Bir URL girin", - "copiedNotification": "Panoya kopyalandı!" + "textFieldHint": "Bir URL girin" }, "relation": { - "relatedDatabasePlaceLabel": "İlgili Veritabanı", + "relatedDatabasePlaceLabel": "İlişkili Veritabanı", "relatedDatabasePlaceholder": "Yok", "inRelatedDatabase": "İçinde", "rowSearchTextFieldPlaceholder": "Ara", - "noDatabaseSelected": "Veritabanı seçilmedi, lütfen önce aşağıdaki listeden bir tane seçin:", + "noDatabaseSelected": "Veritabanı seçilmedi, lütfen aşağıdaki listeden önce bir tane seçin:", "emptySearchResult": "Kayıt bulunamadı", - "linkedRowListLabel": "{count} bağlı satır", - "unlinkedRowListLabel": "Başka bir satırı bağla" + "linkedRowListLabel": "{count} bağlantılı satır", + "unlinkedRowListLabel": "Başka bir satır bağla" }, - "menuName": "Kılavuz", + "menuName": "Tablo", "referencedGridPrefix": "Görünümü", "calculate": "Hesapla", "calculationTypeLabel": { "none": "Yok", "average": "Ortalama", - "max": "Maksimum", + "max": "En büyük", "median": "Medyan", - "min": "Minimum", + "min": "En küçük", "sum": "Toplam", "count": "Sayı", "countEmpty": "Boş sayısı", "countEmptyShort": "BOŞ", - "countNonEmpty": "Dolu sayısı", + "countNonEmpty": "Boş olmayan sayısı", "countNonEmptyShort": "DOLU" }, "media": { "rename": "Yeniden adlandır", "download": "İndir", + "expand": "Genişlet", "delete": "Sil", - "addFileOrImage": "Bir dosya, resim veya bağlantı ekleyin", + "moreFilesHint": "+{}", + "addFileOrImage": "Dosya veya bağlantı ekle", + "attachmentsHint": "{}", "addFileMobile": "Dosya ekle", - "downloadSuccess": "Dosya başarıyla kaydedildi", - "hideFileNames": "Dosya adlarını gizle" + "extraCount": "+{}", + "deleteFileDescription": "Bu dosyayı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "showFileNames": "Dosya adını göster", + "downloadSuccess": "Dosya indirildi", + "downloadFailedToken": "Dosya indirilemedi, kullanıcı jetonu mevcut değil", + "setAsCover": "Kapak olarak ayarla", + "openInBrowser": "Tarayıcıda aç", + "embedLink": "Dosya bağlantısını yerleştir" } }, "document": { @@ -1452,21 +1661,67 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "Oluşturuluyor...", "slashMenu": { "board": { - "selectABoardToLinkTo": "Bağlantı kurulacak bir pano seçin", - "createANewBoard": "Yeni bir pano oluşturun" + "selectABoardToLinkTo": "Bağlanacak bir Pano seçin", + "createANewBoard": "Yeni bir Pano oluştur" }, "grid": { - "selectAGridToLinkTo": "Bağlantı kurulacak bir kılavuz seçin", - "createANewGrid": "Yeni bir kılavuz oluşturun" + "selectAGridToLinkTo": "Bağlanacak bir Tablo seçin", + "createANewGrid": "Yeni bir Tablo oluştur" }, "calendar": { - "selectACalendarToLinkTo": "Bağlantı kurulacak bir takvim seçin", - "createANewCalendar": "Yeni bir takvim oluşturun" + "selectACalendarToLinkTo": "Bağlanacak bir Takvim seçin", + "createANewCalendar": "Yeni bir Takvim oluştur" }, "document": { - "selectADocumentToLinkTo": "Bağlantı kurulacak bir belge seçin" + "selectADocumentToLinkTo": "Bağlanacak bir Belge seçin" + }, + "name": { + "text": "Metin", + "heading1": "Başlık 1", + "heading2": "Başlık 2", + "heading3": "Başlık 3", + "image": "Görsel", + "bulletedList": "Madde işaretli liste", + "numberedList": "Numaralı liste", + "todoList": "Yapılacaklar listesi", + "doc": "Belge", + "linkedDoc": "Sayfaya bağlantı", + "grid": "Tablo", + "linkedGrid": "Bağlantılı Tablo", + "kanban": "Kanban", + "linkedKanban": "Bağlantılı Kanban", + "calendar": "Takvim", + "linkedCalendar": "Bağlantılı Takvim", + "quote": "Alıntı", + "divider": "Ayırıcı", + "table": "Tablo", + "callout": "Not Kutusu", + "outline": "Ana Hat", + "mathEquation": "Matematik Denklemi", + "code": "Kod", + "toggleList": "Açılır liste", + "toggleHeading1": "Açılır başlık 1", + "toggleHeading2": "Açılır başlık 2", + "toggleHeading3": "Açılır başlık 3", + "emoji": "Emoji", + "aiWriter": "Yapay Zeka Yazar", + "dateOrReminder": "Tarih veya Hatırlatıcı", + "photoGallery": "Fotoğraf Galerisi", + "file": "Dosya" + }, + "subPage": { + "name": "Belge", + "keyword1": "alt sayfa", + "keyword2": "sayfa", + "keyword3": "alt sayfa", + "keyword4": "sayfa ekle", + "keyword5": "sayfa yerleştir", + "keyword6": "yeni sayfa", + "keyword7": "sayfa oluştur", + "keyword8": "belge" } }, "selectionMenu": { @@ -1474,62 +1729,95 @@ "codeBlock": "Kod Bloğu" }, "plugins": { - "referencedBoard": "Referans Gösterilen Pano", - "referencedGrid": "Referans Gösterilen Kılavuz", - "referencedCalendar": "Referans Gösterilen Takvim", - "referencedDocument": "Referans Gösterilen Belge", + "referencedBoard": "Referans Pano", + "referencedGrid": "Referans Tablo", + "referencedCalendar": "Referans Takvim", + "referencedDocument": "Referans Belge", "autoGeneratorMenuItemName": "Yapay Zeka Yazar", - "autoGeneratorTitleName": "Yapay Zeka: Yapay zekadan istediğinizi yazmasını isteyin...", - "autoGeneratorLearnMore": "Daha fazla bilgi edinin", + "autoGeneratorTitleName": "Yapay Zeka: Yapay zekadan herhangi bir şey yazmasını isteyin...", + "autoGeneratorLearnMore": "Daha fazla bilgi", "autoGeneratorGenerate": "Oluştur", - "autoGeneratorHintText": "Yapay Zeka'ya sorun ...", - "autoGeneratorCantGetOpenAIKey": "Yapay Zeka anahtarı alınamıyor", - "autoGeneratorRewrite": "Yeniden Yaz", - "smartEdit": "Yapay Zeka Asistanları", + "autoGeneratorHintText": "Yapay zekaya sorun ...", + "autoGeneratorCantGetOpenAIKey": "Yapay zeka anahtarı alınamıyor", + "autoGeneratorRewrite": "Yeniden yaz", + "smartEdit": "Yapay Zekaya Sor", "aI": "Yapay Zeka", - "smartEditFixSpelling": "Yazımı Düzelt", + "smartEditFixSpelling": "Yazım ve dilbilgisini düzelt", "warning": "⚠️ Yapay zeka yanıtları yanlış veya yanıltıcı olabilir.", "smartEditSummarize": "Özetle", - "smartEditImproveWriting": "Yazımı Geliştir", - "smartEditMakeLonger": "Daha Uzun Yap", - "smartEditCouldNotFetchResult": "Yapay Zeka'dan yanıt alınamadı", - "smartEditCouldNotFetchKey": "Yapay Zeka anahtarı alınamadı", - "smartEditDisabled": "Ayarlar'da Yapay Zeka'yı bağlayın", - "appflowyAIEditDisabled": "Yapay zeka özelliklerini kullanmak için oturum açın", - "discardResponse": "Yapay zeka yanıtlarını silmek ister misiniz?", + "smartEditImproveWriting": "Yazımı geliştir", + "smartEditMakeLonger": "Daha uzun yap", + "smartEditCouldNotFetchResult": "Yapay zekadan sonuç alınamadı", + "smartEditCouldNotFetchKey": "Yapay zeka anahtarı alınamadı", + "smartEditDisabled": "Ayarlar'dan Yapay Zeka'yı bağlayın", + "appflowyAIEditDisabled": "Yapay Zeka özelliklerini etkinleştirmek için giriş yapın", + "discardResponse": "Yapay Zeka yanıtlarını silmek istiyor musunuz?", "createInlineMathEquation": "Denklem oluştur", - "fonts": "Yazı Tipleri", + "fonts": "Yazı tipleri", "insertDate": "Tarih ekle", "emoji": "Emoji", - "toggleList": "Listeyi değiştir", + "toggleList": "Açılır liste", + "emptyToggleHeading": "Boş açılır başlık {}. İçerik eklemek için tıklayın.", + "emptyToggleList": "Boş açılır liste. İçerik eklemek için tıklayın.", + "emptyToggleHeadingWeb": "Boş açılır başlık {level}. İçerik eklemek için tıklayın", "quoteList": "Alıntı listesi", "numberedList": "Numaralı liste", "bulletedList": "Madde işaretli liste", - "todoList": "Yapılacaklar Listesi", - "callout": "Bilgi Kutusu", + "todoList": "Yapılacaklar listesi", + "callout": "Not Kutusu", + "simpleTable": { + "moreActions": { + "color": "Renk", + "align": "Hizala", + "delete": "Sil", + "duplicate": "Çoğalt", + "insertLeft": "Sola ekle", + "insertRight": "Sağa ekle", + "insertAbove": "Üste ekle", + "insertBelow": "Alta ekle", + "headerColumn": "Başlık sütunu", + "headerRow": "Başlık satırı", + "clearContents": "İçeriği temizle", + "setToPageWidth": "Sayfa genişliğine ayarla", + "distributeColumnsWidth": "Sütunları eşit dağıt", + "duplicateRow": "Satırı çoğalt", + "duplicateColumn": "Sütunu çoğalt", + "textColor": "Metin rengi", + "cellBackgroundColor": "Hücre arka plan rengi", + "duplicateTable": "Tabloyu çoğalt" + }, + "clickToAddNewRow": "Yeni satır eklemek için tıklayın", + "clickToAddNewColumn": "Yeni sütun eklemek için tıklayın", + "clickToAddNewRowAndColumn": "Yeni satır ve sütun eklemek için tıklayın", + "headerName": { + "table": "Tablo", + "alignText": "Metni hizala" + } + }, "cover": { - "changeCover": "Kapak Resmini Değiştir", + "changeCover": "Kapağı Değiştir", "colors": "Renkler", - "images": "Resimler", + "images": "Görseller", "clearAll": "Tümünü Temizle", "abstract": "Soyut", - "addCover": "Kapak Resmi Ekle", - "addLocalImage": "Yerel resim ekle", - "invalidImageUrl": "Geçersiz resim URL'si", - "failedToAddImageToGallery": "Resim galeriye eklenemedi", - "enterImageUrl": "Resim URL'sini girin", + "addCover": "Kapak Ekle", + "addLocalImage": "Yerel görsel ekle", + "invalidImageUrl": "Geçersiz görsel URL'si", + "failedToAddImageToGallery": "Görsel galeriye eklenemedi", + "enterImageUrl": "Görsel URL'si girin", "add": "Ekle", "back": "Geri", "saveToGallery": "Galeriye kaydet", "removeIcon": "Simgeyi kaldır", - "pasteImageUrl": "Resim URL'sini yapıştır", + "removeCover": "Kapağı kaldır", + "pasteImageUrl": "Görsel URL'sini yapıştırın", "or": "VEYA", "pickFromFiles": "Dosyalardan seç", - "couldNotFetchImage": "Resim yüklenemedi", - "imageSavingFailed": "Resim Kaydedilemedi", + "couldNotFetchImage": "Görsel alınamadı", + "imageSavingFailed": "Görsel Kaydedilemedi", "addIcon": "Simge ekle", "changeIcon": "Simgeyi değiştir", - "coverRemoveAlert": "Silindikten sonra kapak resminden kaldırılacaktır.", + "coverRemoveAlert": "Silindikten sonra kapaktan kaldırılacaktır.", "alertDialogConfirmation": "Devam etmek istediğinizden emin misiniz?" }, "mathEquation": { @@ -1540,9 +1828,11 @@ "optionAction": { "click": "Tıkla", "toOpenMenu": " menüyü açmak için", + "drag": "Sürükle", + "toMove": " taşımak için", "delete": "Sil", - "duplicate": "Kopyala", - "turnInto": "Şuna dönüştür", + "duplicate": "Çoğalt", + "turnInto": "Dönüştür", "moveUp": "Yukarı taşı", "moveDown": "Aşağı taşı", "color": "Renk", @@ -1551,141 +1841,181 @@ "center": "Orta", "right": "Sağ", "defaultColor": "Varsayılan", - "depth": "Derinlik" + "depth": "Derinlik", + "copyLinkToBlock": "Bloğa bağlantıyı kopyala" }, "image": { - "addAnImage": "Bir resim ekle", - "copiedToPasteBoard": "Resim bağlantısı panoya kopyalandı", - "addAnImageDesktop": "Resim(ler)i bırakın veya resim(ler)i eklemek için tıklayın", - "addAnImageMobile": "Bir veya daha fazla resim eklemek için tıklayın", - "dropImageToInsert": "Eklemek için resimleri bırakın", - "imageUploadFailed": "Resim yükleme başarısız oldu", - "imageDownloadFailed": "Resim yükleme başarısız oldu, lütfen tekrar deneyin", - "imageDownloadFailedToken": "Eksik kullanıcı jetonu nedeniyle resim yükleme başarısız oldu, lütfen tekrar deneyin", + "addAnImage": "Görsel ekle", + "copiedToPasteBoard": "Görsel bağlantısı panoya kopyalandı", + "addAnImageDesktop": "Bir görsel ekle", + "addAnImageMobile": "Bir veya daha fazla görsel eklemek için tıklayın", + "dropImageToInsert": "Eklemek için görselleri bırakın", + "imageUploadFailed": "Görsel yüklenemedi", + "imageDownloadFailed": "Görsel indirilemedi, lütfen tekrar deneyin", + "imageDownloadFailedToken": "Kullanıcı jetonu eksik olduğu için görsel indirilemedi, lütfen tekrar deneyin", "errorCode": "Hata kodu" }, "photoGallery": { "name": "Fotoğraf galerisi", - "imageKeyword": "resim", - "imageGalleryKeyword": "resim galerisi", + "imageKeyword": "görsel", + "imageGalleryKeyword": "görsel galerisi", "photoKeyword": "fotoğraf", - "photoBrowserKeyword": "fotoğraf tarayıcı", + "photoBrowserKeyword": "fotoğraf tarayıcısı", "galleryKeyword": "galeri", - "addImageTooltip": "Resim ekle" + "addImageTooltip": "Görsel ekle", + "changeLayoutTooltip": "Düzeni değiştir", + "browserLayout": "Tarayıcı", + "gridLayout": "Izgara", + "deleteBlockTooltip": "Tüm galeriyi sil" }, "math": { "copiedToPasteBoard": "Matematik denklemi panoya kopyalandı" }, "urlPreview": { "copiedToPasteBoard": "Bağlantı panoya kopyalandı", - "convertToLink": "Gömülü bağlantıya dönüştür" + "convertToLink": "Yerleşik bağlantıya dönüştür" }, "outline": { "addHeadingToCreateOutline": "İçindekiler tablosu oluşturmak için başlıklar ekleyin.", "noMatchHeadings": "Eşleşen başlık bulunamadı." }, "table": { - "addAfter": "Sonra ekle", - "addBefore": "Önce ekle", + "addAfter": "Sonrasına ekle", + "addBefore": "Öncesine ekle", "delete": "Sil", "clear": "İçeriği temizle", - "duplicate": "Kopyala", + "duplicate": "Çoğalt", "bgColor": "Arka plan rengi" }, "contextMenu": { "copy": "Kopyala", "cut": "Kes", - "paste": "Yapıştır" + "paste": "Yapıştır", + "pasteAsPlainText": "Düz metin olarak yapıştır" }, - "action": "Eylemler", + "action": "İşlemler", "database": { - "selectDataSource": "Veri kaynağını seçin", + "selectDataSource": "Veri kaynağı seç", "noDataSource": "Veri kaynağı yok", - "selectADataSource": "Bir veri kaynağı seçin", + "selectADataSource": "Bir veri kaynağı seç", "toContinue": "devam etmek için", "newDatabase": "Yeni Veritabanı", - "linkToDatabase": "Veritabanına Bağla" + "linkToDatabase": "Veritabanına Bağlantı" }, "date": "Tarih", "video": { "label": "Video", - "emptyLabel": "Bir video ekleyin", + "emptyLabel": "Video ekle", "placeholder": "Video bağlantısını yapıştırın", "copiedToPasteBoard": "Video bağlantısı panoya kopyalandı", "insertVideo": "Video ekle", - "invalidVideoUrl": "Kaynak URL henüz desteklenmiyor.", + "invalidVideoUrl": "Kaynak URL'si henüz desteklenmiyor.", "invalidVideoUrlYouTube": "YouTube henüz desteklenmiyor.", "supportedFormats": "Desteklenen formatlar: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" - } + }, + "file": { + "name": "Dosya", + "uploadTab": "Yükle", + "uploadMobile": "Bir dosya seç", + "uploadMobileGallery": "Fotoğraf Galerisinden", + "networkTab": "Bağlantı yerleştir", + "placeholderText": "Bir dosya yükleyin veya yerleştirin", + "placeholderDragging": "Yüklemek için dosyayı bırakın", + "dropFileToUpload": "Yüklemek için bir dosya bırakın", + "fileUploadHint": "Bir dosya sürükleyip bırakın veya tıklayarak ", + "fileUploadHintSuffix": "Göz atın", + "networkHint": "Bir dosya bağlantısı yapıştırın", + "networkUrlInvalid": "Geçersiz URL. URL'yi kontrol edip tekrar deneyin.", + "networkAction": "Yerleştir", + "fileTooBigError": "Dosya boyutu çok büyük, lütfen 10MB'dan küçük bir dosya yükleyin", + "renameFile": { + "title": "Dosyayı yeniden adlandır", + "description": "Bu dosya için yeni bir ad girin", + "nameEmptyError": "Dosya adı boş bırakılamaz." + }, + "uploadedAt": "{} tarihinde yüklendi", + "linkedAt": "Bağlantısı {} tarihinde eklendi", + "failedToOpenMsg": "Açılamadı, dosya bulunamadı" + }, + "subPage": { + "handlingPasteHint": " - (yapıştırma işlemi)", + "errors": { + "failedDeletePage": "Sayfa silinemedi", + "failedCreatePage": "Sayfa oluşturulamadı", + "failedMovePage": "Sayfa bu belgeye taşınamadı", + "failedDuplicatePage": "Sayfa çoğaltılamadı", + "failedDuplicateFindView": "Sayfa çoğaltılamadı - orijinal görünüm bulunamadı" + } + }, + "cannotMoveToItsChildren": "Alt öğelerine taşınamaz" }, "outlineBlock": { "placeholder": "İçindekiler" }, "textBlock": { - "placeholder": "Komutlar için '/' yazın veya yazmaya başlayın" + "placeholder": "Komutlar için '/' yazın" }, "title": { - "placeholder": "İsimsiz" + "placeholder": "Başlıksız" }, "imageBlock": { - "placeholder": "Resim(ler)i eklemek için tıklayın", + "placeholder": "Görsel eklemek için tıklayın", "upload": { "label": "Yükle", - "placeholder": "Resim yüklemek için tıklayın" + "placeholder": "Görsel yüklemek için tıklayın" }, "url": { - "label": "Resim URL'si", - "placeholder": "Resim URL'sini girin" + "label": "Görsel URL'si", + "placeholder": "Görsel URL'si girin" }, "ai": { - "label": "Yapay Zeka'dan resim oluştur", - "placeholder": "Lütfen Yapay Zeka'nın resim oluşturması için komutu girin" + "label": "Yapay zeka ile görsel oluştur", + "placeholder": "Yapay zekanın görsel oluşturması için bir istek girin" }, "stability_ai": { - "label": "Stability Yapay Zeka ile resim oluştur", - "placeholder": "Lütfen Stability Yapay Zeka'nın resim oluşturması için komutu girin" + "label": "Stability AI ile görsel oluştur", + "placeholder": "Stability AI'nın görsel oluşturması için bir istek girin" }, - "support": "Resim boyutu sınırı 5 MB'dir. Desteklenen formatlar: JPEG, PNG, GIF, SVG", + "support": "Görsel boyut sınırı 5MB'dır. Desteklenen formatlar: JPEG, PNG, GIF, SVG", "error": { - "invalidImage": "Geçersiz resim", - "invalidImageSize": "Resim boyutu 5 MB'den küçük olmalıdır", - "invalidImageFormat": "Resim formatı desteklenmiyor. Desteklenen formatlar: JPEG, PNG, JPG, GIF, SVG, WEBP", - "invalidImageUrl": "Geçersiz resim URL'si", + "invalidImage": "Geçersiz görsel", + "invalidImageSize": "Görsel boyutu 5MB'dan küçük olmalıdır", + "invalidImageFormat": "Görsel formatı desteklenmiyor. Desteklenen formatlar: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "Geçersiz görsel URL'si", "noImage": "Böyle bir dosya veya dizin yok", - "multipleImagesFailed": "Bir veya daha fazla resim yüklenemedi, lütfen tekrar deneyin" + "multipleImagesFailed": "Bir veya daha fazla görsel yüklenemedi, lütfen tekrar deneyin" }, "embedLink": { - "label": "Bağlantıyı göm", - "placeholder": "Bir resim bağlantısını yapıştırın veya yazın" + "label": "Bağlantı yerleştir", + "placeholder": "Bir görsel bağlantısı yapıştırın veya yazın" }, "unsplash": { "label": "Unsplash" }, - "searchForAnImage": "Bir resim arayın", - "pleaseInputYourOpenAIKey": "Lütfen Ayarlar sayfasında Yapay Zeka anahtarınızı girin", - "saveImageToGallery": "Resmi kaydet", - "failedToAddImageToGallery": "Resim galeriye eklenemedi", - "successToAddImageToGallery": "Resim başarıyla galeriye eklendi", - "unableToLoadImage": "Resim yüklenemedi", - "maximumImageSize": "Desteklenen maksimum yükleme resim boyutu 10 MB'dir", - "uploadImageErrorImageSizeTooBig": "Resim boyutu 10 MB'den küçük olmalıdır", - "imageIsUploading": "Resim yükleniyor", - "openFullScreen": "Tam ekranda aç", + "searchForAnImage": "Bir görsel ara", + "pleaseInputYourOpenAIKey": "lütfen Ayarlar sayfasından yapay zeka anahtarınızı girin", + "saveImageToGallery": "Görseli kaydet", + "failedToAddImageToGallery": "Görsel galeriye eklenemedi", + "successToAddImageToGallery": "Görsel başarıyla galeriye eklendi", + "unableToLoadImage": "Görsel yüklenemedi", + "maximumImageSize": "Desteklenen maksimum görsel yükleme boyutu 10MB'dır", + "uploadImageErrorImageSizeTooBig": "Görsel boyutu 10MB'dan küçük olmalıdır", + "imageIsUploading": "Görsel yükleniyor", + "openFullScreen": "Tam ekran aç", "interactiveViewer": { "toolbar": { - "previousImageTooltip": "Önceki resim", - "nextImageTooltip": "Sonraki resim", + "previousImageTooltip": "Önceki görsel", + "nextImageTooltip": "Sonraki görsel", "zoomOutTooltip": "Uzaklaştır", "zoomInTooltip": "Yakınlaştır", "changeZoomLevelTooltip": "Yakınlaştırma seviyesini değiştir", - "openLocalImage": "Resmi aç", - "downloadImage": "Resmi indir", + "openLocalImage": "Görseli aç", + "downloadImage": "Görseli indir", "closeViewer": "Etkileşimli görüntüleyiciyi kapat", "scalePercentage": "%{}", - "deleteImageTooltip": "Resmi sil" + "deleteImageTooltip": "Görseli sil" } - }, - "pleaseInputYourStabilityAIKey": "Lütfen Ayarlar sayfasında Stability Yapay Zeka anahtarınızı girin" + } }, "codeBlock": { "language": { @@ -1693,59 +2023,71 @@ "placeholder": "Dil seçin", "auto": "Otomatik" }, - "copyTooltip": "Kod bloğunun içeriğini kopyala", + "copyTooltip": "Kopyala", "searchLanguageHint": "Bir dil arayın", "codeCopiedSnackbar": "Kod panoya kopyalandı!" }, "inlineLink": { - "placeholder": "Bir bağlantıyı yapıştırın veya yazın", + "placeholder": "Bir bağlantı yapıştırın veya yazın", "openInNewTab": "Yeni sekmede aç", "copyLink": "Bağlantıyı kopyala", "removeLink": "Bağlantıyı kaldır", "url": { "label": "Bağlantı URL'si", - "placeholder": "Bağlantı URL'sini girin" + "placeholder": "Bağlantı URL'si girin" }, "title": { "label": "Bağlantı Başlığı", - "placeholder": "Bağlantı başlığını girin" + "placeholder": "Bağlantı başlığı girin" } }, "mention": { - "placeholder": "Bir kişiye, sayfaya veya tarihe bahset...", + "placeholder": "Bir kişiden, sayfadan veya tarihten bahsedin...", "page": { "label": "Sayfaya bağlantı", "tooltip": "Sayfayı açmak için tıklayın" }, "deleted": "Silindi", - "deletedContent": "Bu içerik mevcut değil veya silinmiş" + "deletedContent": "Bu içerik mevcut değil veya silinmiş", + "noAccess": "Erişim Yok", + "deletedPage": "Silinmiş sayfa", + "trashHint": " - çöp kutusunda", + "morePages": "daha fazla sayfa" }, "toolbar": { "resetToDefaultFont": "Varsayılana sıfırla" }, "errorBlock": { - "theBlockIsNotSupported": "Blok içeriği ayrıştırılamadı", + "theBlockIsNotSupported": "Mevcut sürüm bu Bloğu desteklemiyor.", "clickToCopyTheBlockContent": "Blok içeriğini kopyalamak için tıklayın", - "blockContentHasBeenCopied": "Blok içeriği kopyalandı" + "blockContentHasBeenCopied": "Blok içeriği kopyalandı.", + "parseError": "{} bloğu ayrıştırılırken bir hata oluştu.", + "copyBlockContent": "Blok içeriğini kopyala" }, "mobilePageSelector": { - "title": "Sayfa seçin", + "title": "Sayfa seç", "failedToLoad": "Sayfa listesi yüklenemedi", "noPagesFound": "Sayfa bulunamadı" + }, + "attachmentMenu": { + "choosePhoto": "Fotoğraf seç", + "takePicture": "Fotoğraf çek", + "chooseFile": "Dosya seç" } }, "board": { "column": { + "label": "Sütun", "createNewCard": "Yeni", "renameGroupTooltip": "Grubu yeniden adlandırmak için basın", "createNewColumn": "Yeni bir grup ekle", "addToColumnTopTooltip": "Üste yeni bir kart ekle", "addToColumnBottomTooltip": "Alta yeni bir kart ekle", - "renameColumn": "Yeniden Adlandır", + "renameColumn": "Yeniden adlandır", "hideColumn": "Gizle", "newGroup": "Yeni grup", "deleteColumn": "Sil", - "deleteColumnConfirmation": "Bu, bu grubu ve içindeki tüm kartları silecektir.\nDevam etmek istediğinizden emin misiniz?" + "deleteColumnConfirmation": "Bu işlem bu grubu ve içindeki tüm kartları silecektir. Devam etmek istediğinizden emin misiniz?" }, "hiddenGroupSection": { "sectionTitle": "Gizli Gruplar", @@ -1753,23 +2095,23 @@ "expandTooltip": "Gizli grupları görüntüle" }, "cardDetail": "Kart Detayı", - "cardActions": "Kart Eylemleri", - "cardDuplicated": "Kart kopyalandı", + "cardActions": "Kart İşlemleri", + "cardDuplicated": "Kart çoğaltıldı", "cardDeleted": "Kart silindi", "showOnCard": "Kart detayında göster", "setting": "Ayar", "propertyName": "Özellik adı", "menuName": "Pano", - "showUngrouped": "Grupsuz öğeleri göster", - "ungroupedButtonText": "Grupsuz", + "showUngrouped": "Gruplanmamış öğeleri göster", + "ungroupedButtonText": "Gruplanmamış", "ungroupedButtonTooltip": "Herhangi bir gruba ait olmayan kartları içerir", "ungroupedItemsTitle": "Panoya eklemek için tıklayın", - "groupBy": "Şuna göre grupla", + "groupBy": "Grupla", "groupCondition": "Gruplama koşulu", "referencedBoardPrefix": "Görünümü", - "notesTooltip": "İçindeki notlar", + "notesTooltip": "İçerideki notlar", "mobile": { - "editURL": "URL'yi Düzenle", + "editURL": "URL'yi düzenle", "showGroup": "Grubu göster", "showGroupContent": "Bu grubu panoda göstermek istediğinizden emin misiniz?", "failedToLoad": "Pano görünümü yüklenemedi" @@ -1780,20 +2122,24 @@ "yesterday": "Dün", "tomorrow": "Yarın", "lastSevenDays": "Son 7 gün", - "nextSevenDays": "Sonraki 7 gün", + "nextSevenDays": "Gelecek 7 gün", "lastThirtyDays": "Son 30 gün", - "nextThirtyDays": "Sonraki 30 gün" + "nextThirtyDays": "Gelecek 30 gün" }, - "noGroup": "Gruplama özelliği yok", - "noGroupDesc": "Pano görünümlerinin görüntülenebilmesi için gruplama yapılacak bir özellik gerekir" + "noGroup": "Özelliğe göre gruplama yok", + "noGroupDesc": "Pano görünümleri görüntülemek için gruplamak üzere bir özellik gerektirir", + "media": { + "cardText": "{} {}", + "fallbackName": "dosyalar" + } }, "calendar": { "menuName": "Takvim", - "defaultNewCalendarTitle": "İsimsiz", - "newEventButtonTooltip": "Yeni bir etkinlik ekleyin", + "defaultNewCalendarTitle": "Başlıksız", + "newEventButtonTooltip": "Yeni etkinlik ekle", "navigation": { "today": "Bugün", - "jumpToday": "Bugüne Git", + "jumpToday": "Bugüne git", "previousMonth": "Önceki Ay", "nextMonth": "Sonraki Ay", "views": { @@ -1805,14 +2151,14 @@ }, "mobileEventScreen": { "emptyTitle": "Henüz etkinlik yok", - "emptyBody": "Bu güne bir etkinlik oluşturmak için artı düğmesine basın." + "emptyBody": "Bu güne etkinlik eklemek için artı düğmesine basın." }, "settings": { "showWeekNumbers": "Hafta numaralarını göster", "showWeekends": "Hafta sonlarını göster", - "firstDayOfWeek": "Haftayı şunda başlat", + "firstDayOfWeek": "Haftanın başlangıç günü", "layoutDateField": "Takvimi şuna göre düzenle", - "changeLayoutDateField": "Düzenleme alanını değiştir", + "changeLayoutDateField": "Düzen alanını değiştir", "noDateTitle": "Tarih Yok", "noDateHint": { "zero": "Planlanmamış etkinlikler burada görünecek", @@ -1826,15 +2172,18 @@ }, "referencedCalendarPrefix": "Görünümü", "quickJumpYear": "Şuraya git", - "duplicateEvent": "Etkinliği kopyala" + "duplicateEvent": "Etkinliği çoğalt" }, "errorDialog": { "title": "@:appName Hatası", - "howToFixFallback": "Verdiğimiz rahatsızlıktan dolayı özür dileriz! Lütfen GitHub sayfamızda hatanızı açıklayan bir sorun bildirin.", - "github": "GitHub'da Görüntüle" + "howToFixFallback": "Rahatsızlık için özür dileriz! GitHub sayfamızda hatanızı açıklayan bir sorun bildirin.", + "howToFixFallbackHint1": "Rahatsızlık için özür dileriz! ", + "howToFixFallbackHint2": " sayfamızda hatanızı açıklayan bir sorun bildirin.", + "github": "GitHub'da görüntüle" }, "search": { "label": "Ara", + "sidebarSearchIcon": "Ara ve hızlıca bir sayfaya git", "placeholder": { "actions": "Eylemleri ara..." } @@ -1845,7 +2194,7 @@ "fail": "Kopyalanamadı" } }, - "unSupportBlock": "Geçerli sürüm bu bloğu desteklemiyor.", + "unSupportBlock": "Mevcut sürüm bu Bloğu desteklemiyor.", "views": { "deleteContentTitle": "{pageType} silmek istediğinizden emin misiniz?", "deleteContentCaption": "Bu {pageType} silerseniz, çöp kutusundan geri yükleyebilirsiniz." @@ -1868,22 +2217,22 @@ "search": "Emoji ara", "noRecent": "Son kullanılan emoji yok", "noEmojiFound": "Emoji bulunamadı", - "filter": "Filtre", + "filter": "Filtrele", "random": "Rastgele", - "selectSkinTone": "Cilt tonunu seç", + "selectSkinTone": "Ten rengi seç", "remove": "Emojiyi kaldır", "categories": { - "smileys": "Gülen Yüzler & Duygular", - "people": "İnsanlar & Vücut", - "animals": "Hayvanlar & Doğa", - "food": "Yiyecek & İçecek", - "activities": "Aktiviteler", - "places": "Seyahat & Yerler", - "objects": "Nesneler", - "symbols": "Semboller", - "flags": "Bayraklar", - "nature": "Doğa", - "frequentlyUsed": "Sık Kullanılanlar" + "smileys": "İfadeler ve Duygular", + "people": "insanlar", + "animals": "doğa", + "food": "yiyecekler", + "activities": "aktiviteler", + "places": "yerler", + "objects": "nesneler", + "symbols": "semboller", + "flags": "bayraklar", + "nature": "doğa", + "frequentlyUsed": "sık kullanılanlar" }, "skinTone": { "default": "Varsayılan", @@ -1892,7 +2241,8 @@ "medium": "Orta", "mediumDark": "Orta-Koyu", "dark": "Koyu" - } + }, + "openSourceIconsFrom": "Açık kaynak ikonlar" }, "inlineActions": { "noResults": "Sonuç yok", @@ -1901,25 +2251,26 @@ "docReference": "Belge referansı", "boardReference": "Pano referansı", "calReference": "Takvim referansı", - "gridReference": "Kılavuz referansı", + "gridReference": "Tablo referansı", "date": "Tarih", "reminder": { "groupTitle": "Hatırlatıcı", "shortKeyword": "hatırlat" - } + }, + "createPage": "\"{}\"-alt sayfası oluştur" }, "datePicker": { - "dateTimeFormatTooltip": "Ayarlardan tarih ve saat formatını değiştirin", - "dateFormat": "Tarih formatı", + "dateTimeFormatTooltip": "Tarih ve saat biçimini ayarlardan değiştirin", + "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", - "timeFormat": "Saat formatı", + "timeFormat": "Saat biçimi", "clearDate": "Tarihi temizle", "reminderLabel": "Hatırlatıcı", "selectReminder": "Hatırlatıcı seç", "reminderOptions": { "none": "Yok", - "atTimeOfEvent": "Etkinlik zamanı", + "atTimeOfEvent": "Etkinlik zamanında", "fiveMinsBefore": "5 dakika önce", "tenMinsBefore": "10 dakika önce", "fifteenMinsBefore": "15 dakika önce", @@ -1944,8 +2295,8 @@ "mobile": { "title": "Güncellemeler" }, - "emptyTitle": "Her şey tamam!", - "emptyBody": "Bekleyen bildirim veya eylem yok. Sakinliğin tadını çıkarın.", + "emptyTitle": "Hepsi tamamlandı!", + "emptyBody": "Bekleyen bildirim veya eylem yok. Huzurun tadını çıkarın.", "tabs": { "inbox": "Gelen Kutusu", "upcoming": "Yaklaşan" @@ -1959,16 +2310,16 @@ "ascending": "Artan", "descending": "Azalan", "groupByDate": "Tarihe göre grupla", - "showUnreadsOnly": "Yalnızca okunmamışları göster", + "showUnreadsOnly": "Sadece okunmamışları göster", "resetToDefault": "Varsayılana sıfırla" } }, "reminderNotification": { "title": "Hatırlatıcı", - "message": "Unutmadan bir göz atın!", + "message": "Unutmadan önce bunu kontrol etmeyi unutmayın!", "tooltipDelete": "Sil", "tooltipMarkRead": "Okundu olarak işaretle", - "tooltipMarkUnread": "Okunmamış olarak işaretle" + "tooltipMarkUnread": "Okunmadı olarak işaretle" }, "findAndReplace": { "find": "Bul", @@ -1979,34 +2330,40 @@ "replaceAll": "Tümünü değiştir", "noResult": "Sonuç yok", "caseSensitive": "Büyük/küçük harf duyarlı", - "searchMore": "Daha fazla sonuç bulmak için ara" + "searchMore": "Daha fazla sonuç bulmak için arama yapın" }, "error": { "weAreSorry": "Üzgünüz", - "loadingViewError": "Bu görünümü yüklemede sorun yaşıyoruz. Lütfen internet bağlantınızı kontrol edin, uygulamayı yenileyin ve sorun devam ederse ekibe ulaşmaktan çekinmeyin." + "loadingViewError": "Bu görünümü yüklerken sorun yaşıyoruz. Lütfen internet bağlantınızı kontrol edin, uygulamayı yenileyin ve sorun devam ederse ekiple iletişime geçmekten çekinmeyin.", + "syncError": "Veriler başka bir cihazdan senkronize edilmedi", + "syncErrorHint": "Lütfen bu sayfayı son düzenlemenin yapıldığı cihazda yeniden açın, ardından mevcut cihazda tekrar açın.", + "clickToCopy": "Hata kodunu kopyalamak için tıklayın" }, "editor": { "bold": "Kalın", - "bulletedList": "Madde İşaretli Liste", - "bulletedListShortForm": "Madde İşaretli", - "checkbox": "Onay Kutusu", - "embedCode": "Kodu Göm", + "bulletedList": "Madde işaretli liste", + "bulletedListShortForm": "Madde işaretli", + "checkbox": "Onay kutusu", + "embedCode": "Kod Yerleştir", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Vurgula", "color": "Renk", - "image": "Resim", + "image": "Görsel", "date": "Tarih", "page": "Sayfa", "italic": "İtalik", "link": "Bağlantı", - "numberedList": "Numaralı Liste", + "numberedList": "Numaralı liste", "numberedListShortForm": "Numaralı", + "toggleHeading1ShortForm": "B1'i aç/kapat", + "toggleHeading2ShortForm": "B2'yi aç/kapat", + "toggleHeading3ShortForm": "B3'ü aç/kapat", "quote": "Alıntı", - "strikethrough": "Üstü Çizili", + "strikethrough": "Üstü çizili", "text": "Metin", - "underline": "Altı Çizili", + "underline": "Altı çizili", "fontColorDefault": "Varsayılan", "fontColorGray": "Gri", "fontColorBrown": "Kahverengi", @@ -2027,9 +2384,9 @@ "backgroundColorPurple": "Mor arka plan", "backgroundColorPink": "Pembe arka plan", "backgroundColorRed": "Kırmızı arka plan", - "backgroundColorLime": "Limoni arka plan", - "backgroundColorAqua": "Su yeşili arka plan", - "done": "Bitti", + "backgroundColorLime": "Limon yeşili arka plan", + "backgroundColorAqua": "Su mavisi arka plan", + "done": "Tamam", "cancel": "İptal", "tint1": "Ton 1", "tint2": "Ton 2", @@ -2045,14 +2402,17 @@ "lightLightTint3": "Açık Pembe", "lightLightTint4": "Turuncu", "lightLightTint5": "Sarı", - "lightLightTint6": "Limoni", + "lightLightTint6": "Limon yeşili", "lightLightTint7": "Yeşil", - "lightLightTint8": "Su yeşili", + "lightLightTint8": "Su mavisi", "lightLightTint9": "Mavi", "urlHint": "URL", "mobileHeading1": "Başlık 1", "mobileHeading2": "Başlık 2", "mobileHeading3": "Başlık 3", + "mobileHeading4": "Başlık 4", + "mobileHeading5": "Başlık 5", + "mobileHeading6": "Başlık 6", "textColor": "Metin Rengi", "backgroundColor": "Arka Plan Rengi", "addYourLink": "Bağlantınızı ekleyin", @@ -2063,14 +2423,14 @@ "linkText": "Metin", "linkTextHint": "Lütfen metin girin", "linkAddressHint": "Lütfen URL girin", - "highlightColor": "Vurgu rengi", - "clearHighlightColor": "Vurgu rengini temizle", + "highlightColor": "Vurgulama rengi", + "clearHighlightColor": "Vurgulama rengini temizle", "customColor": "Özel renk", "hexValue": "Hex değeri", "opacity": "Opaklık", "resetToDefaultColor": "Varsayılan renge sıfırla", - "ltr": "LTR", - "rtl": "RTL", + "ltr": "Soldan sağa", + "rtl": "Sağdan sola", "auto": "Otomatik", "cut": "Kes", "copy": "Kopyala", @@ -2083,15 +2443,15 @@ "closeFind": "Kapat", "replace": "Değiştir", "replaceAll": "Tümünü değiştir", - "regex": "Regex", + "regex": "Düzenli ifade", "caseSensitive": "Büyük/küçük harf duyarlı", - "uploadImage": "Resim Yükle", - "urlImage": "URL Resmi", + "uploadImage": "Görsel Yükle", + "urlImage": "URL Görseli", "incorrectLink": "Hatalı Bağlantı", "upload": "Yükle", - "chooseImage": "Bir resim seçin", + "chooseImage": "Bir görsel seçin", "loading": "Yükleniyor", - "imageLoadFailed": "Resim yüklenemedi", + "imageLoadFailed": "Görsel yüklenemedi", "divider": "Ayırıcı", "table": "Tablo", "colAddBefore": "Önce ekle", @@ -2100,25 +2460,25 @@ "rowAddAfter": "Sonra ekle", "colRemove": "Kaldır", "rowRemove": "Kaldır", - "colDuplicate": "Kopyala", - "rowDuplicate": "Kopyala", + "colDuplicate": "Çoğalt", + "rowDuplicate": "Çoğalt", "colClear": "İçeriği Temizle", "rowClear": "İçeriği Temizle", - "slashPlaceHolder": "Bir blok eklemek için '/' yazın veya yazmaya başlayın", + "slashPlaceHolder": "Blok eklemek için '/' yazın veya yazmaya başlayın", "typeSomething": "Bir şeyler yazın...", - "toggleListShortForm": "Liste Değiştir", + "toggleListShortForm": "Aç/Kapat", "quoteListShortForm": "Alıntı", "mathEquationShortForm": "Formül", "codeBlockShortForm": "Kod" }, "favorite": { "noFavorite": "Favori sayfa yok", - "noFavoriteHintText": "Sayfayı favorilerinize eklemek için sola kaydırın", + "noFavoriteHintText": "Favorilerinize eklemek için sayfayı sola kaydırın", "removeFromSidebar": "Kenar çubuğundan kaldır", "addToSidebar": "Kenar çubuğuna sabitle" }, "cardDetails": { - "notesPlaceholder": "Bir blok eklemek için '/' yazın veya yazmaya başlayın" + "notesPlaceholder": "Blok eklemek için / yazın veya yazmaya başlayın" }, "blockPlaceholders": { "todoList": "Yapılacaklar", @@ -2128,52 +2488,60 @@ "heading": "Başlık {}" }, "titleBar": { - "pageIcon": "Sayfa simgesi", + "pageIcon": "Sayfa ikonu", "language": "Dil", - "font": "Yazı Tipi", + "font": "Yazı tipi", "actions": "Eylemler", "date": "Tarih", "addField": "Alan ekle", - "userIcon": "Kullanıcı simgesi" + "userIcon": "Kullanıcı ikonu" }, "noLogFiles": "Günlük dosyası yok", "newSettings": { "myAccount": { "title": "Hesabım", - "subtitle": "Profilinizi özelleştirin, hesap güvenliğini yönetin, Open AI anahtarlarını yönetin veya hesabınıza giriş yapın.", - "profileLabel": "Hesap adı & Profil resmi", + "subtitle": "Profilinizi özelleştirin, hesap güvenliğini yönetin, yapay zeka anahtarlarını ayarlayın veya hesabınıza giriş yapın.", + "profileLabel": "Hesap adı ve Profil resmi", "profileNamePlaceholder": "Adınızı girin", "accountSecurity": "Hesap güvenliği", "2FA": "2 Adımlı Doğrulama", - "aiKeys": "Yapay Zeka anahtarları", + "aiKeys": "Yapay zeka anahtarları", "accountLogin": "Hesap Girişi", "updateNameError": "Ad güncellenemedi", - "updateIconError": "Simge güncellenemedi", + "updateIconError": "İkon güncellenemedi", "deleteAccount": { "title": "Hesabı Sil", "subtitle": "Hesabınızı ve tüm verilerinizi kalıcı olarak silin.", + "description": "Hesabınızı kalıcı olarak silin ve tüm çalışma alanlarından erişimi kaldırın.", "deleteMyAccount": "Hesabımı sil", "dialogTitle": "Hesabı sil", "dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?", - "dialogContent2": "Bu işlem geri alınamaz ve tüm ekip alanlarına erişiminizi kaldıracak, hesabınızı (özel çalışma alanları dahil) tamamen silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır." + "dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.", + "confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.", + "confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.", + "confirmHint3": "HESABIMI SİL", + "checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz", + "failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı", + "confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor", + "deleteAccountSuccess": "Hesap başarıyla silindi" } }, "workplace": { - "name": "Çalışma Alanı", + "name": "Çalışma alanı", "title": "Çalışma Alanı Ayarları", - "subtitle": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarihini, saatini ve dilini özelleştirin.", + "subtitle": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", "workplaceName": "Çalışma alanı adı", "workplaceNamePlaceholder": "Çalışma alanı adını girin", - "workplaceIcon": "Çalışma alanı simgesi", - "workplaceIconSubtitle": "Çalışma alanınız için bir resim yükleyin veya bir emoji kullanın. Simge, kenar çubuğunuzda ve bildirimlerinizde görünecektir.", + "workplaceIcon": "Çalışma alanı ikonu", + "workplaceIconSubtitle": "Bir görsel yükleyin veya çalışma alanınız için bir emoji kullanın. İkon kenar çubuğunuzda ve bildirimlerde görünecektir.", "renameError": "Çalışma alanı yeniden adlandırılamadı", - "updateIconError": "Simge güncellenemedi", - "chooseAnIcon": "Bir simge seçin", + "updateIconError": "İkon güncellenemedi", + "chooseAnIcon": "Bir ikon seçin", "appearance": { "name": "Görünüm", "themeMode": { "auto": "Otomatik", - "light": "Açık", + "light": "Aydınlık", "dark": "Koyu" }, "language": "Dil" @@ -2188,30 +2556,32 @@ "pageStyle": { "title": "Sayfa stili", "layout": "Düzen", - "coverImage": "Kapak resmi", - "pageIcon": "Sayfa simgesi", + "coverImage": "Kapak görseli", + "pageIcon": "Sayfa ikonu", "colors": "Renkler", "gradient": "Gradyan", - "backgroundImage": "Arka plan resmi", - "presets": "Ön ayarlar", + "backgroundImage": "Arka plan görseli", + "presets": "Hazır ayarlar", "photo": "Fotoğraf", "unsplash": "Unsplash", "pageCover": "Sayfa kapağı", "none": "Yok", "openSettings": "Ayarları Aç", "photoPermissionTitle": "@:appName fotoğraf kitaplığınıza erişmek istiyor", - "photoPermissionDescription": "Görüntü yüklemek için fotoğraf kitaplığına erişim izni verin.", + "photoPermissionDescription": "@:appName belgelerinize görsel ekleyebilmeniz için fotoğraflarınıza erişmeye ihtiyaç duyuyor", + "cameraPermissionTitle": "@:appName kameranıza erişmek istiyor", + "cameraPermissionDescription": "@:appName belgelerinize kameradan görsel ekleyebilmeniz için kameranıza erişmeye ihtiyaç duyuyor", "doNotAllow": "İzin Verme", - "image": "Resim" + "image": "Görsel" }, "commandPalette": { - "placeholder": "Aramak için yazın...", + "placeholder": "Ara veya bir soru sor...", "bestMatches": "En iyi eşleşmeler", "recentHistory": "Son geçmiş", "navigateHint": "gezinmek için", - "loadingTooltip": "Sonuçlar aranıyor...", + "loadingTooltip": "Sonuçları arıyoruz...", "betaLabel": "BETA", - "betaTooltip": "Şu anda yalnızca sayfaları ve belgelerdeki içeriği aramayı destekliyoruz", + "betaTooltip": "Şu anda yalnızca sayfalarda ve belgelerde içerik aramayı destekliyoruz", "fromTrashHint": "Çöp kutusundan", "noResultsHint": "Aradığınızı bulamadık, başka bir terim aramayı deneyin.", "clearSearchTooltip": "Arama alanını temizle" @@ -2219,26 +2589,26 @@ "space": { "delete": "Sil", "deleteConfirmation": "Sil: ", - "deleteConfirmationDescription": "Bu Alan içindeki tüm sayfalar silinecek ve Çöp Kutusu'na taşınacaktır. Yayınlanan sayfalar da silinecektir.", + "deleteConfirmationDescription": "Bu Alan içindeki tüm sayfalar silinecek ve Çöp Kutusuna taşınacak, yayınlanmış sayfaların yayını kaldırılacaktır.", "rename": "Alanı Yeniden Adlandır", - "changeIcon": "Simgeyi değiştir", + "changeIcon": "İkonu değiştir", "manage": "Alanı Yönet", "addNewSpace": "Alan Oluştur", "collapseAllSubPages": "Tüm alt sayfaları daralt", "createNewSpace": "Yeni bir alan oluştur", - "createSpaceDescription": "Çalışmanızı daha iyi organize etmek için birden fazla genel ve özel alan oluşturun.", + "createSpaceDescription": "İşlerinizi daha iyi organize etmek için birden fazla genel ve özel alan oluşturun.", "spaceName": "Alan adı", - "spaceNamePlaceholder": "ör. Pazarlama, Mühendislik, İK", - "permission": "İzin", + "spaceNamePlaceholder": "örn. Pazarlama, Mühendislik, İK", + "permission": "Alan izni", "publicPermission": "Genel", "publicPermissionDescription": "Tam erişime sahip tüm çalışma alanı üyeleri", "privatePermission": "Özel", - "privatePermissionDescription": "Yalnızca siz bu alana erişebilirsiniz", + "privatePermissionDescription": "Bu alana yalnızca siz erişebilirsiniz", "spaceIconBackground": "Arka plan rengi", - "spaceIcon": "Simge", - "dangerZone": "Tehlike Bölgesi", - "unableToDeleteLastSpace": "Son Alan silinemiyor", - "unableToDeleteSpaceNotCreatedByYou": "Başkaları tarafından oluşturulan Alanlar silinemiyor", + "spaceIcon": "İkon", + "dangerZone": "Tehlikeli Bölge", + "unableToDeleteLastSpace": "Son Alan silinemez", + "unableToDeleteSpaceNotCreatedByYou": "Başkaları tarafından oluşturulan alanlar silinemez", "enableSpacesForYourWorkspace": "Çalışma alanınız için Alanları etkinleştirin", "title": "Alanlar", "defaultSpaceName": "Genel", @@ -2246,53 +2616,468 @@ "upgradeSpaceDescription": "Çalışma alanınızı daha iyi organize etmek için birden fazla genel ve özel Alan oluşturun.", "upgrade": "Güncelle", "upgradeYourSpace": "Birden fazla Alan oluştur", - "quicklySwitch": "Hızlıca bir sonraki alana geç", - "duplicate": "Alanı Kopyala", + "quicklySwitch": "Hızlıca sonraki alana geç", + "duplicate": "Alanı Çoğalt", "movePageToSpace": "Sayfayı alana taşı", - "switchSpace": "Alanı değiştir" + "cannotMovePageToDatabase": "Sayfa veritabanına taşınamaz", + "switchSpace": "Alan değiştir", + "spaceNameCannotBeEmpty": "Alan adı boş olamaz", + "success": { + "deleteSpace": "Alan başarıyla silindi", + "renameSpace": "Alan başarıyla yeniden adlandırıldı", + "duplicateSpace": "Alan başarıyla çoğaltıldı", + "updateSpace": "Alan başarıyla güncellendi" + }, + "error": { + "deleteSpace": "Alan silinemedi", + "renameSpace": "Alan yeniden adlandırılamadı", + "duplicateSpace": "Alan çoğaltılamadı", + "updateSpace": "Alan güncellenemedi" + }, + "createSpace": "Alan oluştur", + "manageSpace": "Alanı yönet", + "renameSpace": "Alanı yeniden adlandır", + "mSpaceIconColor": "Alan ikonu rengi", + "mSpaceIcon": "Alan ikonu" }, "publish": { "hasNotBeenPublished": "Bu sayfa henüz yayınlanmadı", + "spaceHasNotBeenPublished": "Henüz bir alanın yayınlanması desteklenmiyor", "reportPage": "Sayfayı bildir", - "databaseHasNotBeenPublished": "Veritabanı yayınlama henüz desteklenmemektedir.", + "databaseHasNotBeenPublished": "Veritabanı yayınlama henüz desteklenmiyor.", "createdWith": "Şununla oluşturuldu", - "downloadApp": "AppFlowy'i İndir", + "downloadApp": "AppFlowy'yi İndir", "copy": { "codeBlock": "Kod bloğunun içeriği panoya kopyalandı", - "imageBlock": "Resim bağlantısı panoya kopyalandı", - "mathBlock": "Matematik denklemi panoya kopyalandı" + "imageBlock": "Görsel bağlantısı panoya kopyalandı", + "mathBlock": "Matematik denklemi panoya kopyalandı", + "fileBlock": "Dosya bağlantısı panoya kopyalandı" }, - "containsPublishedPage": "Bu sayfa bir veya daha fazla yayınlanmış sayfa içeriyor. Devam ederseniz, yayınlanmamış olacaklar. Silme işlemine devam etmek istiyor musunuz?", + "containsPublishedPage": "Bu sayfa bir veya daha fazla yayınlanmış sayfa içeriyor. Devam ederseniz, yayından kaldırılacaklar. Silme işlemine devam etmek istiyor musunuz?", "publishSuccessfully": "Başarıyla yayınlandı", - "unpublishSuccessfully": "Başarıyla yayınlanmamış", - "publishFailed": "Yayınlama başarısız oldu", - "unpublishFailed": "Yayından kaldırma başarısız oldu", + "unpublishSuccessfully": "Yayından kaldırma başarılı", + "publishFailed": "Yayınlanamadı", + "unpublishFailed": "Yayından kaldırılamadı", "noAccessToVisit": "Bu sayfaya erişim yok...", "createWithAppFlowy": "AppFlowy ile bir web sitesi oluşturun", - "fastWithAI": "Yapay Zeka ile hızlı ve kolay.", + "fastWithAI": "Yapay zeka ile hızlı ve kolay.", "tryItNow": "Şimdi deneyin", - "onlyGridViewCanBePublished": "Yalnızca Kılavuz görünümü yayınlanabilir", + "onlyGridViewCanBePublished": "Yalnızca Tablo görünümü yayınlanabilir", "database": { - "zero": "Seçilen {} görünümü yayınla", - "one": "Seçilen {} görünümü yayınla", - "many": "Seçilen {} görünümü yayınla", - "other": "Seçilen {} görünümü yayınla" + "zero": "{} seçili görünümü yayınla", + "one": "{} seçili görünümü yayınla", + "many": "{} seçili görünümü yayınla", + "other": "{} seçili görünümü yayınla" }, - "mustSelectPrimaryDatabase": "Birincil görünüm seçilmelidir", + "mustSelectPrimaryDatabase": "Ana görünüm seçilmelidir", "noDatabaseSelected": "Veritabanı seçilmedi, lütfen en az bir veritabanı seçin.", - "unableToDeselectPrimaryDatabase": "Birincil veritabanı seçimi kaldırılamıyor" + "unableToDeselectPrimaryDatabase": "Ana veritabanının seçimi kaldırılamaz", + "saveThisPage": "Bu şablonla başlayın", + "duplicateTitle": "Nereye eklemek istersiniz", + "selectWorkspace": "Bir çalışma alanı seçin", + "addTo": "Şuraya ekle", + "duplicateSuccessfully": "Çalışma alanınıza eklendi", + "duplicateSuccessfullyDescription": "AppFlowy yüklü değil mi? 'İndir'e tıkladıktan sonra indirme otomatik olarak başlayacak.", + "downloadIt": "İndir", + "openApp": "Uygulamada aç", + "duplicateFailed": "Çoğaltma başarısız", + "membersCount": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "useThisTemplate": "Bu şablonu kullan" }, "web": { - "continue": "Devam Et", + "continue": "Devam et", "or": "veya", - "continueWithGoogle": "Google ile Devam Et", - "continueWithGithub": "GitHub ile Devam Et", - "continueWithDiscord": "Discord ile Devam Et", - "signInAgreement": "Yukarıdaki \"Devam Et\" düğmesine tıklayarak,\nAppFlowy'nin", + "continueWithGoogle": "Google ile devam et", + "continueWithGithub": "GitHub ile devam et", + "continueWithDiscord": "Discord ile devam et", + "continueWithApple": "Apple ile devam et", + "moreOptions": "Daha fazla seçenek", + "collapse": "Daralt", + "signInAgreement": "Yukarıdaki \"Devam et\" düğmesine tıklayarak AppFlowy'nin şunlarını kabul etmiş olursunuz:", "and": "ve", "termOfUse": "Kullanım Koşulları", "privacyPolicy": "Gizlilik Politikası", - "signInError": "Oturum açma hatası", - "login": "Kaydolun veya giriş yapın" + "signInError": "Giriş hatası", + "login": "Kaydol veya giriş yap", + "fileBlock": { + "uploadedAt": "{time} tarihinde yüklendi", + "linkedAt": "Bağlantısı {time} tarihinde eklendi", + "empty": "Bir dosya yükleyin veya yerleştirin", + "uploadFailed": "Yükleme başarısız, lütfen tekrar deneyin", + "retry": "Tekrar dene" + }, + "importNotion": "Notion'dan içe aktar", + "import": "İçe aktar", + "importSuccess": "Başarıyla yüklendi", + "importSuccessMessage": "İçe aktarma tamamlandığında size bildirim göndereceğiz. Bundan sonra, içe aktarılan sayfalarınızı kenar çubuğunda görüntüleyebilirsiniz.", + "importFailed": "İçe aktarma başarısız, lütfen dosya formatını kontrol edin", + "dropNotionFile": "Yüklemek için Notion zip dosyanızı buraya bırakın veya göz atmak için tıklayın", + "error": { + "pageNameIsEmpty": "Sayfa adı boş, lütfen başka bir tane deneyin" + } + }, + "globalComment": { + "comments": "Yorumlar", + "addComment": "Yorum ekle", + "reactedBy": "tepki verenler", + "addReaction": "Tepki ekle", + "reactedByMore": "ve {count} diğer", + "showSeconds": { + "one": "1 saniye önce", + "other": "{count} saniye önce", + "zero": "Az önce", + "many": "{count} saniye önce" + }, + "showMinutes": { + "one": "1 dakika önce", + "other": "{count} dakika önce", + "many": "{count} dakika önce" + }, + "showHours": { + "one": "1 saat önce", + "other": "{count} saat önce", + "many": "{count} saat önce" + }, + "showDays": { + "one": "1 gün önce", + "other": "{count} gün önce", + "many": "{count} gün önce" + }, + "showMonths": { + "one": "1 ay önce", + "other": "{count} ay önce", + "many": "{count} ay önce" + }, + "showYears": { + "one": "1 yıl önce", + "other": "{count} yıl önce", + "many": "{count} yıl önce" + }, + "reply": "Yanıtla", + "deleteComment": "Yorumu sil", + "youAreNotOwner": "Bu yorumun sahibi siz değilsiniz", + "confirmDeleteDescription": "Bu yorumu silmek istediğinizden emin misiniz?", + "hasBeenDeleted": "Silindi", + "replyingTo": "Yanıtlanıyor", + "noAccessDeleteComment": "Bu yorumu silme izniniz yok", + "collapse": "Daralt", + "readMore": "Devamını oku", + "failedToAddComment": "Yorum eklenemedi", + "commentAddedSuccessfully": "Yorum başarıyla eklendi.", + "commentAddedSuccessTip": "Az önce bir yorum eklediniz veya yanıtladınız. En son yorumları görmek için başa dönmek ister misiniz?" + }, + "template": { + "asTemplate": "Şablon olarak kaydet", + "name": "Şablon adı", + "description": "Şablon Açıklaması", + "about": "Şablon Hakkında", + "deleteFromTemplate": "Şablonlardan sil", + "preview": "Şablon Önizleme", + "categories": "Şablon Kategorileri", + "isNewTemplate": "Yeni şablona SABİTLE", + "featured": "Öne Çıkanlara SABİTLE", + "relatedTemplates": "İlgili Şablonlar", + "requiredField": "{field} gereklidir", + "addCategory": "\"{category}\" ekle", + "addNewCategory": "Yeni kategori ekle", + "addNewCreator": "Yeni oluşturucu ekle", + "deleteCategory": "Kategoriyi sil", + "editCategory": "Kategoriyi düzenle", + "editCreator": "Oluşturucuyu düzenle", + "category": { + "name": "Kategori adı", + "icon": "Kategori ikonu", + "bgColor": "Kategori arka plan rengi", + "priority": "Kategori önceliği", + "desc": "Kategori açıklaması", + "type": "Kategori türü", + "icons": "Kategori İkonları", + "colors": "Kategori Renkleri", + "byUseCase": "Kullanım Durumuna Göre", + "byFeature": "Özelliğe Göre", + "deleteCategory": "Kategoriyi sil", + "deleteCategoryDescription": "Bu kategoriyi silmek istediğinizden emin misiniz?", + "typeToSearch": "Kategorilerde arama yapmak için yazın..." + }, + "creator": { + "label": "Şablon Oluşturucu", + "name": "Oluşturucu adı", + "avatar": "Oluşturucu avatarı", + "accountLinks": "Oluşturucu hesap bağlantıları", + "uploadAvatar": "Avatar yüklemek için tıklayın", + "deleteCreator": "Oluşturucuyu sil", + "deleteCreatorDescription": "Bu oluşturucuyu silmek istediğinizden emin misiniz?", + "typeToSearch": "Oluşturucularda arama yapmak için yazın..." + }, + "uploadSuccess": "Şablon başarıyla yüklendi", + "uploadSuccessDescription": "Şablonunuz başarıyla yüklendi. Artık şablon galerisinde görüntüleyebilirsiniz.", + "viewTemplate": "Şablonu görüntüle", + "deleteTemplate": "Şablonu sil", + "deleteSuccess": "Şablon başarıyla silindi", + "deleteTemplateDescription": "Bu işlem mevcut sayfayı veya yayınlanma durumunu etkilemeyecektir. Bu şablonu silmek istediğinizden emin misiniz?", + "addRelatedTemplate": "İlgili şablon ekle", + "removeRelatedTemplate": "İlgili şablonu kaldır", + "uploadAvatar": "Avatar yükle", + "searchInCategory": "{category} içinde ara", + "label": "Şablonlar" + }, + "fileDropzone": { + "dropFile": "Yüklemek için dosyayı bu alana tıklayın veya sürükleyin", + "uploading": "Yükleniyor...", + "uploadFailed": "Yükleme başarısız", + "uploadSuccess": "Yükleme başarılı", + "uploadSuccessDescription": "Dosya başarıyla yüklendi", + "uploadFailedDescription": "Dosya yüklenemedi", + "uploadingDescription": "Dosya yükleniyor" + }, + "gallery": { + "preview": "Tam ekran aç", + "copy": "Kopyala", + "download": "İndir", + "prev": "Önceki", + "next": "Sonraki", + "resetZoom": "Yakınlaştırmayı sıfırla", + "zoomIn": "Yakınlaştır", + "zoomOut": "Uzaklaştır" + }, + "invitation": { + "join": "Katıl", + "on": "tarihinde", + "invitedBy": "Davet eden", + "membersCount": { + "zero": "{count} üye", + "one": "{count} üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "tip": "Aşağıdaki iletişim bilgileriyle bu çalışma alanına katılmaya davet edildiniz. Bu bilgiler yanlışsa, davetiyeyi yeniden göndermesi için yöneticinizle iletişime geçin.", + "joinWorkspace": "Çalışma alanına katıl", + "success": "Çalışma alanına başarıyla katıldınız", + "successMessage": "Artık içindeki tüm sayfalara ve çalışma alanlarına erişebilirsiniz.", + "openWorkspace": "AppFlowy'yi Aç", + "alreadyAccepted": "Bu daveti zaten kabul ettiniz", + "errorModal": { + "title": "Bir hata oluştu", + "description": "Mevcut hesabınızın {email} bu çalışma alanına erişimi olmayabilir. Lütfen doğru hesapla giriş yapın veya yardım için çalışma alanı sahibiyle iletişime geçin.", + "contactOwner": "Sahiple iletişime geç", + "close": "Ana sayfaya dön", + "changeAccount": "Hesap değiştir" + } + }, + "requestAccess": { + "title": "Bu sayfaya erişim yok", + "subtitle": "Bu sayfanın sahibinden erişim talep edebilirsiniz. Onaylandığında, sayfayı görüntüleyebilirsiniz.", + "requestAccess": "Erişim talep et", + "backToHome": "Ana sayfaya dön", + "tip": "Şu anda olarak giriş yapmış durumdasınız.", + "mightBe": "Farklı bir hesapla yapmanız gerekebilir.", + "successful": "Talep başarıyla gönderildi", + "successfulMessage": "Sahibi talebinizi onayladığında size bildirim gönderilecek.", + "requestError": "Erişim talebi başarısız oldu", + "repeatRequestError": "Bu sayfa için zaten erişim talebinde bulundunuz" + }, + "approveAccess": { + "title": "Çalışma Alanı Katılım Talebini Onayla", + "requestSummary": ", 'a katılmak ve 'a erişmek istiyor", + "upgrade": "yükselt", + "downloadApp": "AppFlowy'yi İndir", + "approveButton": "Onayla", + "approveSuccess": "Başarıyla onaylandı", + "approveError": "Onaylama başarısız, çalışma alanı plan limitinin aşılmadığından emin olun", + "getRequestInfoError": "Talep bilgisi alınamadı", + "memberCount": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "alreadyProTitle": "Çalışma alanı plan limitine ulaştınız", + "alreadyProMessage": "Daha fazla üye eklemek için ile iletişime geçmelerini isteyin", + "repeatApproveError": "Bu talebi zaten onayladınız", + "ensurePlanLimit": "Çalışma alanı plan limitinin aşılmadığından emin olun. Limit aşılırsa, çalışma alanı planını veya .", + "requestToJoin": "katılmak için talep etti", + "asMember": "üye olarak" + }, + "upgradePlanModal": { + "title": "Pro'ya Yükselt", + "message": "{name} ücretsiz üye limitine ulaştı. Daha fazla üye davet etmek için Pro Plana yükseltin.", + "upgradeSteps": "AppFlowy'de planınızı nasıl yükseltirsiniz:", + "step1": "1. Ayarlar'a gidin", + "step2": "2. 'Plan'a tıklayın", + "step3": "3. 'Planı Değiştir'i seçin", + "appNote": "Not: ", + "actionButton": "Yükselt", + "downloadLink": "Uygulamayı İndir", + "laterButton": "Sonra", + "refreshNote": "Başarılı yükseltmeden sonra, yeni özelliklerinizi etkinleştirmek için tıklayın.", + "refresh": "buraya" + }, + "breadcrumbs": { + "label": "Gezinti menüsü" + }, + "time": { + "justNow": "Az önce", + "seconds": { + "one": "1 saniye", + "other": "{count} saniye" + }, + "minutes": { + "one": "1 dakika", + "other": "{count} dakika" + }, + "hours": { + "one": "1 saat", + "other": "{count} saat" + }, + "days": { + "one": "1 gün", + "other": "{count} gün" + }, + "weeks": { + "one": "1 hafta", + "other": "{count} hafta" + }, + "months": { + "one": "1 ay", + "other": "{count} ay" + }, + "years": { + "one": "1 yıl", + "other": "{count} yıl" + }, + "ago": "önce", + "yesterday": "Dün", + "today": "Bugün" + }, + "members": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "tabMenu": { + "close": "Kapat", + "closeDisabledHint": "Sabitlenmiş bir sekme kapatılamaz, lütfen önce sabitlemeyi kaldırın", + "closeOthers": "Diğer sekmeleri kapat", + "closeOthersHint": "Bu işlem, bu sekme dışındaki tüm sabitlenmemiş sekmeleri kapatacaktır", + "closeOthersDisabledHint": "Tüm sekmeler sabitlenmiş, kapatılacak sekme bulunamadı", + "favorite": "Favorilere ekle", + "unfavorite": "Favorilerden kaldır", + "favoriteDisabledHint": "Bu görünüm favorilere eklenemez", + "pinTab": "Sabitle", + "unpinTab": "Sabitlemeyi kaldır" + }, + "openFileMessage": { + "success": "Dosya başarıyla açıldı", + "fileNotFound": "Dosya bulunamadı", + "noAppToOpenFile": "Bu dosyayı açacak uygulama yok", + "permissionDenied": "Bu dosyayı açma izni yok", + "unknownError": "Dosya açılamadı" + }, + "inviteMember": { + "requestInviteMembers": "Çalışma alanınıza davet et", + "inviteFailedMemberLimit": "Üye limitine ulaşıldı, lütfen ", + "upgrade": "yükselt", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "Davetleri gönder", + "inviteAlready": "Bu e-postayı zaten davet ettiniz: {email}", + "inviteSuccess": "Davet başarıyla gönderildi", + "description": "E-postaları aralarına virgül koyarak aşağıya girin. Ücretlendirme üye sayısına göre yapılır.", + "emails": "E-posta" + }, + "quickNote": { + "label": "Hızlı Not", + "quickNotes": "Hızlı Notlar", + "search": "Hızlı Notlarda Ara", + "collapseFullView": "Tam görünümü daralt", + "expandFullView": "Tam görünümü genişlet", + "createFailed": "Hızlı Not oluşturulamadı", + "quickNotesEmpty": "Hızlı Not yok", + "emptyNote": "Boş not", + "deleteNotePrompt": "Seçilen not kalıcı olarak silinecektir. Silmek istediğinizden emin misiniz?", + "addNote": "Yeni Not", + "noAdditionalText": "Ek metin yok" + }, + "subscribe": { + "upgradePlanTitle": "Planları karşılaştır ve seç", + "yearly": "Yıllık", + "save": "%{discount} tasarruf", + "monthly": "Aylık", + "priceIn": "Fiyat: ", + "free": "Ücretsiz", + "pro": "Pro", + "freeDescription": "2 üyeye kadar bireyler için her şeyi organize etmek için", + "proDescription": "Küçük ekipler için projeleri ve ekip bilgisini yönetmek için", + "proDuration": { + "monthly": "üye başına aylık\naylık faturalandırma", + "yearly": "üye başına aylık\nyıllık faturalandırma" + }, + "cancel": "Alt plana geç", + "changePlan": "Pro Plana yükselt", + "everythingInFree": "Ücretsiz plandaki her şey +", + "currentPlan": "Mevcut", + "freeDuration": "süresiz", + "freePoints": { + "first": "2 üyeye kadar 1 işbirliği çalışma alanı", + "second": "Sınırsız sayfa ve blok", + "three": "5 GB depolama", + "four": "Akıllı arama", + "five": "20 yapay zeka yanıtı", + "six": "Mobil uygulama", + "seven": "Gerçek zamanlı işbirliği" + }, + "proPoints": { + "first": "Sınırsız depolama", + "second": "10 çalışma alanı üyesine kadar", + "three": "Sınırsız yapay zeka yanıtı", + "four": "Sınırsız dosya yükleme", + "five": "Özel alan adı" + }, + "cancelPlan": { + "title": "Gitmenize üzüldük", + "success": "Aboneliğiniz başarıyla iptal edildi", + "description": "Gitmenize üzüldük. AppFlowy'yi geliştirmemize yardımcı olmak için geri bildiriminizi almak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", + "commonOther": "Diğer", + "otherHint": "Yanıtınızı buraya yazın", + "questionOne": { + "question": "AppFlowy Pro aboneliğinizi iptal etmenize ne sebep oldu?", + "answerOne": "Maliyet çok yüksek", + "answerTwo": "Özellikler beklentileri karşılamadı", + "answerThree": "Daha iyi bir alternatif buldum", + "answerFour": "Maliyeti karşılayacak kadar kullanmadım", + "answerFive": "Hizmet sorunu veya teknik zorluklar" + }, + "questionTwo": { + "question": "Gelecekte AppFlowy Pro'ya yeniden abone olma olasılığınız nedir?", + "answerOne": "Çok muhtemel", + "answerTwo": "Biraz muhtemel", + "answerThree": "Emin değilim", + "answerFour": "Muhtemel değil", + "answerFive": "Hiç muhtemel değil" + }, + "questionThree": { + "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", + "answerOne": "Çoklu kullanıcı işbirliği", + "answerTwo": "Daha uzun süreli versiyon geçmişi", + "answerThree": "Sınırsız yapay zeka yanıtları", + "answerFour": "Yerel yapay zeka modellerine erişim" + }, + "questionFour": { + "question": "AppFlowy ile genel deneyiminizi nasıl tanımlarsınız?", + "answerOne": "Harika", + "answerTwo": "İyi", + "answerThree": "Ortalama", + "answerFour": "Ortalamanın altında", + "answerFive": "Memnun değilim" + } + } + }, + "ai": { + "contentPolicyViolation": "Hassas içerik nedeniyle görsel oluşturma başarısız oldu. Lütfen girdinizi yeniden düzenleyip tekrar deneyin" } } diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 0230d59b7a..394801ed21 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -64,7 +64,7 @@ "settings": "Налаштування", "magicLinkSent": "Magic Link надіслано!", "invalidEmail": "Будь ласка, введіть дійсну адресу електронної пошти", - "alreadyHaveAnAccount": "Вже є аккаунт?", + "alreadyHaveAnAccount": "Вже є акаунт?", "logIn": "Авторизуватися", "generalError": "Щось пішло не так. Будь ласка спробуйте пізніше", "limitRateError": "З міркувань безпеки ви можете запитувати чарівне посилання лише кожні 60 секунд", @@ -225,14 +225,14 @@ "questionBubble": { "shortcuts": "Комбінації клавіш", "whatsNew": "Що нового?", - "help": "Довідка та підтримка", "markdown": "Markdown", "debug": { "name": "Інформація для налагодження", "success": "Інформацію для налагодження скопійовано в буфер обміну!", "fail": "Не вдалося скопіювати інформацію для налагодження в буфер обміну" }, - "feedback": "Зворотний зв'язок" + "feedback": "Зворотний зв'язок", + "help": "Довідка та підтримка" }, "menuAppHeader": { "moreButtonToolTip": "Видалити, перейменувати та інше...", @@ -362,7 +362,7 @@ "helpCenter": "Центр допомоги", "add": "Додати", "yes": "Так", - "no": "Немає", + "no": "Ні", "clear": "Очистити", "remove": "Видалити", "dontRemove": "Не видаляйте", @@ -810,7 +810,7 @@ "description": "Ви впевнені, що хочете видалити {plan}? Ви негайно втратите доступ до функцій і переваг {plan}." } }, - "currentPeriodBadge": "ПОТОМ", + "currentPeriodBadge": "ПОТОЧНИЙ", "changePeriod": "Період зміни", "planPeriod": "{} період", "monthlyInterval": "Щомісяця", @@ -846,11 +846,11 @@ "itemFour": "Співпраця в реальному часі", "itemFive": "Мобільний додаток", "itemSix": "Відповіді ШІ", + "itemSeven": "Спеціальний простір імен", "itemFileUpload": "Завантаження файлів", "tooltipSix": "Термін служби означає кількість відповідей, які ніколи не скидаються", "intelligentSearch": "Інтелектуальний пошук", - "tooltipSeven": "Дозволяє налаштувати частину URL-адреси для вашої робочої області", - "itemSeven": "Спеціальний простір імен" + "tooltipSeven": "Дозволяє налаштувати частину URL-адреси для вашої робочої області" }, "freeLabels": { "itemOne": "сплачується за робоче місце", @@ -1445,8 +1445,7 @@ "url": { "launch": "Відкрити посилання в браузері", "copy": "Копіювати посилання в буфер обміну", - "textFieldHint": "Введіть URL", - "copiedNotification": "Скопійовано в буфер обміну!" + "textFieldHint": "Введіть URL" }, "relation": { "relatedDatabasePlaceLabel": "Пов'язана база даних", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 91ea6a32e8..e60648590d 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -226,14 +226,14 @@ "questionBubble": { "shortcuts": "Phím tắt", "whatsNew": "Có gì mới?", - "help": "Trợ giúp & Hỗ trợ", "markdown": "Markdown", "debug": { "name": "Thông tin gỡ lỗi", "success": "Đã sao chép thông tin gỡ lỗi vào khay nhớ tạm!", "fail": "Không thể sao chép thông tin gỡ lỗi vào khay nhớ tạm" }, - "feedback": "Nhận xét" + "feedback": "Nhận xét", + "help": "Trợ giúp & Hỗ trợ" }, "menuAppHeader": { "moreButtonToolTip": "Xóa, đổi tên và hơn thế nữa...", @@ -308,7 +308,6 @@ "storageLimitDialogTitle": "Bạn đã hết dung lượng lưu trữ miễn phí. Nâng cấp để mở khóa dung lượng lưu trữ không giới hạn", "storageLimitDialogTitleIOS": "Bạn đã hết dung lượng lưu trữ miễn phí.", "aiResponseLimitTitle": "Bạn đã hết phản hồi AI miễn phí. Nâng cấp lên Gói Pro hoặc mua tiện ích bổ sung AI để mở khóa phản hồi không giới hạn", - "aiResponseLimitTitleIOS": "Bạn đã hết lượt sử dụng AI miễn phí.", "aiResponseLimitDialogTitle": "Đã đạt đến giới hạn sử dụng AI", "aiResponseLimit": "Bạn đã hết lượt dùng AI miễn phí.\nVào Cài đặt -> Gói đăng ký -> Nhấp vào AI Max hoặc Gói Pro để có thêm lượt dùng AI", "askOwnerToUpgradeToPro": "Không gian làm việc của bạn sắp hết dung lượng lưu trữ miễn phí. Vui lòng yêu cầu chủ sở hữu không gian làm việc của bạn nâng cấp lên Gói Pro", @@ -1440,8 +1439,7 @@ "url": { "launch": "Mở liên kết trong trình duyệt", "copy": "Sao chép URL", - "textFieldHint": "Nhập một URL", - "copiedNotification": "Đã sao chép vào bảng tạm!" + "textFieldHint": "Nhập một URL" }, "relation": { "relatedDatabasePlaceLabel": "Cơ sở dữ liệu liên quan", @@ -2271,12 +2269,12 @@ "dialogTitle": "Xóa tài khoản", "dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?", "dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.", - "confirmHint1": "Vui lòng nhập \"XÓA TÀI KHOẢN CỦA TÔI\" để xác nhận.", + "confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.", "confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.", "confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI", "checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa", "failedToGetCurrentUser": "Không lấy được email người dùng hiện tại", - "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"XÓA TÀI KHOẢN CỦA TÔI\"", + "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Tài khoản đã được xóa thành công" } }, diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index d7e9b70dac..d1433929bc 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -79,6 +79,7 @@ "chooseWorkspace": "选择您的工作区", "defaultName": "我的工作区", "create": "新建工作区", + "new": "新的工作空间", "importFromNotion": "从 Notion 导入", "learnMore": "了解更多", "reset": "重置工作区", @@ -136,7 +137,8 @@ "copyLinkFailed": "无法将链接复制到剪贴板", "copyLinkToBlockSuccess": "区块链接已复制到剪贴板", "copyLinkToBlockFailed": "无法将区块链接复制到剪贴板", - "manageAllSites": "管理所有站点" + "manageAllSites": "管理所有站点", + "updatePathName": "更新路径名称" }, "moreAction": { "small": "小", @@ -149,7 +151,12 @@ "charCount": "字符数:{}", "createdAt": "创建于 :{}", "deleteView": "删除", - "duplicateView": "复制" + "duplicateView": "复制", + "wordCountLabel": "词总数:", + "charCountLabel": "字符总数:", + "createdAtLabel": "已创建的:", + "syncedAtLabel": "已同步的:", + "saveAsNewPage": "在页面中添加消息" }, "importPanel": { "textAndMarkdown": "文本 和 Markdown", @@ -170,7 +177,10 @@ "addToFavorites": "添加到收藏夹", "copyLink": "复制链接", "changeIcon": "更改图标", - "collapseAllPages": "收起全部子页面" + "collapseAllPages": "收起全部子页面", + "movePageTo": "将页面移动至", + "move": "移动", + "lockPage": "锁定页面" }, "blankPageTitle": "空白页", "newPageText": "新页面", @@ -186,6 +196,7 @@ "relatedQuestion": "相关问题", "serverUnavailable": "服务暂时不可用,请稍后再试", "aiServerUnavailable": "🌈 不妙!🌈 一只独角兽吃掉了我们的回复。请重试!", + "retry": "重试", "clickToRetry": "点击重试", "regenerateAnswer": "重新生成", "question1": "如何使用 Kanban 来管理任务", @@ -200,6 +211,20 @@ "uploadFile": "上传聊天使用的 PDF、md 或 txt 文件", "questionDetail": "{} 你好!我能怎么帮到你?", "indexingFile": "正在索引 {}", + "generatingResponse": "正在生成相应", + "sourceUnsupported": "我们当前不支持基于数据库的交流", + "regenerate": "请重试", + "addToPageTitle": "添加消息至......", + "addToNewPage": "创建新的页面", + "openPagePreviewFailedToast": "打开页面失败", + "changeFormat": { + "actionButton": "变更样式", + "confirmButton": "基于该样式重新生成", + "imageOnly": "仅图片", + "textAndImage": "文本与图片", + "text": "段落", + "table": "表格" + }, "referenceSource": "找到 {} 个来源", "referenceSources": "找到 {} 个来源", "questionTitle": "想法" @@ -245,20 +270,21 @@ "questionBubble": { "shortcuts": "快捷键", "whatsNew": "新功能", - "help": "帮助和支持", "markdown": "Markdown", "debug": { "name": "调试信息", "success": "将调试信息复制到剪贴板!", "fail": "无法将调试信息复制到剪贴板" }, - "feedback": "反馈" + "feedback": "反馈", + "help": "帮助和支持" }, "menuAppHeader": { "moreButtonToolTip": "删除、重命名等等...", "addPageTooltip": "在其中快速添加页面", "defaultNewPageName": "未命名页面", - "renameDialog": "重命名" + "renameDialog": "重命名", + "pageNameSuffix": "复制" }, "noPagesInside": "里面没有页面", "toolbar": { @@ -330,13 +356,13 @@ "storageLimitDialogTitle": "你已用尽免费存储。升级以解锁无限制存储", "storageLimitDialogTitleIOS": "你已用尽免费存储。", "aiResponseLimitTitle": "你已用尽免费 AI 回应。升级到专业版或者购买 AI 插件来解锁无限制回应", - "aiResponseLimitTitleIOS": "你已用尽免费 AI 回应。", "aiResponseLimitDialogTitle": "已达到 AI 回应限额", "aiResponseLimit": "你已用尽了免费 AI 回应。\n\n转到“设置 -> 计划 -> 点击 AI Max 或 Pro 计划”获取更多 AI 回应", "askOwnerToUpgradeToPro": "你的工作区即将用尽免费存储。请联系工作区所有者升级到专业版计划", "askOwnerToUpgradeToProIOS": "你的工作区即将用尽免费存储。", "askOwnerToUpgradeToAIMax": "你的工作区即将用尽免费 AI 回应。请联系工作区所有者升级计划或购买 AI 插件", "askOwnerToUpgradeToAIMaxIOS": "你的工作区即将用尽免费 AI 回应限额。", + "aiImageResponseLimit": "你已消耗完你的 AI 图像响应额度。\n转到 设置 -> 方案 -> 点击 AI Max 去获得更多的图像响应额度", "purchaseStorageSpace": "购买存储空间", "purchaseAIResponse": "购买", "askOwnerToUpgradeToLocalAI": "联系工作区所有者启用设备上 AI", @@ -375,6 +401,7 @@ "upload": "上传", "edit": "编辑", "delete": "删除", + "copy": "复制", "duplicate": "复制", "putback": "放回去", "update": "更新", @@ -413,6 +440,8 @@ "viewing": "查看", "editing": "编辑", "gotIt": "我知道了", + "retry": "重试", + "uploadFailed": "上传失败", "Done": "完成", "Cancel": "取消", "OK": "确认" @@ -453,7 +482,53 @@ "homepageHeader": "主页", "updateNamespace": "更新名字空间", "removeHomepage": "移除主页", - "selectHomePage": "选择一个页面" + "selectHomePage": "选择一个页面", + "customUrl": "自定义 URL", + "namespace": { + "updateExistingNamespace": "更新现有的名称空间", + "upgradeToPro": "请升级至 Pro 订阅计划以设置主页", + "redirectToPayment": "正在重定向至付款页面......", + "onlyWorkspaceOwnerCanSetHomePage": "仅工作空间的所有者能为其设置主页", + "pleaseAskOwnerToSetHomePage": "请联系工作空间所有者更新至 Pro 订阅计划" + }, + "publishedPage": { + "title": "所有已发布页面", + "description": "管理您已发布的页面", + "date": "已发布的数据", + "emptyHinText": "在当前工作空间没有已发布的页面", + "noPublishedPages": "没有已发布的页面", + "settings": "发布设置", + "clickToOpenPageInApp": "在 App 中打开页面", + "clickToOpenPageInBrowser": "在浏览器中打开页面" + }, + "error": { + "failedToUpdateNamespace": "更新名称空间失败", + "proPlanLimitation": "您需要升级至 Pro 方案以更新名称空间", + "namespaceAlreadyInUse": "该名称空间已被占用你,请尝试其他名称空间", + "invalidNamespace": "无效的名称空间,请尝试其他的名称空间", + "namespaceLengthAtLeast2Characters": "名称空间应不少于 2 个字符长度", + "onlyWorkspaceOwnerCanUpdateNamespace": "仅工作空间所有者可更新名称空间", + "onlyWorkspaceOwnerCanRemoveHomepage": "仅工作空间所有者可移除主页", + "setHomepageFailed": "设置主页失败", + "namespaceTooLong": "该名称空间过长,请尝试其他名称空间", + "namespaceTooShort": "该名称空间过短,请尝试其他名称空间", + "namespaceIsReserved": "该名称空间已被占用,请尝试其他名称空间", + "updatePathNameFailed": "更新路径名称失败", + "removeHomePageFailed": "移除主页失败", + "publishNameContainsInvalidCharacters": "该路径名称包含无效的字符,请尝试其他的路径名称", + "publishNameTooShort": "路径名称过短,请尝试其他路径名称", + "publishNameTooLong": "路径名称过长,请尝试其他路径名称", + "publishNameAlreadyInUse": "该路径名称已被使用,请尝试其他路径名称", + "namespaceContainsInvalidCharacters": "该名称空间包含无效的字符,请尝试其他名称空间", + "publishPermissionDenied": "仅工作空间所有者或页面发布者可管理发布设置", + "publishNameCannotBeEmpty": "该路径名称不能为空,请尝试其他路径名称" + }, + "success": { + "namespaceUpdated": "更新名称空间成功", + "setHomepageSuccess": "成功设置主页", + "updatePathNameSuccess": "更新路径名称成功", + "removeHomePageSuccess": "成功删除主页" + } }, "accountPage": { "menuLabel": "我的账户", @@ -594,6 +669,7 @@ "descriptionEncrypted": "你的数据已加密。", "action": "加密数据", "dialog": { + "title": "加密您的所有数据?", "description": "加密所有数据将保证数据安全。此操作无法撤消。您确定要继续吗?" } }, @@ -612,14 +688,46 @@ } }, "shortcutsPage": { + "menuLabel": "快捷键", + "title": "快捷键", "actions": { "resetDefault": "重置为默认" }, + "errorPage": { + "howToFix": "请再次尝试,如果该问题依然存在,请在 Github 上联系我们" + }, "resetDialog": { + "title": "重置快捷键", "description": "这将会将所有按键绑定重置为默认,之后无法撤销。你确定要继续吗?" }, + "conflictDialog": { + "confirmLabel": "继续" + }, "keybindings": { - "selectAllCodeblock": "全选" + "insertNewParagraphInCodeblock": "插入新的段落", + "pasteInCodeblock": "粘贴为代码块", + "selectAllCodeblock": "全选", + "copy": "复制选中的内容", + "alignLeft": "文本居左对齐", + "alignCenter": "文本居中对齐", + "alignRight": "文本居右对齐", + "undo": "撤销", + "redo": "重做", + "convertToParagraph": "将块转换为段落", + "backspace": "删除", + "deleteLeftWord": "删除左侧文字", + "deleteLeftSentence": "删除左侧句子", + "delete": "删除右侧字符", + "deleteMacOS": "删除左侧字符", + "deleteRightWord": "删除右侧文字", + "moveCursorLeft": "将光标移至左侧", + "moveCursorBeginning": "将光标移至开头", + "moveCursorLeftWord": "将光标移至文字左侧", + "moveCursorRight": "将光标移至右侧", + "moveCursorEnd": "将光标移至末尾", + "moveCursorRightWord": "将光标移至文字右侧", + "home": "滚动至顶部", + "end": "滚动至底部" } }, "aiPage": { @@ -1162,8 +1270,7 @@ "url": { "launch": "在浏览器中打开链接", "copy": "将链接复制到剪贴板", - "textFieldHint": "输入 URL", - "copiedNotification": "已复制到剪贴板!" + "textFieldHint": "输入 URL" }, "relation": { "rowSearchTextFieldPlaceholder": "搜索" @@ -1219,6 +1326,62 @@ }, "document": { "selectADocumentToLinkTo": "选择要链接到的文档" + }, + "name": { + "textStyle": "文本样式", + "list": "列表", + "toggle": "切换", + "fileAndMedia": "文件与媒体", + "simpleTable": "简单表格", + "visuals": "视觉元素", + "document": "文档", + "advanced": "高级", + "text": "文本", + "heading1": "一级标题", + "heading2": "二级标题", + "heading3": "三级标题", + "image": "图片", + "bulletedList": "项目符号列表", + "numberedList": "编号列表", + "todoList": "待办事项列表", + "doc": "文档", + "linkedDoc": "链接到页面", + "grid": "网格", + "linkedGrid": "链接网格", + "kanban": "看板", + "linkedKanban": "链接看板", + "calendar": "日历", + "linkedCalendar": "链接日历", + "quote": "引用", + "divider": "分隔符", + "table": "表格", + "callout": "提示框", + "outline": "大纲", + "mathEquation": "数学公式", + "code": "代码", + "toggleList": "切换列表", + "toggleHeading1": "切换标题1", + "toggleHeading2": "切换标题2", + "toggleHeading3": "切换标题3", + "emoji": "表情符号", + "aiWriter": "向AI提问", + "dateOrReminder": "日期或提醒", + "photoGallery": "图片库", + "file": "文件", + "twoColumns": "两列", + "threeColumns": "三列", + "fourColumns": "四列" + }, + "subPage": { + "name": "文档", + "keyword1": "子页面", + "keyword2": "页面", + "keyword3": "子页面", + "keyword4": "插入页面", + "keyword5": "嵌入页面", + "keyword6": "新页面", + "keyword7": "创建页面", + "keyword8": "文档" } }, "selectionMenu": { @@ -1230,6 +1393,16 @@ "referencedGrid": "引用的网格", "referencedCalendar": "引用的日历", "referencedDocument": "参考文档", + "aiWriter": { + "userQuestion": "向AI提问", + "continueWriting": "继续写作", + "fixSpelling": "修正拼写和语法", + "improveWriting": "优化写作", + "summarize": "总结", + "explain": "解释", + "makeShorter": "缩短", + "makeLonger": "扩展" + }, "autoGeneratorMenuItemName": "AI 创作", "autoGeneratorTitleName": "AI: 让 AI 写些什么...", "autoGeneratorLearnMore": "学习更多", @@ -1290,7 +1463,7 @@ }, "optionAction": { "click": "点击", - "toOpenMenu": " 来打开菜单", + "toOpenMenu": "打开菜单", "delete": "删除", "duplicate": "复制", "turnInto": "变成", @@ -1300,8 +1473,10 @@ "align": "对齐", "left": "左", "center": "中心", - "right": "又", - "defaultColor": "默认" + "right": "右", + "defaultColor": "默认", + "depth": "深度", + "copyLinkToBlock": "粘贴块链接" }, "image": { "addAnImage": "添加图像", @@ -1342,6 +1517,27 @@ "placeholder": "粘贴视频链接", "copiedToPasteBoard": "视频链接已复制到剪贴板", "insertVideo": "添加视频" + }, + "linkPreview": { + "typeSelection": { + "pasteAs": "粘贴为", + "mention": "提及", + "URL": "URL", + "bookmark": "书签", + "embed": "嵌入" + }, + "linkPreviewMenu": { + "toMetion": "转换为提及", + "toUrl": "转换为URL", + "toEmbed": "转换为嵌入", + "toBookmark": "转换为书签", + "copyLink": "复制链接", + "replace": "替换", + "reload": "重新加载", + "removeLink": "移除链接", + "pasteHint": "粘贴 https://...", + "unableToDisplay": "无法显示" + } } }, "outlineBlock": { @@ -1812,7 +2008,14 @@ "deleteMyAccount": "删除我的账户", "dialogTitle": "删除帐户", "dialogContent1": "你确定要永久删除您的帐户吗?", - "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。" + "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。", + "confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。", + "confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。", + "confirmHint3": "删除我的账户", + "checkToConfirmError": "你必须勾选以确认删除。", + "failedToGetCurrentUser": "获取当前用户邮箱失败", + "confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "账户删除成功" } }, "workplace": { @@ -1889,4 +2092,4 @@ "yesterday": "昨天", "today": "今天" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index 34f86cd383..b5f4ff3d5f 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -216,14 +216,14 @@ "questionBubble": { "shortcuts": "快捷鍵", "whatsNew": "有什麼新功能?", - "help": "幫助 & 支援", "markdown": "Markdown", "debug": { "name": "除錯資訊", "success": "已將除錯資訊複製到剪貼簿!", "fail": "無法將除錯資訊複製到剪貼簿" }, - "feedback": "意見回饋" + "feedback": "意見回饋", + "help": "幫助 & 支援" }, "menuAppHeader": { "moreButtonToolTip": "移除、重新命名等等...", @@ -297,7 +297,6 @@ "storageLimitDialogTitle": "您的免費儲存空間已用完,升級以解鎖無限儲存空間", "storageLimitDialogTitleIOS": "您的免費儲存空間已用完。", "aiResponseLimitTitle": "您的免費 AI 回覆已用完,升級到 Pro 或購買 AI 附加方案以解鎖無限回覆", - "aiResponseLimitTitleIOS": "您的免費 AI 回覆已用完。", "aiResponseLimitDialogTitle": "AI 回覆已達到限制", "purchaseStorageSpace": "購買儲存空間", "purchaseAIResponse": "購買" @@ -839,8 +838,7 @@ "url": { "launch": "在瀏覽器中開啟", "copy": "複製網址", - "textFieldHint": "輸入網址", - "copiedNotification": "已複製到剪貼簿" + "textFieldHint": "輸入網址" }, "menuName": "網格", "referencedGridPrefix": "檢視", diff --git a/frontend/rust-lib/.vscode/launch.json b/frontend/rust-lib/.vscode/launch.json new file mode 100644 index 0000000000..3b1e6e62a7 --- /dev/null +++ b/frontend/rust-lib/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "AF-desktop: Debug Rust", + "type": "lldb", + // "request": "attach", + // "pid": "${command:pickMyProcess}" + // To launch the application directly, use the following configuration: + "request": "launch", + "program": "/Users/lucas.xu/Desktop/appflowy_backup/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app", + }, + ] + } diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index c26bb535ff..51a3f1a3b2 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -14,6 +14,284 @@ dependencies = [ "syn 2.0.94", ] +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.4.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0346d8c1f762b41b458ed3145eea914966bb9ad20b9be0d6d463b20d45586370" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-http" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129d4c88e98860e1758c5de288d1632b07970a16d59bdf7b8d66053d582bb71f" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "ahash 0.8.6", + "base64 0.21.5", + "bitflags 2.4.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2 0.3.21", + "http 0.2.9", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.8.5", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd 0.13.2", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.9", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6398974fd4284f4768af07965701efbbb5fdc0616bff20cade1bb14b77675e24" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio 1.0.3", + "socket2 0.5.5", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-tls" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac453898d866cdbecdbc2334fe1738c747b4eba14a677261f2b768ba05329389" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "tokio", + "tokio-rustls 0.23.4", + "tokio-util", + "tracing", + "webpki-roots 0.22.6", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43428f3bf11dee6d166b00ec2df4e3aa8cc1606aaa0b7433c146852e2f4e03b" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "ahash 0.8.6", + "bytes", + "bytestring", + "cfg-if", + "cookie 0.16.2", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.5.5", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-web-lab" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7675c1a84eec1b179c844cdea8488e3e409d8e4984026e92fa96c87dd86f33c6" +dependencies = [ + "actix-http", + "actix-router", + "actix-service", + "actix-utils", + "actix-web", + "actix-web-lab-derive", + "ahash 0.8.6", + "arc-swap", + "async-trait", + "bytes", + "bytestring", + "csv", + "derive_more", + "futures-core", + "futures-util", + "http 0.2.9", + "impl-more", + "itertools 0.12.1", + "local-channel", + "mediatype", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "actix-web-lab-derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa0b287c8de4a76b691f29dbb5451e8dd5b79d777eaf87350c9b0cbfdb5e968" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-ws" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535aec173810be3ca6f25dd5b4d431ae7125d62000aa3cbae1ec739921b02cf3" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "futures-core", + "tokio", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -64,6 +342,58 @@ dependencies = [ "subtle", ] +[[package]] +name = "af-local-ai" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "af-plugin", + "anyhow", + "bytes", + "reqwest 0.11.27", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "af-mcp" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "anyhow", + "futures-util", + "mcp_daemon", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "af-plugin" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "anyhow", + "cfg-if", + "crossbeam-utils", + "log", + "once_cell", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tracing", + "winreg 0.55.0", + "xattr", +] + [[package]] name = "again" version = "0.1.2" @@ -156,19 +486,19 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", "getrandom 0.2.10", - "reqwest 0.12.9", + "reqwest 0.12.15", "serde", "serde_json", "serde_repr", @@ -183,55 +513,16 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bytes", "futures", - "pin-project", "serde", "serde_json", "serde_repr", "thiserror 1.0.64", -] - -[[package]] -name = "appflowy-local-ai" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" -dependencies = [ - "anyhow", - "appflowy-plugin", - "bytes", - "reqwest 0.11.27", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "zip 2.2.0", - "zip-extensions", -] - -[[package]] -name = "appflowy-plugin" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" -dependencies = [ - "anyhow", - "cfg-if", - "crossbeam-utils", - "log", - "once_cell", - "parking_lot 0.12.1", - "serde", - "serde_json", - "thiserror 1.0.64", - "tokio", - "tokio-stream", - "tracing", - "xattr", + "uuid", ] [[package]] @@ -283,6 +574,15 @@ dependencies = [ "zstd-safe 7.2.0", ] +[[package]] +name = "async-convert" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d416feee97712e43152cd42874de162b8f9b77295b1c85e5d92725cc8310bae" +dependencies = [ + "async-trait", +] + [[package]] name = "async-lock" version = "3.4.0" @@ -294,6 +594,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-openai" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc0b1877fb1bc415caa14d1899f0f477e8eb38f2fe16f54be196d7c4a92e15c" +dependencies = [ + "async-convert", + "backoff", + "base64 0.21.5", + "bytes", + "derive_builder 0.12.0", + "futures", + "rand 0.8.5", + "reqwest 0.11.27", + "reqwest-eventsource", + "secrecy", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -307,9 +632,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -318,9 +643,9 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", @@ -401,7 +726,7 @@ dependencies = [ "rustversion", "serde", "sync_wrapper 0.1.2", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", ] @@ -423,6 +748,20 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.10", + "instant", + "pin-project-lite", + "rand 0.8.5", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -548,6 +887,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c5f8abc69af414cbd6f2103bb668b91e584072f2105e4b38bed79b6ad0975f" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b69edf39b6f321cb2699a93fc20c256adb839719c42676d03f7aa975e4e5581d" +dependencies = [ + "darling 0.20.11", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.94", +] + [[package]] name = "borsh" version = "1.5.1" @@ -646,6 +1010,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +dependencies = [ + "bytes", +] + [[package]] name = "bzip2" version = "0.4.4" @@ -786,7 +1159,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "again", "anyhow", @@ -794,7 +1167,6 @@ dependencies = [ "arc-swap", "async-trait", "base64 0.22.1", - "bincode", "brotli", "bytes", "chrono", @@ -818,19 +1190,18 @@ dependencies = [ "pin-project", "prost 0.13.3", "rayon", - "reqwest 0.12.9", + "reqwest 0.12.15", "scraper 0.17.1", "semver", "serde", "serde_json", - "serde_repr", "serde_urlencoded", "shared-entity", "thiserror 1.0.64", "tokio", "tokio-retry", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "tokio-util", "tracing", "url", @@ -843,7 +1214,7 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "collab-entity", "collab-rt-entity", @@ -856,7 +1227,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "futures-channel", "futures-util", @@ -865,18 +1236,19 @@ dependencies = [ "percent-encoding", "thiserror 1.0.64", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "wasm-bindgen", "web-sys", ] [[package]] name = "cmd_lib" -version = "1.3.0" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba0f413777386d37f85afa5242f277a7b461905254c1af3c339d4af06800f62" +checksum = "371c15a3c178d0117091bd84414545309ca979555b1aad573ef591ad58818d41" dependencies = [ "cmd_lib_macros", + "env_logger 0.10.2", "faccess", "lazy_static", "log", @@ -885,20 +1257,20 @@ dependencies = [ [[package]] name = "cmd_lib_macros" -version = "1.3.0" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e66605092ff6c6e37e0246601ae6c3f62dc1880e0599359b5f303497c112dc0" +checksum = "cb844bd05be34d91eb67101329aeba9d3337094c04fd8507d821db7ebb488eaf" dependencies = [ - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.94", ] [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -923,7 +1295,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-trait", @@ -963,7 +1335,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -984,7 +1356,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "bytes", @@ -1004,7 +1376,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "arc-swap", @@ -1026,7 +1398,7 @@ dependencies = [ [[package]] name = "collab-importer" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-recursion", @@ -1068,7 +1440,6 @@ version = "0.1.0" dependencies = [ "anyhow", "arc-swap", - "async-trait", "collab", "collab-database", "collab-document", @@ -1079,18 +1450,18 @@ dependencies = [ "diesel", "flowy-error", "flowy-sqlite", - "futures", "lib-infra", "serde", "serde_json", "tokio", "tracing", + "uuid", ] [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-stream", @@ -1128,13 +1499,12 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", "bytes", "chrono", - "client-websocket", "collab", "collab-entity", "collab-rt-protocol", @@ -1143,17 +1513,15 @@ dependencies = [ "prost-build", "protoc-bin-vendored", "serde", - "serde_json", "serde_repr", - "thiserror 1.0.64", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "yrs", ] [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "async-trait", @@ -1164,13 +1532,14 @@ dependencies = [ "thiserror 1.0.64", "tokio", "tracing", + "uuid", "yrs", ] [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "collab", @@ -1256,6 +1625,23 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1273,7 +1659,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" dependencies = [ - "cookie", + "cookie 0.18.1", "document-features", "idna 1.0.3", "log", @@ -1446,35 +1832,70 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.8" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] name = "darling_core" -version = "0.20.8" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", "syn 2.0.94", ] [[package]] name = "darling_macro" -version = "0.20.8" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", "quote", "syn 2.0.94", ] @@ -1548,11 +1969,8 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ - "anyhow", - "app-error", - "appflowy-ai-client", "bincode", "bytes", "chrono", @@ -1628,14 +2046,78 @@ dependencies = [ "syn 2.0.94", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.94", +] + [[package]] name = "derive_more" version = "0.99.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" dependencies = [ + "convert_case", "proc-macro2", "quote", + "rustc_version", "syn 1.0.109", ] @@ -1736,9 +2218,9 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "downcast-rs" -version = "1.2.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" [[package]] name = "dtoa" @@ -1798,6 +2280,19 @@ dependencies = [ "regex", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1818,7 +2313,6 @@ dependencies = [ name = "event-integration-test" version = "0.1.0" dependencies = [ - "anyhow", "assert-json-diff", "bytes", "chrono", @@ -1827,15 +2321,11 @@ dependencies = [ "collab-document", "collab-entity", "collab-folder", - "collab-plugins", - "dotenv", "flowy-ai", "flowy-ai-pub", "flowy-core", - "flowy-database-pub", "flowy-database2", "flowy-document", - "flowy-document-pub", "flowy-folder", "flowy-folder-pub", "flowy-notification", @@ -1847,7 +2337,6 @@ dependencies = [ "flowy-user", "flowy-user-pub", "futures", - "futures-util", "lib-dispatch", "lib-infra", "nanoid", @@ -1857,10 +2346,7 @@ dependencies = [ "serde", "serde_json", "strum", - "tempdir", - "thread-id", "tokio", - "tokio-postgres", "tracing", "uuid", "walkdir", @@ -1888,6 +2374,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "faccess" version = "0.2.4" @@ -1909,12 +2406,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fancy-regex" version = "0.10.0" @@ -1985,12 +2476,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "finl_unicode" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" - [[package]] name = "fixedbitset" version = "0.4.2" @@ -2011,10 +2496,11 @@ dependencies = [ name = "flowy-ai" version = "0.1.0" dependencies = [ + "af-local-ai", + "af-mcp", + "af-plugin", "allo-isolate", "anyhow", - "appflowy-local-ai", - "appflowy-plugin", "arc-swap", "base64 0.21.5", "bytes", @@ -2033,7 +2519,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "log", - "md5", "notify", "pin-project", "protobuf", @@ -2050,20 +2535,20 @@ dependencies = [ "tracing-subscriber", "uuid", "validator 0.18.1", - "zip 2.2.0", - "zip-extensions", ] [[package]] name = "flowy-ai-pub" version = "0.1.0" dependencies = [ - "bytes", "client-api", "flowy-error", + "flowy-sqlite", "futures", "lib-infra", + "serde", "serde_json", + "uuid", ] [[package]] @@ -2103,8 +2588,9 @@ dependencies = [ name = "flowy-core" version = "0.1.0" dependencies = [ + "af-local-ai", + "af-plugin", "anyhow", - "appflowy-local-ai", "arc-swap", "base64 0.21.5", "bytes", @@ -2136,7 +2622,6 @@ dependencies = [ "flowy-storage-pub", "flowy-user", "flowy-user-pub", - "futures", "futures-core", "lib-dispatch", "lib-infra", @@ -2149,20 +2634,20 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "url", "uuid", - "walkdir", ] [[package]] name = "flowy-database-pub" version = "0.1.0" dependencies = [ - "anyhow", "client-api", "collab", "collab-entity", "flowy-error", "lib-infra", + "uuid", ] [[package]] @@ -2211,6 +2696,7 @@ dependencies = [ "tokio-util", "tracing", "url", + "uuid", "validator 0.18.1", ] @@ -2288,11 +2774,11 @@ dependencies = [ name = "flowy-document-pub" version = "0.1.0" dependencies = [ - "anyhow", "collab", "collab-document", "flowy-error", "lib-infra", + "uuid", ] [[package]] @@ -2322,6 +2808,7 @@ dependencies = [ "thiserror 1.0.64", "tokio", "url", + "uuid", "validator 0.18.1", ] @@ -2348,6 +2835,7 @@ dependencies = [ "flowy-notification", "flowy-search-pub", "flowy-sqlite", + "flowy-user-pub", "futures", "lazy_static", "lib-dispatch", @@ -2404,20 +2892,17 @@ dependencies = [ name = "flowy-search" version = "0.1.0" dependencies = [ + "allo-isolate", "async-stream", "bytes", "collab", "collab-folder", - "diesel", - "diesel_derives", - "diesel_migrations", + "derive_builder 0.20.2", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-folder", - "flowy-notification", "flowy-search-pub", - "flowy-sqlite", "flowy-user", "futures", "lib-dispatch", @@ -2425,13 +2910,13 @@ dependencies = [ "protobuf", "serde", "serde_json", - "strsim 0.11.0", + "strsim 0.11.1", "strum_macros 0.26.1", "tantivy", - "tempfile", "tokio", + "tokio-stream", "tracing", - "validator 0.18.1", + "uuid", ] [[package]] @@ -2442,8 +2927,8 @@ dependencies = [ "collab", "collab-folder", "flowy-error", - "futures", "lib-infra", + "uuid", ] [[package]] @@ -2463,8 +2948,8 @@ dependencies = [ "collab-folder", "collab-plugins", "collab-user", - "dashmap 6.0.1", "dotenv", + "flowy-ai", "flowy-ai-pub", "flowy-database-pub", "flowy-document-pub", @@ -2472,33 +2957,25 @@ dependencies = [ "flowy-folder-pub", "flowy-search-pub", "flowy-server-pub", + "flowy-sqlite", "flowy-storage", "flowy-storage-pub", "flowy-user-pub", "futures", "futures-util", - "hex", - "hyper 0.14.27", "lazy_static", - "lib-dispatch", "lib-infra", - "mime_guess", - "postgrest", "rand 0.8.5", - "reqwest 0.11.27", "semver", "serde", "serde_json", "thiserror 1.0.64", "tokio", - "tokio-retry", "tokio-stream", "tokio-util", "tracing", "tracing-subscriber", - "url", "uuid", - "yrs", ] [[package]] @@ -2535,7 +3012,6 @@ name = "flowy-storage" version = "0.1.0" dependencies = [ "allo-isolate", - "anyhow", "async-trait", "bytes", "chrono", @@ -2547,8 +3023,6 @@ dependencies = [ "flowy-notification", "flowy-sqlite", "flowy-storage-pub", - "futures-util", - "fxhash", "lib-dispatch", "lib-infra", "mime_guess", @@ -2576,9 +3050,8 @@ dependencies = [ "mime", "mime_guess", "serde", - "serde_json", "tokio", - "tracing", + "uuid", ] [[package]] @@ -2601,7 +3074,6 @@ dependencies = [ "collab-user", "dashmap 6.0.1", "diesel", - "diesel_derives", "fake", "fancy-regex 0.11.0", "flowy-codegen", @@ -2615,8 +3087,6 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", - "nanoid", - "once_cell", "protobuf", "quickcheck", "quickcheck_macros", @@ -2626,7 +3096,6 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_repr", "strum", "strum_macros 0.25.2", "tokio", @@ -2641,7 +3110,6 @@ dependencies = [ name = "flowy-user-pub" version = "0.1.0" dependencies = [ - "anyhow", "base64 0.21.5", "chrono", "client-api", @@ -2650,6 +3118,7 @@ dependencies = [ "collab-folder", "flowy-error", "flowy-folder-pub", + "flowy-sqlite", "lib-infra", "serde", "serde_json", @@ -2709,12 +3178,6 @@ dependencies = [ "libc", ] -[[package]] -name = "fuchsia-cprng" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" - [[package]] name = "funty" version = "2.0.0" @@ -2733,9 +3196,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -2748,9 +3211,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -2758,15 +3221,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2775,9 +3238,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -2794,9 +3257,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -2805,21 +3268,27 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2842,19 +3311,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "generator" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows 0.48.0", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2970,28 +3426,24 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", - "futures-util", "getrandom 0.2.10", "gotrue-entity", "infra", - "reqwest 0.12.9", + "reqwest 0.12.15", "serde", "serde_json", - "tokio", "tracing", ] [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ - "anyhow", "app-error", - "chrono", "jsonwebtoken", "lazy_static", "serde", @@ -3085,9 +3537,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.3.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" [[package]] name = "hex" @@ -3352,6 +3804,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperloglogplus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" +dependencies = [ + "serde", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -3557,6 +4018,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + [[package]] name = "indexed_db_futures" version = "0.4.2" @@ -3598,13 +4065,13 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bytes", "futures", "pin-project", - "reqwest 0.12.9", + "reqwest 0.12.15", "serde", "serde_json", "tokio", @@ -3648,9 +4115,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -3659,6 +4123,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "itertools" version = "0.10.5" @@ -3677,6 +4152,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -3694,10 +4178,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.67" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -3735,6 +4220,12 @@ dependencies = [ "libc", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.4.0" @@ -3903,6 +4394,23 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.10" @@ -3925,20 +4433,6 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - [[package]] name = "lru" version = "0.12.3" @@ -4067,12 +4561,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" [[package]] -name = "md-5" -version = "0.10.5" +name = "mcp_daemon" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +checksum = "ed0bdbb83765c69f4bf506d318119a25776dbad54906de9c17c1eae566088100" dependencies = [ - "digest", + "actix-cors", + "actix-web", + "actix-web-lab", + "actix-ws", + "anyhow", + "async-openai", + "async-trait", + "bytes", + "bytestring", + "futures", + "futures-core", + "futures-util", + "jsonwebtoken", + "pin-project-lite", + "reqwest 0.12.15", + "rustls 0.20.9", + "rustls-pemfile 1.0.3", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.21.0", + "tracing", + "url", + "uuid", ] [[package]] @@ -4083,14 +4602,19 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "measure_time" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" dependencies = [ - "instant", "log", ] +[[package]] +name = "mediatype" +version = "0.19.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" + [[package]] name = "memchr" version = "2.7.4" @@ -4179,6 +4703,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + [[package]] name = "moka" version = "0.12.8" @@ -4272,7 +4808,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 0.8.9", "walkdir", "windows-sys 0.48.0", ] @@ -4333,16 +4869,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" version = "0.32.1" @@ -4360,12 +4886,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "oneshot" -version = "0.1.6" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" -dependencies = [ - "loom", -] +checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" [[package]] name = "opaque-debug" @@ -4429,12 +4952,12 @@ dependencies = [ [[package]] name = "os_pipe" -version = "0.9.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb233f06c2307e1f5ce2ecad9f8121cffbbee2c95428f44ea85222e460d0d213" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" dependencies = [ "libc", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -4445,9 +4968,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "ownedbytes" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" dependencies = [ "stable_deref_trait", ] @@ -4788,44 +5311,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "postgres-protocol" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" -dependencies = [ - "base64 0.21.5", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.8.5", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2234cdee9408b523530a9b6d2d6b373d1db34f6a8e51dc03ded1828d7fb67c" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol", -] - -[[package]] -name = "postgrest" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7" -dependencies = [ - "reqwest 0.11.27", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -5152,7 +5637,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "env_logger", + "env_logger 0.8.4", "log", "rand 0.8.5", ] @@ -5217,7 +5702,7 @@ dependencies = [ "once_cell", "socket2 0.5.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5246,19 +5731,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi", -] - [[package]] name = "rand" version = "0.7.3" @@ -5304,21 +5776,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.5.1" @@ -5394,15 +5851,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", -] - [[package]] name = "redox_syscall" version = "0.1.57" @@ -5479,6 +5927,12 @@ dependencies = [ "regex-syntax 0.8.4", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -5497,15 +5951,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - [[package]] name = "rend" version = "0.4.0" @@ -5542,6 +5987,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.21.7", + "rustls-native-certs", "rustls-pemfile 1.0.3", "serde", "serde_json", @@ -5558,19 +6004,18 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.25.2", - "winreg", + "winreg 0.50.0", ] [[package]] name = "reqwest" -version = "0.12.9" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "base64 0.22.1", "bytes", - "cookie", + "cookie 0.18.1", "cookie_store", "encoding_rs", "futures-core", @@ -5605,6 +6050,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls 0.26.1", "tokio-util", + "tower 0.5.2", "tower-service", "url", "wasm-bindgen", @@ -5615,6 +6061,22 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "reqwest-eventsource" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f03f570355882dd8d15acc3a313841e6e90eddbc76a93c748fd82cc13ba9f51" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest 0.11.27", + "thiserror 1.0.64", +] + [[package]] name = "ring" version = "0.16.20" @@ -5758,6 +6220,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + [[package]] name = "rustls" version = "0.21.7" @@ -5784,6 +6258,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.3", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.3" @@ -5891,12 +6377,6 @@ dependencies = [ "parking_lot 0.12.1", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -5952,6 +6432,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -6005,18 +6495,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -6035,10 +6525,23 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.128" +name = "serde_html_form" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +dependencies = [ + "form_urlencoded", + "indexmap 2.1.0", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -6140,7 +6643,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=4a26572a4e43714def9b362d444c640fdf1bc0d9#4a26572a4e43714def9b362d444c640fdf1bc0d9" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "app-error", @@ -6152,14 +6655,12 @@ dependencies = [ "futures", "gotrue-entity", "infra", - "log", "pin-project", - "reqwest 0.12.9", + "reqwest 0.12.15", "serde", "serde_json", "serde_repr", "thiserror 1.0.64", - "tracing", "uuid", "validator 0.19.0", ] @@ -6232,9 +6733,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "sketches-ddsketch" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" dependencies = [ "serde", ] @@ -6336,17 +6837,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" -dependencies = [ - "finl_unicode", - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "strsim" version = "0.10.0" @@ -6355,9 +6845,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -6481,7 +6971,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows 0.52.0", + "windows", ] [[package]] @@ -6534,14 +7024,15 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856" +checksum = "b21ad8b222d71c57aa979353ed702f0bc6d97e66d368962cbded57fbd19eedd7" dependencies = [ "aho-corasick", "arc-swap", "base64 0.22.1", "bitpacking", + "bon", "byteorder", "census", "crc32fast", @@ -6551,20 +7042,20 @@ dependencies = [ "fnv", "fs4", "htmlescape", - "itertools 0.12.1", + "hyperloglogplus", + "itertools 0.14.0", "levenshtein_automata", "log", "lru", "lz4_flex", "measure_time", "memmap2", - "num_cpus", "once_cell", "oneshot", "rayon", "regex", "rust-stemmers", - "rustc-hash 1.1.0", + "rustc-hash 2.1.0", "serde", "serde_json", "sketches-ddsketch", @@ -6577,7 +7068,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 1.0.64", + "thiserror 2.0.9", "time", "uuid", "winapi", @@ -6585,22 +7076,22 @@ dependencies = [ [[package]] name = "tantivy-bitpacker" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" +checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" +checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" dependencies = [ "downcast-rs", "fastdivide", - "itertools 0.12.1", + "itertools 0.14.0", "serde", "tantivy-bitpacker", "tantivy-common", @@ -6610,9 +7101,9 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" +checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" dependencies = [ "async-trait", "byteorder", @@ -6634,19 +7125,23 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" +checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" dependencies = [ "nom", + "serde", + "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" dependencies = [ + "futures-util", + "itertools 0.14.0", "tantivy-bitpacker", "tantivy-common", "tantivy-fst", @@ -6655,9 +7150,9 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" dependencies = [ "murmurhash32", "rand_distr", @@ -6666,9 +7161,9 @@ dependencies = [ [[package]] name = "tantivy-tokenizer-api" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" +checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" dependencies = [ "serde", ] @@ -6679,26 +7174,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tempdir" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" -dependencies = [ - "rand 0.4.6", - "remove_dir_all", -] - [[package]] name = "tempfile" -version = "3.10.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", "fastrand", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6734,6 +7220,15 @@ dependencies = [ "unic-segment", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.1.17" @@ -6863,22 +7358,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.0.3", "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2 0.5.5", "tokio-macros", "tracing", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -6893,9 +7387,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -6912,32 +7406,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d340244b32d920260ae7448cb72b6e238bddc3d4f7603394e7dd46ed8e48f5b8" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot 0.12.1", - "percent-encoding", - "phf 0.11.2", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.8.5", - "socket2 0.5.5", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-retry" version = "0.3.0" @@ -6949,6 +7417,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -6992,7 +7471,21 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.21.0", ] [[package]] @@ -7087,7 +7580,7 @@ dependencies = [ "prost 0.12.3", "tokio", "tokio-stream", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", "tracing", @@ -7114,16 +7607,31 @@ dependencies = [ ] [[package]] -name = "tower-layer" -version = "0.3.2" +name = "tower" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -7309,6 +7817,26 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.2.0", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.64", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.16.0" @@ -7456,6 +7984,7 @@ dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", + "serde", ] [[package]] @@ -7532,7 +8061,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error", "proc-macro2", @@ -7546,7 +8075,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -7605,23 +8134,24 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.94", @@ -7642,9 +8172,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7652,9 +8182,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -7665,9 +8195,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-streams" @@ -7718,10 +8251,23 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.2" +name = "webpki" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] [[package]] name = "webpki-roots" @@ -7744,16 +8290,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "whoami" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -7785,15 +8321,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows" version = "0.52.0" @@ -7814,33 +8341,38 @@ dependencies = [ ] [[package]] -name = "windows-registry" -version = "0.2.0" +name = "windows-link" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ "windows-result", "windows-strings", - "windows-targets 0.52.6", + "windows-targets 0.53.0", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -7861,6 +8393,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -7885,13 +8426,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -7904,6 +8461,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -7916,6 +8479,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -7928,12 +8497,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -7946,6 +8527,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -7958,6 +8545,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -7970,6 +8563,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -7982,6 +8581,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.5.30" @@ -8001,6 +8606,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "write16" version = "1.0.0" @@ -8216,15 +8831,6 @@ dependencies = [ "zstd 0.13.2", ] -[[package]] -name = "zip-extensions" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb0a99499b3497d765525c5d05e3ade9ca4a731c184365c19472c3fd6ba86341" -dependencies = [ - "zip 2.2.0", -] - [[package]] name = "zopfli" version = "0.8.1" diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 8435b0f904..1561c7ea7d 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -77,9 +77,10 @@ diesel = { version = "2.1.0", features = [ "r2d2", "serde_json", ] } +diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" -futures = "0.3.29" +futures = "0.3.31" tokio = "1.38.0" tokio-stream = "0.1.14" async-trait = "0.1.81" @@ -97,14 +98,18 @@ validator = { version = "0.18", features = ["derive"] } tokio-util = "0.7.11" zip = "2.2.0" dashmap = "6.0.1" +derive_builder = "0.20.2" +tantivy = { version = "0.24.0" } +af-plugin = { version = "0.1" } +af-local-ai = { version = "0.1" } # Please using the following command to update the revision id # Current directory: frontend # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "4a26572a4e43714def9b362d444c640fdf1bc0d9" } -client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "4a26572a4e43714def9b362d444c640fdf1bc0d9" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } [profile.dev] opt-level = 0 @@ -139,18 +144,19 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120 # 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 = "98463cc" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } -collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } # Working directory: frontend # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -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" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml b/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml index 27d36c310c..ae07268ee9 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml +++ b/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml @@ -12,7 +12,7 @@ serde_json.workspace = true flowy-ast.workspace = true quote = "1.0" -cmd_lib = { version = "1.3.0", optional = true } +cmd_lib = { version = "1.9.5", optional = true } protoc-rust = { version = "2.28.0", optional = true } #protobuf-codegen = { version = "3.7.1" } walkdir = { version = "2", optional = true } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs index 1ddb1bdeb0..677e7bcddf 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs @@ -76,64 +76,64 @@ pub fn dart_gen(crate_name: &str) { } } -#[allow(unused_variables)] -pub fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { - // 1. generate the proto files to proto_file_dir - #[cfg(feature = "proto_gen")] - let proto_crates = gen_proto_files(crate_name); - - for proto_crate in proto_crates { - let mut proto_file_paths = vec![]; - let mut file_names = vec![]; - let proto_file_output_path = proto_crate - .proto_output_path() - .to_str() - .unwrap() - .to_string(); - let protobuf_output_path = proto_crate - .protobuf_crate_path() - .to_str() - .unwrap() - .to_string(); - - for (path, file_name) in WalkDir::new(&proto_file_output_path) - .into_iter() - .filter_map(|e| e.ok()) - .map(|e| { - let path = e.path().to_str().unwrap().to_string(); - let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); - (path, file_name) - }) - { - if path.ends_with(".proto") { - // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project - println!("cargo:rerun-if-changed={}", path); - proto_file_paths.push(path); - file_names.push(file_name); - } - } - let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); - - // 2. generate the protobuf files(Dart) - #[cfg(feature = "ts")] - generate_ts_protobuf_files( - dest_folder_name, - &proto_file_output_path, - &proto_file_paths, - &file_names, - &protoc_bin_path, - &project, - ); - - // 3. generate the protobuf files(Rust) - generate_rust_protobuf_files( - &protoc_bin_path, - &proto_file_paths, - &proto_file_output_path, - &protobuf_output_path, - ); - } -} +// #[allow(unused_variables)] +// fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { +// // 1. generate the proto files to proto_file_dir +// #[cfg(feature = "proto_gen")] +// let proto_crates = gen_proto_files(crate_name); +// +// for proto_crate in proto_crates { +// let mut proto_file_paths = vec![]; +// let mut file_names = vec![]; +// let proto_file_output_path = proto_crate +// .proto_output_path() +// .to_str() +// .unwrap() +// .to_string(); +// let protobuf_output_path = proto_crate +// .protobuf_crate_path() +// .to_str() +// .unwrap() +// .to_string(); +// +// for (path, file_name) in WalkDir::new(&proto_file_output_path) +// .into_iter() +// .filter_map(|e| e.ok()) +// .map(|e| { +// let path = e.path().to_str().unwrap().to_string(); +// let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); +// (path, file_name) +// }) +// { +// if path.ends_with(".proto") { +// // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project +// println!("cargo:rerun-if-changed={}", path); +// proto_file_paths.push(path); +// file_names.push(file_name); +// } +// } +// let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); +// +// // 2. generate the protobuf files(Dart) +// #[cfg(feature = "ts")] +// generate_ts_protobuf_files( +// dest_folder_name, +// &proto_file_output_path, +// &proto_file_paths, +// &file_names, +// &protoc_bin_path, +// &project, +// ); +// +// // 3. generate the protobuf files(Rust) +// generate_rust_protobuf_files( +// &protoc_bin_path, +// &proto_file_paths, +// &proto_file_output_path, +// &protobuf_output_path, +// ); +// } +// } fn generate_rust_protobuf_files( protoc_bin_path: &Path, diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs index ff51ff952b..97a7f5f529 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs @@ -153,8 +153,7 @@ pub fn parse_event_crate(event_crate: &TsEventCrate) -> Vec { attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) - .enumerate() - .map(|(_index, variant)| EventASTContext::from(&variant.attrs)) + .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], diff --git a/frontend/rust-lib/collab-integrate/Cargo.toml b/frontend/rust-lib/collab-integrate/Cargo.toml index ab375c77fc..0cbdd41ccd 100644 --- a/frontend/rust-lib/collab-integrate/Cargo.toml +++ b/frontend/rust-lib/collab-integrate/Cargo.toml @@ -19,14 +19,13 @@ serde.workspace = true serde_json.workspace = true anyhow.workspace = true tracing.workspace = true -async-trait.workspace = true tokio = { workspace = true, features = ["sync"] } lib-infra = { workspace = true } -futures = "0.3" arc-swap = "1.7" flowy-sqlite = { workspace = true } diesel.workspace = true flowy-error.workspace = true +uuid.workspace = true [features] default = [] diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index b6e89a5a2d..10149bf259 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -33,8 +33,10 @@ use collab_plugins::local_storage::kv::KVTransactionDB; use collab_plugins::local_storage::CollabPersistenceConfig; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; +use flowy_error::FlowyError; use lib_infra::{if_native, if_wasm}; use tracing::{error, instrument, trace, warn}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum CollabPluginProviderType { @@ -66,8 +68,8 @@ impl Display for CollabPluginProviderContext { } pub trait WorkspaceCollabIntegrate: Send + Sync { - fn workspace_id(&self) -> Result; - fn device_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn device_id(&self) -> Result; } pub struct AppFlowyCollabBuilder { @@ -119,15 +121,15 @@ impl AppFlowyCollabBuilder { pub fn collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, ) -> Result { // Compare the workspace_id with the currently opened workspace_id. Return an error if they do not match. // This check is crucial in asynchronous code contexts where the workspace_id might change during operation. let actual_workspace_id = self.workspace_integrate.workspace_id()?; - if workspace_id != actual_workspace_id { + if workspace_id != &actual_workspace_id { return Err(anyhow::anyhow!( "workspace_id not match when build collab. expect workspace_id: {}, actual workspace_id: {}", workspace_id, @@ -135,12 +137,11 @@ impl AppFlowyCollabBuilder { )); } let device_id = self.workspace_integrate.device_id()?; - let workspace_id = self.workspace_integrate.workspace_id()?; Ok(CollabObject::new( uid, object_id.to_string(), collab_type, - workspace_id, + workspace_id.to_string(), device_id, )) } @@ -276,7 +277,7 @@ impl AppFlowyCollabBuilder { let collab_db = collab_db.clone(); let device_id = self.workspace_integrate.device_id()?; let collab = tokio::task::spawn_blocking(move || { - let mut collab = CollabBuilder::new(object.uid, &object.object_id, data_source) + let collab = CollabBuilder::new(object.uid, &object.object_id, data_source) .with_device_id(device_id) .build()?; let persistence_config = CollabPersistenceConfig::default(); @@ -284,12 +285,11 @@ impl AppFlowyCollabBuilder { object.uid, object.workspace_id.clone(), object.object_id.to_string(), - object.collab_type.clone(), + object.collab_type, collab_db, persistence_config, ); collab.add_plugin(Box::new(db_plugin)); - collab.initialize(); Ok::<_, Error>(collab) }) .await??; @@ -399,11 +399,11 @@ impl CollabBuilderConfig { pub struct CollabPersistenceImpl { pub db: Weak, pub uid: i64, - pub workspace_id: String, + pub workspace_id: Uuid, } impl CollabPersistenceImpl { - pub fn new(db: Weak, uid: i64, workspace_id: String) -> Self { + pub fn new(db: Weak, uid: i64, workspace_id: Uuid) -> Self { Self { db, uid, @@ -425,10 +425,11 @@ impl CollabPersistence for CollabPersistenceImpl { let object_id = collab.object_id().to_string(); let rocksdb_read = collab_db.read_txn(); + let workspace_id = self.workspace_id.to_string(); - if rocksdb_read.is_exist(self.uid, &self.workspace_id, &object_id) { + if rocksdb_read.is_exist(self.uid, &workspace_id, &object_id) { let mut txn = collab.transact_mut(); - match rocksdb_read.load_doc_with_txn(self.uid, &self.workspace_id, &object_id, &mut txn) { + match rocksdb_read.load_doc_with_txn(self.uid, &workspace_id, &object_id, &mut txn) { Ok(update_count) => { trace!( "did load collab:{}-{} from disk, update_count:{}", @@ -453,6 +454,7 @@ impl CollabPersistence for CollabPersistenceImpl { object_id: &str, encoded_collab: EncodedCollab, ) -> Result<(), CollabError> { + let workspace_id = self.workspace_id.to_string(); let collab_db = self .db .upgrade() @@ -461,7 +463,7 @@ impl CollabPersistence for CollabPersistenceImpl { write_txn .flush_doc( self.uid, - self.workspace_id.as_str(), + workspace_id.as_str(), object_id, encoded_collab.state_vector.to_vec(), encoded_collab.doc_state.to_vec(), diff --git a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs index 82e993fc49..adb8b72de1 100644 --- a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs +++ b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs @@ -7,6 +7,8 @@ use flowy_sqlite::{ DBConnection, ExpressionMethods, Identifiable, Insertable, Queryable, }; use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; #[derive(Queryable, Insertable, Identifiable)] #[diesel(table_name = af_collab_metadata)] @@ -43,13 +45,18 @@ pub fn batch_insert_collab_metadata( pub fn batch_select_collab_metadata( mut conn: DBConnection, - object_ids: &[String], -) -> FlowyResult> { + object_ids: &[Uuid], +) -> FlowyResult> { + let object_ids = object_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + let metadata = dsl::af_collab_metadata - .filter(af_collab_metadata::object_id.eq_any(object_ids)) + .filter(af_collab_metadata::object_id.eq_any(&object_ids)) .load::(&mut conn)? .into_iter() - .map(|m| (m.object_id.clone(), m)) + .flat_map(|m| Uuid::from_str(&m.object_id).map(|v| (v, m))) .collect(); Ok(metadata) } diff --git a/frontend/rust-lib/dart-ffi/Cargo.toml b/frontend/rust-lib/dart-ffi/Cargo.toml index 8ad17c028f..969f64e6f9 100644 --- a/frontend/rust-lib/dart-ffi/Cargo.toml +++ b/frontend/rust-lib/dart-ffi/Cargo.toml @@ -44,7 +44,7 @@ collab-integrate = { workspace = true } flowy-derive.workspace = true serde_yaml = "0.9.27" flowy-error = { workspace = true, features = ["impl_from_sqlite", "impl_from_dispatch_error", "impl_from_appflowy_cloud", "impl_from_reqwest", "impl_from_serde", "dart"] } -futures = "0.3.26" +futures = "0.3.31" [features] default = ["dart"] diff --git a/frontend/rust-lib/event-integration-test/Cargo.toml b/frontend/rust-lib/event-integration-test/Cargo.toml index 392d82d858..6b2d5af7ba 100644 --- a/frontend/rust-lib/event-integration-test/Cargo.toml +++ b/frontend/rust-lib/event-integration-test/Cargo.toml @@ -12,16 +12,13 @@ flowy-user-pub = { workspace = true } flowy-folder = { path = "../flowy-folder", features = ["test_helper"] } flowy-folder-pub = { workspace = true } flowy-database2 = { path = "../flowy-database2" } -flowy-database-pub = { workspace = true } flowy-document = { path = "../flowy-document" } -flowy-document-pub = { workspace = true } flowy-ai = { workspace = true } lib-dispatch = { workspace = true } lib-infra = { workspace = true } flowy-server = { path = "../flowy-server" } flowy-server-pub = { workspace = true } flowy-notification = { workspace = true } -anyhow.workspace = true flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } flowy-search = { workspace = true } @@ -31,8 +28,6 @@ serde.workspace = true serde_json.workspace = true protobuf.workspace = true tokio = { workspace = true, features = ["full"] } -futures-util = "0.3.26" -thread-id = "3.3.0" bytes.workspace = true nanoid = "0.4.0" tracing.workspace = true @@ -41,21 +36,17 @@ collab = { workspace = true } collab-document = { workspace = true } collab-folder = { workspace = true } collab-database = { workspace = true } -collab-plugins = { workspace = true } collab-entity = { workspace = true } rand = { version = "0.8.5", features = [] } strum = "0.25.0" [dev-dependencies] -dotenv = "0.15.0" -tempdir = "0.3.7" uuid.workspace = true assert-json-diff = "2.0.2" -tokio-postgres = { version = "0.7.8" } chrono = "0.4.31" zip.workspace = true walkdir = "2.5.0" -futures = "0.3.30" +futures = "0.3.31" flowy-ai-pub = { workspace = true } [features] diff --git a/frontend/rust-lib/event-integration-test/src/chat_event.rs b/frontend/rust-lib/event-integration-test/src/chat_event.rs index f1aafcd136..c8c638bc85 100644 --- a/frontend/rust-lib/event-integration-test/src/chat_event.rs +++ b/frontend/rust-lib/event-integration-test/src/chat_event.rs @@ -1,8 +1,8 @@ use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; use flowy_ai::entities::{ - ChatMessageListPB, ChatMessageTypePB, CompleteTextPB, CompleteTextTaskPB, CompletionTypePB, - LoadNextChatMessagePB, LoadPrevChatMessagePB, SendChatPayloadPB, + ChatMessageListPB, ChatMessageTypePB, LoadNextChatMessagePB, LoadPrevChatMessagePB, + SendChatPayloadPB, }; use flowy_ai::event_map::AIEvent; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; @@ -87,24 +87,4 @@ impl EventIntegrationTest { .await .parse::() } - - pub async fn complete_text( - &self, - text: &str, - completion_type: CompletionTypePB, - ) -> CompleteTextTaskPB { - let payload = CompleteTextPB { - text: text.to_string(), - completion_type, - stream_port: 0, - object_id: "".to_string(), - rag_ids: vec![], - }; - EventBuilder::new(self.clone()) - .event(AIEvent::CompleteText) - .payload(payload) - .async_send() - .await - .parse::() - } } diff --git a/frontend/rust-lib/event-integration-test/src/document/document_event.rs b/frontend/rust-lib/event-integration-test/src/document/document_event.rs index 65b4943f80..28fb03e9ed 100644 --- a/frontend/rust-lib/event-integration-test/src/document/document_event.rs +++ b/frontend/rust-lib/event-integration-test/src/document/document_event.rs @@ -1,8 +1,6 @@ use collab::entity::EncodedCollab; use std::collections::HashMap; -use serde_json::Value; - use flowy_document::entities::*; use flowy_document::event_map::DocumentEvent; use flowy_document::parser::parser_entities::{ @@ -11,6 +9,8 @@ use flowy_document::parser::parser_entities::{ }; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; +use serde_json::Value; +use uuid::Uuid; use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data}; use crate::event_builder::EventBuilder; @@ -37,7 +37,7 @@ impl DocumentEventTest { Self { event_test: core } } - pub async fn get_encoded_v1(&self, doc_id: &str) -> EncodedCollab { + pub async fn get_encoded_v1(&self, doc_id: &Uuid) -> EncodedCollab { let doc = self .event_test .appflowy_core diff --git a/frontend/rust-lib/event-integration-test/src/folder_event.rs b/frontend/rust-lib/event-integration-test/src/folder_event.rs index fae34e175c..345c1e58e0 100644 --- a/frontend/rust-lib/event-integration-test/src/folder_event.rs +++ b/frontend/rust-lib/event-integration-test/src/folder_event.rs @@ -1,4 +1,5 @@ use flowy_folder::view_operation::{GatherEncodedCollab, ViewData}; +use std::str::FromStr; use std::sync::Arc; use collab_folder::{FolderData, View}; @@ -10,12 +11,13 @@ use flowy_folder_pub::entities::PublishPayload; use flowy_search::services::manager::{SearchHandler, SearchType}; use flowy_user::entities::{ AcceptWorkspaceInvitationPB, QueryWorkspacePB, RemoveWorkspaceMemberPB, - RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, WorkspaceMemberInvitationPB, - WorkspaceMemberPB, + RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, UserWorkspaceIdPB, UserWorkspacePB, + WorkspaceMemberInvitationPB, WorkspaceMemberPB, }; use flowy_user::errors::FlowyError; use flowy_user::event_map::UserEvent; use flowy_user_pub::entities::Role; +use uuid::Uuid; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; @@ -110,6 +112,18 @@ impl EventIntegrationTest { .parse::() } + pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB { + let payload = UserWorkspaceIdPB { + workspace_id: workspace_id.to_string(), + }; + EventBuilder::new(self.clone()) + .event(UserEvent::GetUserWorkspace) + .payload(payload) + .async_send() + .await + .parse::() + } + pub fn get_folder_search_handler(&self) -> &Arc { self .appflowy_core @@ -123,10 +137,10 @@ impl EventIntegrationTest { let create_view_params = views .into_iter() .map(|view| CreateViewParams { - parent_view_id: view.parent_view_id, + parent_view_id: Uuid::from_str(&view.parent_view_id).unwrap(), name: view.name, layout: view.layout.into(), - view_id: view.id, + view_id: Uuid::from_str(&view.id).unwrap(), initial_data: ViewData::Empty, meta: Default::default(), set_as_current: false, @@ -195,9 +209,10 @@ impl EventIntegrationTest { view_id: &str, layout: ViewLayout, ) -> GatherEncodedCollab { + let view_id = Uuid::from_str(view_id).unwrap(); self .folder_manager - .gather_publish_encode_collab(view_id, &layout) + .gather_publish_encode_collab(&view_id, &layout) .await .unwrap() } diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index 573c8b692b..ff0a3847df 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -1,3 +1,4 @@ +use crate::user_event::TestNotificationSender; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; @@ -7,22 +8,21 @@ use collab_entity::CollabType; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::AppFlowyCore; use flowy_notification::register_notification_sender; -use flowy_server::AppFlowyServer; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use flowy_user::errors::FlowyError; use lib_dispatch::runtime::AFPluginRuntime; use nanoid::nanoid; use semver::Version; use std::env::temp_dir; use std::path::PathBuf; +use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::select; use tokio::task::LocalSet; use tokio::time::sleep; - -use crate::user_event::TestNotificationSender; +use uuid::Uuid; mod chat_event; pub mod database_event; @@ -59,7 +59,7 @@ impl EventIntegrationTest { let clean_path = config.storage_path.clone(); let inner = init_core(config).await; let notification_sender = TestNotificationSender::new(); - let authenticator = Arc::new(AtomicU8::new(AuthenticatorPB::Local as u8)); + let authenticator = Arc::new(AtomicU8::new(AuthTypePB::Local as u8)); register_notification_sender(notification_sender.clone()); // In case of dropping the runtime that runs the core, we need to forget the dispatcher @@ -112,16 +112,25 @@ impl EventIntegrationTest { self.appflowy_core.config.application_path.clone() } - pub fn get_server(&self) -> Arc { - self.appflowy_core.server_provider.get_server().unwrap() - } - pub async fn wait_ws_connected(&self) { - if self.get_server().get_ws_state().is_connected() { + if self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .get_ws_state() + .is_connected() + { return; } - let mut ws_state = self.get_server().subscribe_ws_state().unwrap(); + let mut ws_state = self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .subscribe_ws_state() + .unwrap(); loop { select! { _ = sleep(Duration::from_secs(20)) => { @@ -143,12 +152,19 @@ impl EventIntegrationTest { oid: &str, collab_type: CollabType, ) -> Result, FlowyError> { - let server = self.server_provider.get_server().unwrap(); + let server = self.server_provider.get_server()?; + let workspace_id = self.get_current_workspace().await.id; + let oid = Uuid::from_str(oid)?; let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() - .get_folder_doc_state(&workspace_id, uid, collab_type, oid) + .get_folder_doc_state( + &Uuid::from_str(&workspace_id).unwrap(), + uid, + collab_type, + &oid, + ) .await?; Ok(doc_state) diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index 1b82d9b83c..ab10bb7083 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -17,13 +17,14 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, - OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, - SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, - UserWorkspaceIdPB, UserWorkspacePB, + AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, + OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, + SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, + UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; +use flowy_user_pub::entities::AuthType; use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes}; use crate::event_builder::EventBuilder; @@ -64,7 +65,7 @@ impl EventIntegrationTest { email, name: "appflowy".to_string(), password: password.clone(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: uuid::Uuid::new_v4().to_string(), } .into_bytes() @@ -112,7 +113,7 @@ impl EventIntegrationTest { .await; } - pub fn set_auth_type(&self, auth_type: AuthenticatorPB) { + pub fn set_auth_type(&self, auth_type: AuthTypePB) { self.authenticator.store(auth_type as u8, Ordering::Release); } @@ -139,7 +140,7 @@ impl EventIntegrationTest { pub async fn af_cloud_sign_in_with_email(&self, email: &str) -> FlowyResult { let payload = SignInUrlPayloadPB { email: email.to_string(), - authenticator: AuthenticatorPB::AppFlowyCloud, + authenticator: AuthTypePB::Server, }; let sign_in_url = EventBuilder::new(self.clone()) .event(UserEvent::GenerateSignInURL) @@ -154,7 +155,7 @@ impl EventIntegrationTest { map.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, - authenticator: AuthenticatorPB::AppFlowyCloud, + authenticator: AuthTypePB::Server, }; let user_profile = EventBuilder::new(self.clone()) @@ -189,9 +190,10 @@ impl EventIntegrationTest { } } - pub async fn create_workspace(&self, name: &str) -> UserWorkspacePB { + pub async fn create_workspace(&self, name: &str, auth_type: AuthType) -> UserWorkspacePB { let payload = CreateWorkspacePB { name: name.to_string(), + auth_type: auth_type.into(), }; EventBuilder::new(self.clone()) .event(UserEvent::CreateWorkspace) @@ -278,9 +280,10 @@ impl EventIntegrationTest { .await; } - pub async fn open_workspace(&self, workspace_id: &str) { - let payload = UserWorkspaceIdPB { + pub async fn open_workspace(&self, workspace_id: &str, auth_type: AuthTypePB) { + let payload = OpenUserWorkspacePB { workspace_id: workspace_id.to_string(), + auth_type, }; EventBuilder::new(self.clone()) .event(UserEvent::OpenWorkspace) diff --git a/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs deleted file mode 100644 index f8c2f08b50..0000000000 --- a/frontend/rust-lib/event-integration-test/tests/chat/ai_tool_test.rs +++ /dev/null @@ -1,19 +0,0 @@ -use event_integration_test::user_event::use_localhost_af_cloud; -use event_integration_test::EventIntegrationTest; -use flowy_ai::entities::CompletionTypePB; - -use std::time::Duration; - -#[tokio::test] -async fn af_cloud_complete_text_test() { - use_localhost_af_cloud().await; - let test = EventIntegrationTest::new().await; - test.af_cloud_sign_up().await; - - let _workspace_id = test.get_current_workspace().await.id; - let _task = test - .complete_text("hello world", CompletionTypePB::MakeLonger) - .await; - - tokio::time::sleep(Duration::from_secs(6)).await; -} diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs index 1a5f9356c4..aacba827c4 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -3,10 +3,12 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_ai::entities::ChatMessageListPB; use flowy_ai::notification::ChatNotification; +use std::str::FromStr; use flowy_ai_pub::cloud::ChatMessageType; use std::time::Duration; +use uuid::Uuid; #[tokio::test] async fn af_cloud_create_chat_message_test() { @@ -17,15 +19,19 @@ async fn af_cloud_create_chat_message_test() { let current_workspace = test.get_current_workspace().await; let view = test.create_chat(¤t_workspace.id).await; let chat_id = view.id.clone(); - let chat_service = test.server_provider.get_server().unwrap().chat_service(); + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); for i in 0..10 { let _ = chat_service .create_question( - ¤t_workspace.id, - &chat_id, + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), &format!("hello world {}", i), ChatMessageType::System, - &[], ) .await .unwrap(); @@ -73,15 +79,19 @@ async fn af_cloud_load_remote_system_message_test() { let view = test.create_chat(¤t_workspace.id).await; let chat_id = view.id.clone(); - let chat_service = test.server_provider.get_server().unwrap().chat_service(); + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); for i in 0..10 { let _ = chat_service .create_question( - ¤t_workspace.id, - &chat_id, + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), &format!("hello server {}", i), ChatMessageType::System, - &[], ) .await .unwrap(); @@ -91,10 +101,8 @@ async fn af_cloud_load_remote_system_message_test() { .notification_sender .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); - // Previous messages were created by the server, so there are no messages in the local cache. - // It will try to load messages in the background. let all = test.load_next_message(&chat_id, 5, None).await; - assert!(all.messages.is_empty()); + assert_eq!(all.messages.len(), 5); // Wait for the messages to be loaded. let next_back_five = receive_with_timeout(rx, Duration::from_secs(60)) @@ -119,7 +127,6 @@ async fn af_cloud_load_remote_system_message_test() { let first_five_messages = receive_with_timeout(rx, Duration::from_secs(60)) .await .unwrap(); - assert!(!first_five_messages.has_more); assert_eq!(first_five_messages.messages[0].content, "hello server 4"); assert_eq!(first_five_messages.messages[1].content, "hello server 3"); assert_eq!(first_five_messages.messages[2].content, "hello server 2"); diff --git a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs index 21c16131e9..773bdab81f 100644 --- a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs @@ -1,2 +1 @@ -mod ai_tool_test; mod chat_message_test; diff --git a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs index 04798f044a..7d8ecc9680 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs @@ -66,6 +66,7 @@ async fn af_cloud_upload_big_file_test() { // download the file and then compare the data. let file_service = test + .appflowy_core .server_provider .get_server() .unwrap() diff --git a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs index 199c1b43c2..d9273dbe8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs @@ -8,6 +8,8 @@ use flowy_document::parser::parser_entities::{ }; use serde_json::{json, Value}; use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; #[tokio::test] async fn get_document_event_test() { @@ -101,8 +103,8 @@ async fn document_size_test() { let s = generate_random_string(string_size); test.insert_index(&view.id, &s, 1, None).await; } - - let encoded_v1 = test.get_encoded_v1(&view.id).await; + let view_id = Uuid::from_str(&view.id).unwrap(); + let encoded_v1 = test.get_encoded_v1(&view_id).await; if encoded_v1.doc_state.len() > max_size { panic!( "The document size is too large. {}", diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs index d0a4a28429..5857190b8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs @@ -1,8 +1,8 @@ use collab_folder::ViewLayout; - use event_integration_test::EventIntegrationTest; use flowy_folder::entities::icon::{ViewIconPB, ViewIconTypePB}; use flowy_folder::entities::ViewLayoutPB; +use uuid::Uuid; use crate::folder::local_test::script::FolderScript::*; use crate::folder::local_test::script::FolderTest; @@ -338,11 +338,11 @@ async fn move_view_event_test() { async fn create_orphan_child_view_and_get_its_ancestors_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; + let view_id = Uuid::new_v4().to_string(); test - .create_orphan_view(name, view_id, ViewLayoutPB::Grid) + .create_orphan_view(name, &view_id, ViewLayoutPB::Grid) .await; - let ancestors = test.get_view_ancestors(view_id).await; + let ancestors = test.get_view_ancestors(&view_id).await; assert_eq!(ancestors.len(), 1); assert_eq!(ancestors[0].name, "Orphan View"); assert_eq!(ancestors[0].id, view_id); diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs index 0d5b9bc08c..089310b260 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs @@ -6,6 +6,7 @@ use flowy_folder::view_operation::GatherEncodedCollab; use flowy_folder_pub::entities::{ PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData, }; +use uuid::Uuid; async fn mock_single_document_view_publish_payload( test: &EventIntegrationTest, @@ -140,11 +141,11 @@ async fn create_nested_document(test: &EventIntegrationTest, view_id: &str, name #[tokio::test] async fn single_document_get_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; - let view_id = "20240521"; + let view_id = Uuid::new_v4().to_string(); let name = "Orphan View"; - create_single_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, true).await; + create_single_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; let expect_payload = mock_single_document_view_publish_payload( &test, @@ -160,10 +161,10 @@ async fn single_document_get_publish_view_payload_test() { async fn nested_document_get_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; - create_nested_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, true).await; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; let expect_payload = mock_nested_document_view_publish_payload( &test, @@ -180,10 +181,10 @@ async fn nested_document_get_publish_view_payload_test() { async fn no_children_publish_view_payload_test() { let test = EventIntegrationTest::new_anon().await; let name = "Orphan View"; - let view_id = "20240521"; - create_nested_document(&test, view_id, name).await; - let view = test.get_view(view_id).await; - let payload = test.get_publish_payload(view_id, false).await; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, false).await; let data = mock_single_document_view_publish_payload( &test, diff --git a/frontend/rust-lib/event-integration-test/tests/main.rs b/frontend/rust-lib/event-integration-test/tests/main.rs index 05f19e9b75..cf4c1591ac 100644 --- a/frontend/rust-lib/event-integration-test/tests/main.rs +++ b/frontend/rust-lib/event-integration-test/tests/main.rs @@ -4,6 +4,8 @@ mod folder; // TODO(Mathias): Enable tests for search // mod search; + +mod sql_test; mod user; pub mod util; diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs new file mode 100644 index 0000000000..3294ad26db --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs @@ -0,0 +1,609 @@ +use event_integration_test::user_event::use_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_ai_pub::cloud::MessageCursor; +use flowy_ai_pub::persistence::{ + select_answer_where_match_reply_message_id, select_chat_messages, select_message, + select_message_content, total_message_count, upsert_chat_messages, ChatMessageTable, +}; +use uuid::Uuid; + +#[tokio::test] +async fn chat_message_table_insert_select_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id_1 = 1000; + let message_id_2 = 2000; + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: message_id_1, + chat_id: chat_id.clone(), + content: "Hello, this is a test message".to_string(), + created_at: 1625097600, // 2021-07-01 + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ChatMessageTable { + message_id: message_id_2, + chat_id: chat_id.clone(), + content: "This is a reply to the test message".to_string(), + created_at: 1625097700, // 2021-07-01, 100 seconds later + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(message_id_1), + metadata: Some(r#"{"source": "test"}"#.to_string()), + is_sync: false, + }, + ]; + + // Test insert_chat_messages + let result = upsert_chat_messages(db_conn, &messages); + assert!( + result.is_ok(), + "Failed to insert chat messages: {:?}", + result + ); + + // Test select_chat_messages + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let messages_result = + select_chat_messages(db_conn, &chat_id, 10, MessageCursor::Offset(0)).unwrap(); + + assert_eq!(messages_result.messages.len(), 2); + assert_eq!(messages_result.total_count, 2); + assert!(!messages_result.has_more); + + // Verify the content of the returned messages + let first_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_1) + .unwrap(); + assert_eq!(first_message.content, "Hello, this is a test message"); + assert_eq!(first_message.author_type, 1); + + let second_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_2) + .unwrap(); + assert_eq!( + second_message.content, + "This is a reply to the test message" + ); + assert_eq!(second_message.reply_message_id, Some(message_id_1)); +} + +#[tokio::test] +async fn chat_message_table_cursor_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create multiple test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..6 { + messages.push(ChatMessageTable { + message_id: i * 1000, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 100), // Increasing timestamps + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }); + } + + // Insert messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Test MessageCursor::Offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_offset = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!(result_offset.messages.len(), 2); + assert!(result_offset.has_more); + + // Test MessageCursor::AfterMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_after = select_chat_messages( + db_conn, + &chat_id, + 3, // Limit to 3 messages + MessageCursor::AfterMessageId(2000), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), 3); // Should get message IDs 3000, 4000, 5000 + assert!(result_after.messages.iter().all(|m| m.message_id > 2000)); + + // Test MessageCursor::BeforeMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::BeforeMessageId(4000), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), 2); // Should get message IDs 1000, 2000, 3000 + assert!(result_before.messages.iter().all(|m| m.message_id < 4000)); +} + +#[tokio::test] +async fn chat_message_total_count_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: 1001, + chat_id: chat_id.clone(), + content: "Message 1".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ChatMessageTable { + message_id: 1002, + chat_id: chat_id.clone(), + content: "Message 2".to_string(), + created_at: 1625097700, + author_type: 0, + author_id: "ai".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ]; + + // Insert messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Test total_message_count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 2); + + // Add one more message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let additional_message = ChatMessageTable { + message_id: 1003, + chat_id: chat_id.clone(), + content: "Message 3".to_string(), + created_at: 1625097800, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + upsert_chat_messages(db_conn, &[additional_message]).unwrap(); + + // Verify count increased + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(updated_count, 3); + + // Test count for non-existent chat + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let empty_count = total_message_count(db_conn, "non_existent_chat").unwrap(); + assert_eq!(empty_count, 0); +} + +#[tokio::test] +async fn chat_message_select_message_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 2001; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "This is a test message for select_message".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: Some(r#"{"test_key": "test_value"}"#.to_string()), + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap(); + assert!(result.is_some()); + + let retrieved_message = result.unwrap(); + assert_eq!(retrieved_message.message_id, message_id); + assert_eq!(retrieved_message.chat_id, chat_id); + assert_eq!( + retrieved_message.content, + "This is a test message for select_message" + ); + assert_eq!(retrieved_message.author_id, "user_1"); + assert_eq!( + retrieved_message.metadata, + Some(r#"{"test_key": "test_value"}"#.to_string()) + ); + + // Test select_message with non-existent ID + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let non_existent = select_message(db_conn, 9999).unwrap(); + assert!(non_existent.is_none()); +} + +#[tokio::test] +async fn chat_message_select_content_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 3001; + let message_content = "This is the content to retrieve"; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: message_content.to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message_content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let content = select_message_content(db_conn, message_id).unwrap(); + assert!(content.is_some()); + assert_eq!(content.unwrap(), message_content); + + // Test with non-existent message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_content = select_message_content(db_conn, 9999).unwrap(); + assert!(no_content.is_none()); +} + +#[tokio::test] +async fn chat_message_reply_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let question_id = 4001; + let answer_id = 4002; + + // Create question and answer messages + let question = ChatMessageTable { + message_id: question_id, + chat_id: chat_id.clone(), + content: "What is the question?".to_string(), + created_at: 1625097600, + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + let answer = ChatMessageTable { + message_id: answer_id, + chat_id: chat_id.clone(), + content: "This is the answer".to_string(), + created_at: 1625097700, + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(question_id), // Link to question + metadata: None, + is_sync: false, + }; + + // Insert messages + upsert_chat_messages(db_conn, &[question, answer]).unwrap(); + + // Test select_message_where_match_reply_message_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_answer_where_match_reply_message_id(db_conn, &chat_id, question_id).unwrap(); + + assert!(result.is_some()); + let reply = result.unwrap(); + assert_eq!(reply.message_id, answer_id); + assert_eq!(reply.content, "This is the answer"); + assert_eq!(reply.reply_message_id, Some(question_id)); + + // Test with non-existent reply relation + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_reply = select_answer_where_match_reply_message_id( + db_conn, &chat_id, 9999, // Non-existent question ID + ) + .unwrap(); + + assert!(no_reply.is_none()); + + // Test with wrong chat_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let wrong_chat = + select_answer_where_match_reply_message_id(db_conn, "wrong_chat_id", question_id).unwrap(); + + assert!(wrong_chat.is_none()); +} + +#[tokio::test] +async fn chat_message_upsert_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 5001; + + // Create initial message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "Original content".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Check original content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let original = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(original.content, "Original content"); + + // Create updated message with same ID but different content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_message = ChatMessageTable { + message_id, // Same ID + chat_id: chat_id.clone(), + content: "Updated content".to_string(), // New content + created_at: 1625097700, // Updated timestamp + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: Some(1000), // Added reply ID + metadata: Some(r#"{"updated": true}"#.to_string()), + is_sync: false, + }; + + // Upsert message + upsert_chat_messages(db_conn, &[updated_message]).unwrap(); + + // Verify update + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(result.content, "Updated content"); + assert_eq!(result.created_at, 1625097700); + assert_eq!(result.reply_message_id, Some(1000)); + assert_eq!(result.metadata, Some(r#"{"updated": true}"#.to_string())); + + // Count should still be 1 (update, not insert) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn chat_message_select_with_large_dataset() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create 100 test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..=100 { + messages.push(ChatMessageTable { + message_id: i * 100, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 10), // Increasing timestamps + author_type: if i % 2 == 0 { 0 } else { 1 }, // Alternate between AI and User + author_id: if i % 2 == 0 { + "ai".to_string() + } else { + "user_1".to_string() + }, + reply_message_id: if i > 1 && i % 2 == 0 { + Some((i - 1) * 100) + } else { + None + }, // Even messages reply to previous message + metadata: if i % 5 == 0 { + Some(format!(r#"{{"index": {}}}"#, i)) + } else { + None + }, + is_sync: false, + }); + } + + // Insert all 100 messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Verify total count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 100, "Should have 100 messages in the database"); + + // Test 1: MessageCursor::Offset with small page size + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let page_size = 10; + let result_offset = + select_chat_messages(db_conn, &chat_id, page_size, MessageCursor::Offset(0)).unwrap(); + + assert_eq!( + result_offset.messages.len(), + page_size as usize, + "Should return exactly {page_size} messages" + ); + assert!( + result_offset.has_more, + "Should have more messages available" + ); + assert_eq!(result_offset.total_count, 100, "Total count should be 100"); + + // Test 2: Pagination with offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_page2 = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::Offset(page_size), + ) + .unwrap(); + + assert_eq!(result_page2.messages.len(), page_size as usize); + assert!( + result_page2.messages[0].message_id != result_offset.messages[0].message_id, + "Second page should have different messages than first page" + ); + + // Test 3: MessageCursor::AfterMessageId (forward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let middle_message_id = 5000; // Message ID from the middle + let result_after = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), page_size as usize); + assert!( + result_after + .messages + .iter() + .all(|m| m.message_id > middle_message_id), + "All messages should have ID greater than the cursor" + ); + + // Test 4: MessageCursor::BeforeMessageId (backward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::BeforeMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), page_size as usize); + assert!( + result_before + .messages + .iter() + .all(|m| m.message_id < middle_message_id), + "All messages should have ID less than the cursor" + ); + + // Test 5: Large page size (retrieve all) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_all = select_chat_messages( + db_conn, + &chat_id, + 200, // More than we have + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!( + result_all.messages.len(), + 100, + "Should return all 100 messages" + ); + assert!(!result_all.has_more, "Should not have more messages"); + + // Test 6: Empty result when using out of range cursor + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_out_of_range = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(10000), // After the last message + ) + .unwrap(); + + assert_eq!( + result_out_of_range.messages.len(), + 0, + "Should return no messages" + ); + assert!( + !result_out_of_range.has_more, + "Should not have more messages" + ); +} diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs new file mode 100644 index 0000000000..773bdab81f --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs @@ -0,0 +1 @@ +mod chat_message_test; diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs index 718bc1d9af..7f743b931c 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs @@ -1,7 +1,7 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use crate::util::unzip; @@ -72,7 +72,7 @@ async fn migrate_anon_user_data_to_af_cloud_test() { let user = test.af_cloud_sign_up().await; let workspace = test.get_current_workspace().await; println!("user workspace: {:?}", workspace.id); - assert_eq!(user.authenticator, AuthenticatorPB::AppFlowyCloud); + assert_eq!(user.auth_type, AuthTypePB::Server); let user_first_level_views = test.get_all_workspace_views().await; assert_eq!(user_first_level_views.len(), 3); diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs index 7b31babd0e..eaec8f7540 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs @@ -1,6 +1,5 @@ use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; -use flowy_user::entities::UpdateUserProfilePayloadPB; use crate::util::generate_test_email; @@ -13,29 +12,3 @@ async fn af_cloud_sign_up_test() { let user = test.af_cloud_sign_in_with_email(&email).await.unwrap(); assert_eq!(user.email, email); } - -#[tokio::test] -async fn af_cloud_update_user_metadata() { - use_localhost_af_cloud().await; - let test = EventIntegrationTest::new().await; - let user = test.af_cloud_sign_up().await; - - let old_profile = test.get_user_profile().await.unwrap(); - assert_eq!(old_profile.openai_key, "".to_string()); - - test - .update_user_profile(UpdateUserProfilePayloadPB { - id: user.id, - openai_key: Some("new openai key".to_string()), - stability_ai_key: Some("new stability ai key".to_string()), - ..Default::default() - }) - .await; - - let new_profile = test.get_user_profile().await.unwrap(); - assert_eq!(new_profile.openai_key, "new openai key".to_string()); - assert_eq!( - new_profile.stability_ai_key, - "new stability ai key".to_string() - ); -} diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index 56cf22a4da..3bb71ea0dc 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -1,15 +1,15 @@ +use crate::user::af_cloud_test::util::get_synced_workspaces; use collab::core::collab::DataSource::DocStateV1; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use collab_folder::Folder; use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; +use flowy_user_pub::entities::AuthType; use std::time::Duration; use tokio::task::LocalSet; use tokio::time::sleep; -use crate::user::af_cloud_test::util::get_synced_workspaces; - #[tokio::test] async fn af_cloud_workspace_delete() { use_localhost_af_cloud().await; @@ -18,7 +18,9 @@ async fn af_cloud_workspace_delete() { let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 2); @@ -66,7 +68,9 @@ async fn af_cloud_create_workspace_test() { let first_workspace_id = workspaces[0].workspace_id.as_str(); assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; @@ -85,7 +89,12 @@ async fn af_cloud_create_workspace_test() { } { // after opening new workspace - test.open_workspace(&created_workspace.workspace_id).await; + test + .open_workspace( + &created_workspace.workspace_id, + created_workspace.workspace_auth_type, + ) + .await; let folder_ws = test.folder_read_current_workspace().await; assert_eq!(folder_ws.id, created_workspace.workspace_id); let views = test.folder_read_current_workspace_views().await; @@ -106,6 +115,7 @@ async fn af_cloud_open_workspace_test() { test.create_document("A").await; test.create_document("B").await; let first_workspace = test.get_current_workspace().await; + let first_workspace = test.get_user_workspace(&first_workspace.id).await; let views = test.get_all_workspace_views().await; assert_eq!(views.len(), 4); assert_eq!(views[0].name, default_document_name); @@ -113,9 +123,17 @@ async fn af_cloud_open_workspace_test() { assert_eq!(views[2].name, "A"); assert_eq!(views[3].name, "B"); - let user_workspace = test.create_workspace("second workspace").await; - test.open_workspace(&user_workspace.workspace_id).await; + let user_workspace = test + .create_workspace("second workspace", AuthType::AppFlowyCloud) + .await; + test + .open_workspace( + &user_workspace.workspace_id, + user_workspace.workspace_auth_type, + ) + .await; let second_workspace = test.get_current_workspace().await; + let second_workspace = test.get_user_workspace(&second_workspace.id).await; test.create_document("C").await; test.create_document("D").await; @@ -129,13 +147,23 @@ async fn af_cloud_open_workspace_test() { // simulate open workspace and check if the views are correct for i in 0..10 { if i % 2 == 0 { - test.open_workspace(&first_workspace.id).await; + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.workspace_auth_type.clone(), + ) + .await; sleep(Duration::from_millis(300)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) .await; } else { - test.open_workspace(&second_workspace.id).await; + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.workspace_auth_type.clone(), + ) + .await; sleep(Duration::from_millis(200)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) @@ -143,14 +171,24 @@ async fn af_cloud_open_workspace_test() { } } - test.open_workspace(&first_workspace.id).await; + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.workspace_auth_type.clone(), + ) + .await; let views_1 = test.get_all_workspace_views().await; assert_eq!(views_1[0].name, default_document_name); assert_eq!(views_1[1].name, "Shared"); assert_eq!(views_1[2].name, "A"); assert_eq!(views_1[3].name, "B"); - test.open_workspace(&second_workspace.id).await; + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.workspace_auth_type.clone(), + ) + .await; let views_2 = test.get_all_workspace_views().await; assert_eq!(views_2[0].name, default_document_name); assert_eq!(views_2[1].name, "Shared"); @@ -206,7 +244,12 @@ async fn af_cloud_different_open_same_workspace_test() { for i in 0..30 { let index = i % 2; let iter_workspace_id = &all_workspaces[index].workspace_id; - client.open_workspace(iter_workspace_id).await; + client + .open_workspace( + iter_workspace_id, + all_workspaces[index].workspace_auth_type.clone(), + ) + .await; if iter_workspace_id == &cloned_shared_workspace_id { let views = client.get_all_workspace_views().await; assert_eq!(views.len(), 2); diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs index 3cd3733837..138f6f0258 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs @@ -1,6 +1,6 @@ use event_integration_test::user_event::{login_password, unique_email}; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, SignInPayloadPB, SignUpPayloadPB}; +use flowy_user::entities::{AuthTypePB, SignInPayloadPB, SignUpPayloadPB}; use flowy_user::errors::ErrorCode; use flowy_user::event_map::UserEvent::*; @@ -14,7 +14,7 @@ async fn sign_up_with_invalid_email() { email: email.to_string(), name: valid_name(), password: login_password(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -31,29 +31,6 @@ async fn sign_up_with_invalid_email() { ); } } -#[tokio::test] -async fn sign_up_with_long_password() { - let sdk = EventIntegrationTest::new().await; - let request = SignUpPayloadPB { - email: unique_email(), - name: valid_name(), - password: "1234".repeat(100).as_str().to_string(), - auth_type: AuthenticatorPB::Local, - device_id: "".to_string(), - }; - - assert_eq!( - EventBuilder::new(sdk) - .event(SignUp) - .payload(request) - .async_send() - .await - .error() - .unwrap() - .code, - ErrorCode::PasswordTooLong - ); -} #[tokio::test] async fn sign_in_with_invalid_email() { @@ -63,7 +40,7 @@ async fn sign_in_with_invalid_email() { email: email.to_string(), password: login_password(), name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -90,7 +67,7 @@ async fn sign_in_with_invalid_password() { email: unique_email(), password, name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs index 00df14e8e1..47c2d53a6b 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs @@ -1,6 +1,6 @@ use crate::user::local_test::helper::*; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, UpdateUserProfilePayloadPB, UserProfilePB}; +use flowy_user::entities::{AuthTypePB, UpdateUserProfilePayloadPB, UserProfilePB}; use flowy_user::{errors::ErrorCode, event_map::UserEvent::*}; use nanoid::nanoid; #[tokio::test] @@ -24,9 +24,7 @@ async fn anon_user_profile_get() { .await .parse::(); assert_eq!(user_profile.id, user.id); - assert_eq!(user_profile.openai_key, user.openai_key); - assert_eq!(user_profile.stability_ai_key, user.stability_ai_key); - assert_eq!(user_profile.authenticator, AuthenticatorPB::Local); + assert_eq!(user_profile.auth_type, AuthTypePB::Local); } #[tokio::test] @@ -50,31 +48,6 @@ async fn user_update_with_name() { assert_eq!(user_profile.name, new_name,); } -#[tokio::test] -async fn user_update_with_ai_key() { - let sdk = EventIntegrationTest::new().await; - let user = sdk.init_anon_user().await; - let openai_key = "openai_key".to_owned(); - let stability_ai_key = "stability_ai_key".to_owned(); - let request = UpdateUserProfilePayloadPB::new(user.id) - .openai_key(&openai_key) - .stability_ai_key(&stability_ai_key); - let _ = EventBuilder::new(sdk.clone()) - .event(UpdateUserProfile) - .payload(request) - .async_send() - .await; - - let user_profile = EventBuilder::new(sdk.clone()) - .event(GetUserProfile) - .async_send() - .await - .parse::(); - - assert_eq!(user_profile.openai_key, openai_key,); - assert_eq!(user_profile.stability_ai_key, stability_ai_key,); -} - #[tokio::test] async fn anon_user_update_with_email() { let sdk = EventIntegrationTest::new().await; diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs index 5a75197a9c..61833429aa 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs @@ -1,44 +1,9 @@ use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_folder::entities::ViewLayoutPB; use std::time::Duration; use crate::util::unzip; -#[tokio::test] -async fn migrate_020_historical_empty_document_test() { - let user_db_path = unzip( - "./tests/user/migration_test/history_user_db", - "020_historical_user_data", - ) - .unwrap(); - let test = - EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; - - let mut views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 1); - - // Check the parent view - let parent_view = views.pop().unwrap(); - assert_eq!(parent_view.layout, ViewLayoutPB::Document); - let data = test.open_document(parent_view.id.clone()).await.data; - assert!(!data.page_id.is_empty()); - assert_eq!(data.blocks.len(), 2); - assert!(!data.meta.children_map.is_empty()); - - // Check the child views of the parent view - let child_views = test.get_view(&parent_view.id).await.child_views; - assert_eq!(child_views.len(), 4); - assert_eq!(child_views[0].layout, ViewLayoutPB::Document); - assert_eq!(child_views[1].layout, ViewLayoutPB::Grid); - assert_eq!(child_views[2].layout, ViewLayoutPB::Calendar); - assert_eq!(child_views[3].layout, ViewLayoutPB::Board); - - let database = test.get_database(&child_views[1].id).await; - assert_eq!(database.fields.len(), 8); - assert_eq!(database.rows.len(), 3); -} - #[tokio::test] async fn migrate_036_fav_v1_workspace_array_test() { // Used to test migration: FavoriteV1AndWorkspaceArrayMigration diff --git a/frontend/rust-lib/flowy-ai-pub/Cargo.toml b/frontend/rust-lib/flowy-ai-pub/Cargo.toml index 3afd04d530..93ea79bcab 100644 --- a/frontend/rust-lib/flowy-ai-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-ai-pub/Cargo.toml @@ -9,6 +9,8 @@ edition = "2021" lib-infra = { workspace = true } flowy-error = { workspace = true } client-api = { workspace = true } -bytes.workspace = true futures.workspace = true serde_json.workspace = true +serde.workspace = true +uuid.workspace = true +flowy-sqlite = { workspace = true } \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs index bf787f3d20..2292e0f332 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -1,49 +1,106 @@ -use bytes::Bytes; +use crate::cloud::ai_dto::AvailableModel; pub use client_api::entity::ai_dto::{ - AppFlowyOfflineAI, CompleteTextParams, CompletionMetadata, CompletionType, CreateChatContext, - LLMModel, LocalAIConfig, ModelInfo, OutputContent, OutputLayout, RelatedQuestion, - RepeatedRelatedQuestion, ResponseFormat, StringOrMessage, + AppFlowyOfflineAI, CompleteTextParams, CompletionMessage, CompletionMetadata, CompletionType, + CreateChatContext, CustomPrompt, LLMModel, LocalAIConfig, ModelInfo, ModelList, OutputContent, + OutputLayout, RelatedQuestion, RepeatedRelatedQuestion, ResponseFormat, StringOrMessage, }; pub use client_api::entity::billing_dto::SubscriptionPlan; pub use client_api::entity::chat_dto::{ - ChatMessage, ChatMessageMetadata, ChatMessageType, ChatRAGData, ChatSettings, ContextLoader, - MessageCursor, RepeatedChatMessage, UpdateChatParams, + ChatMessage, ChatMessageType, ChatRAGData, ChatSettings, ContextLoader, MessageCursor, + RepeatedChatMessage, UpdateChatParams, }; pub use client_api::entity::QuestionStreamValue; -use client_api::error::AppResponseError; +pub use client_api::entity::*; +pub use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; use flowy_error::FlowyError; use futures::stream::BoxStream; use lib_infra::async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use uuid::Uuid; pub type ChatMessageStream = BoxStream<'static, Result>; pub type StreamAnswer = BoxStream<'static, Result>; -pub type StreamComplete = BoxStream<'static, Result>; +pub type StreamComplete = BoxStream<'static, Result>; + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] +pub struct AIModel { + pub name: String, + pub is_local: bool, + #[serde(default)] + pub desc: String, +} + +impl From for AIModel { + fn from(value: AvailableModel) -> Self { + let desc = value + .metadata + .as_ref() + .and_then(|v| v.get("desc").map(|v| v.as_str().unwrap_or(""))) + .unwrap_or(""); + Self { + name: value.name, + is_local: false, + desc: desc.to_string(), + } + } +} + +impl AIModel { + pub fn server(name: String, desc: String) -> Self { + Self { + name, + is_local: false, + desc, + } + } + + pub fn local(name: String, desc: String) -> Self { + Self { + name, + is_local: true, + desc, + } + } +} + +pub const DEFAULT_AI_MODEL_NAME: &str = "Auto"; +impl Default for AIModel { + fn default() -> Self { + Self { + name: DEFAULT_AI_MODEL_NAME.to_string(), + is_local: false, + desc: "".to_string(), + } + } +} + #[async_trait] pub trait ChatCloudService: Send + Sync + 'static { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError>; async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result; async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -51,72 +108,71 @@ pub trait ChatCloudService: Send + Sync + 'static { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, - message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result; async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, - question_message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, ) -> Result; async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result; async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result; async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result; async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, + ai_model: Option, ) -> Result; - async fn index_file( + async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError>; - async fn get_local_ai_config(&self, workspace_id: &str) -> Result; - - async fn get_workspace_plan( - &self, - workspace_id: &str, - ) -> Result, FlowyError>; - async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result; async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError>; + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result; + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result; } diff --git a/frontend/rust-lib/flowy-ai-pub/src/lib.rs b/frontend/rust-lib/flowy-ai-pub/src/lib.rs index 1ede32218e..9a7423ec3f 100644 --- a/frontend/rust-lib/flowy-ai-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/lib.rs @@ -1 +1,2 @@ pub mod cloud; +pub mod persistence; diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs new file mode 100644 index 0000000000..230e5761d2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs @@ -0,0 +1,188 @@ +use crate::cloud::MessageCursor; +use client_api::entity::chat_dto::ChatMessage; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::upsert::excluded; +use flowy_sqlite::{ + diesel, insert_into, + query_dsl::*, + schema::{chat_message_table, chat_message_table::dsl}, + DBConnection, ExpressionMethods, Identifiable, Insertable, OptionalExtension, QueryResult, + Queryable, +}; + +#[derive(Queryable, Insertable, Identifiable)] +#[diesel(table_name = chat_message_table)] +#[diesel(primary_key(message_id))] +pub struct ChatMessageTable { + pub message_id: i64, + pub chat_id: String, + pub content: String, + pub created_at: i64, + pub author_type: i64, + pub author_id: String, + pub reply_message_id: Option, + pub metadata: Option, + pub is_sync: bool, +} +impl ChatMessageTable { + pub fn from_message(chat_id: String, message: ChatMessage, is_sync: bool) -> Self { + ChatMessageTable { + message_id: message.message_id, + chat_id, + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, + } + } +} + +pub fn update_chat_message_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + message_id_val: i64, + is_sync_val: bool, +) -> FlowyResult<()> { + diesel::update(chat_message_table::table) + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.eq(message_id_val)) + .set(chat_message_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn)?; + + Ok(()) +} + +pub fn upsert_chat_messages( + mut conn: DBConnection, + new_messages: &[ChatMessageTable], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + for message in new_messages { + let _ = insert_into(chat_message_table::table) + .values(message) + .on_conflict(chat_message_table::message_id) + .do_update() + .set(( + chat_message_table::content.eq(excluded(chat_message_table::content)), + chat_message_table::metadata.eq(excluded(chat_message_table::metadata)), + chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), + chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), + chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), + chat_message_table::reply_message_id.eq(excluded(chat_message_table::reply_message_id)), + )) + .execute(conn)?; + } + Ok::<(), FlowyError>(()) + })?; + + Ok(()) +} + +pub struct ChatMessagesResult { + pub messages: Vec, + pub total_count: i64, + pub has_more: bool, +} + +pub fn select_chat_messages( + mut conn: DBConnection, + chat_id_val: &str, + limit_val: u64, + offset: MessageCursor, +) -> QueryResult { + let mut query = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .into_boxed(); + + match offset { + MessageCursor::AfterMessageId(after_message_id) => { + query = query.filter(chat_message_table::message_id.gt(after_message_id)); + }, + MessageCursor::BeforeMessageId(before_message_id) => { + query = query.filter(chat_message_table::message_id.lt(before_message_id)); + }, + MessageCursor::Offset(offset_val) => { + query = query.offset(offset_val as i64); + }, + MessageCursor::NextBack => {}, + } + + // Get total count before applying limit + let total_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn)?; + + query = query + .order(( + chat_message_table::created_at.desc(), + chat_message_table::message_id.desc(), + )) + .limit(limit_val as i64); + + let messages: Vec = query.load::(&mut *conn)?; + + // Check if there are more messages + let has_more = if let Some(last_message) = messages.last() { + let remaining_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.lt(last_message.message_id)) + .count() + .first::(&mut *conn)?; + + remaining_count > 0 + } else { + false + }; + + Ok(ChatMessagesResult { + messages, + total_count, + has_more, + }) +} + +pub fn total_message_count(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn) +} + +pub fn select_message( + mut conn: DBConnection, + message_id_val: i64, +) -> QueryResult> { + let message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(message_id_val)) + .first::(&mut *conn) + .optional()?; + Ok(message) +} + +pub fn select_message_content( + mut conn: DBConnection, + message_id_val: i64, +) -> QueryResult> { + let message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(message_id_val)) + .select(chat_message_table::content) + .first::(&mut *conn) + .optional()?; + Ok(message) +} + +pub fn select_answer_where_match_reply_message_id( + mut conn: DBConnection, + chat_id: &str, + answer_message_id_val: i64, +) -> QueryResult> { + dsl::chat_message_table + .filter(chat_message_table::reply_message_id.eq(answer_message_id_val)) + .filter(chat_message_table::chat_id.eq(chat_id)) + .first::(&mut *conn) + .optional() +} diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs similarity index 58% rename from frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs rename to frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs index e962f2c880..f5398c48c0 100644 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_sql.rs +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs @@ -7,7 +7,10 @@ use flowy_sqlite::{ schema::{chat_table, chat_table::dsl}, AsChangeset, DBConnection, ExpressionMethods, Identifiable, Insertable, QueryResult, Queryable, }; +use lib_infra::util::timestamp; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; #[derive(Clone, Default, Queryable, Insertable, Identifiable)] #[diesel(table_name = chat_table)] @@ -16,10 +19,25 @@ pub struct ChatTable { pub chat_id: String, pub created_at: i64, pub name: String, - pub local_files: String, pub metadata: String, - pub local_enabled: bool, - pub sync_to_cloud: bool, + pub rag_ids: Option, + pub is_sync: bool, +} + +impl ChatTable { + pub fn new(chat_id: String, metadata: Value, rag_ids: Vec, is_sync: bool) -> Self { + let rag_ids = rag_ids.iter().map(|v| v.to_string()).collect::>(); + let metadata = serialize_chat_metadata(&metadata); + let rag_ids = Some(serialize_rag_ids(&rag_ids)); + Self { + chat_id, + created_at: timestamp(), + name: "".to_string(), + metadata, + rag_ids, + is_sync, + } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -49,22 +67,37 @@ pub struct ChatTableFile { pub struct ChatTableChangeset { pub chat_id: String, pub name: Option, - pub local_files: Option, pub metadata: Option, - pub local_enabled: Option, - pub sync_to_cloud: Option, + pub rag_ids: Option, + pub is_sync: Option, } -impl ChatTableChangeset { - pub fn from_metadata(metadata: ChatTableMetadata) -> Self { - ChatTableChangeset { - metadata: serde_json::to_string(&metadata).ok(), - ..Default::default() - } +pub fn serialize_rag_ids(rag_ids: &[String]) -> String { + serde_json::to_string(rag_ids).unwrap_or_default() +} + +pub fn deserialize_rag_ids(rag_ids_str: &Option) -> Vec { + match rag_ids_str { + Some(str) => serde_json::from_str(str).unwrap_or_default(), + None => Vec::new(), } } -pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { +pub fn deserialize_chat_metadata(metadata: &str) -> T +where + T: serde::de::DeserializeOwned + Default, +{ + serde_json::from_str(metadata).unwrap_or_default() +} + +pub fn serialize_chat_metadata(metadata: &T) -> String +where + T: Serialize, +{ + serde_json::to_string(metadata).unwrap_or_default() +} + +pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { diesel::insert_into(chat_table::table) .values(new_chat) .on_conflict(chat_table::chat_id) @@ -72,11 +105,13 @@ pub fn insert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult< .set(( chat_table::created_at.eq(excluded(chat_table::created_at)), chat_table::name.eq(excluded(chat_table::name)), + chat_table::metadata.eq(excluded(chat_table::metadata)), + chat_table::rag_ids.eq(excluded(chat_table::rag_ids)), + chat_table::is_sync.eq(excluded(chat_table::is_sync)), )) .execute(&mut *conn) } -#[allow(dead_code)] pub fn update_chat( conn: &mut SqliteConnection, changeset: ChatTableChangeset, @@ -86,7 +121,16 @@ pub fn update_chat( Ok(affected_row) } -#[allow(dead_code)] +pub fn update_chat_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + is_sync_val: bool, +) -> QueryResult { + diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))) + .set(chat_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn) +} + pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { let row = dsl::chat_table .filter(chat_table::chat_id.eq(chat_id_val)) @@ -94,7 +138,17 @@ pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult FlowyResult> { + let chat = dsl::chat_table + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(conn)?; + + Ok(deserialize_rag_ids(&chat.rag_ids)) +} + pub fn read_chat_metadata( conn: &mut SqliteConnection, chat_id_val: &str, @@ -103,8 +157,7 @@ pub fn read_chat_metadata( .select(chat_table::metadata) .filter(chat_table::chat_id.eq(chat_id_val)) .first::(&mut *conn)?; - let value = serde_json::from_str(&metadata_str).unwrap_or_default(); - Ok(value) + Ok(deserialize_chat_metadata(&metadata_str)) } #[allow(dead_code)] diff --git a/frontend/rust-lib/flowy-ai/src/persistence/mod.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs similarity index 100% rename from frontend/rust-lib/flowy-ai/src/persistence/mod.rs rename to frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml index c2f94d509f..3a6aaf5898 100644 --- a/frontend/rust-lib/flowy-ai/Cargo.toml +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -12,6 +12,7 @@ flowy-error = { path = "../flowy-error", features = [ "impl_from_dispatch_error", "impl_from_collab_folder", "impl_from_sqlite", + "impl_from_appflowy_cloud", ] } lib-dispatch = { workspace = true } tracing.workspace = true @@ -34,21 +35,20 @@ serde_json = { workspace = true } anyhow = "1.0.86" tokio-stream = "0.1.15" tokio-util = { workspace = true, features = ["full"] } -appflowy-local-ai = { version = "0.1.0", features = ["verbose"] } -appflowy-plugin = { version = "0.1.0" } -reqwest = "0.11.27" +af-local-ai = { workspace = true } +af-plugin = { workspace = true } +reqwest = { version = "0.11.27", features = ["json"] } sha2 = "0.10.7" base64 = "0.21.5" futures-util = "0.3.30" -md5 = "0.7.0" -zip = { workspace = true, features = ["deflate"] } -zip-extensions = "0.8.0" pin-project = "1.1.5" flowy-storage-pub = { workspace = true } collab-integrate.workspace = true + [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] notify = "6.1.1" +af-mcp = { version = "0.1.0" } [dev-dependencies] dotenv = "0.15.0" @@ -61,5 +61,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] -web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] diff --git a/frontend/rust-lib/flowy-ai/build.rs b/frontend/rust-lib/flowy-ai/build.rs index e9230d3d6d..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-ai/build.rs +++ b/frontend/rust-lib/flowy-ai/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs index c88a26504c..9055341b99 100644 --- a/frontend/rust-lib/flowy-ai/src/ai_manager.rs +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -1,35 +1,43 @@ use crate::chat::Chat; use crate::entities::{ - ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, FilePB, PredefinedFormatPB, - RepeatedRelatedQuestionPB, StreamMessageParams, + AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, + FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; -use crate::local_ai::local_llm_chat::LocalAIController; -use crate::middleware::chat_service_mw::AICloudServiceMiddleware; -use crate::persistence::{insert_chat, read_chat_metadata, ChatTable}; +use crate::local_ai::controller::{LocalAIController, LocalAISetting}; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; +use flowy_ai_pub::persistence::read_chat_metadata; use std::collections::HashMap; -use appflowy_plugin::manager::PluginManager; use dashmap::DashMap; -use flowy_ai_pub::cloud::{ChatCloudService, ChatSettings, UpdateChatParams}; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatSettings, UpdateChatParams, DEFAULT_AI_MODEL_NAME, +}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use crate::notification::{chat_notification_builder, ChatNotification}; +use crate::util::ai_available_models_key; use collab_integrate::persistence::collab_metadata_sql::{ batch_insert_collab_metadata, batch_select_collab_metadata, AFCollabMetadata, }; +use flowy_ai_pub::cloud::ai_dto::AvailableModel; use flowy_storage_pub::storage::StorageService; use lib_infra::async_trait::async_trait; use lib_infra::util::timestamp; +use serde_json::json; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::{error, info, trace}; +use tokio::sync::RwLock; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; +#[async_trait] pub trait AIUserService: Send + Sync + 'static { fn user_id(&self) -> Result; - fn device_id(&self) -> Result; - fn workspace_id(&self) -> Result; + async fn is_local_model(&self) -> FlowyResult; + fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn application_root_dir(&self) -> Result; } @@ -39,27 +47,36 @@ pub trait AIUserService: Send + Sync + 'static { pub trait AIExternalService: Send + Sync + 'static { async fn query_chat_rag_ids( &self, - parent_view_id: &str, - chat_id: &str, - ) -> Result, FlowyError>; + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError>; async fn sync_rag_documents( &self, - workspace_id: &str, - rag_ids: Vec, - rag_metadata_map: HashMap, + workspace_id: &Uuid, + rag_ids: Vec, + rag_metadata_map: HashMap, ) -> Result, FlowyError>; - async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError>; + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError>; } +#[derive(Debug, Default)] +struct ServerModelsCache { + models: Vec, + timestamp: Option, +} + +pub const GLOBAL_ACTIVE_MODEL_KEY: &str = "global_active_model"; + pub struct AIManager { - pub cloud_service_wm: Arc, + pub cloud_service_wm: Arc, pub user_service: Arc, pub external_service: Arc, - chats: Arc>>, - pub local_ai_controller: Arc, - store_preferences: Arc, + chats: Arc>>, + pub local_ai: Arc, + pub store_preferences: Arc, + server_models: Arc>, } impl AIManager { @@ -69,22 +86,19 @@ impl AIManager { store_preferences: Arc, storage_service: Weak, query_service: impl AIExternalService, + local_ai: Arc, ) -> AIManager { let user_service = Arc::new(user_service); - let plugin_manager = Arc::new(PluginManager::new()); - let local_ai_controller = Arc::new(LocalAIController::new( - plugin_manager.clone(), - store_preferences.clone(), - user_service.clone(), - chat_cloud_service.clone(), - )); - let external_service = Arc::new(query_service); + let cloned_local_ai = local_ai.clone(); + tokio::spawn(async move { + cloned_local_ai.observe_plugin_resource().await; + }); - // setup local chat service - let cloud_service_wm = Arc::new(AICloudServiceMiddleware::new( + let external_service = Arc::new(query_service); + let cloud_service_wm = Arc::new(ChatServiceMiddleware::new( user_service.clone(), chat_cloud_service, - local_ai_controller.clone(), + local_ai.clone(), storage_service, )); @@ -92,37 +106,65 @@ impl AIManager { cloud_service_wm, user_service, chats: Arc::new(DashMap::new()), - local_ai_controller, + local_ai, external_service, store_preferences, + server_models: Arc::new(Default::default()), } } + #[instrument(skip_all, err)] pub async fn initialize(&self, _workspace_id: &str) -> Result<(), FlowyError> { - // Ignore following error - let _ = self.local_ai_controller.refresh().await; + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); Ok(()) } - pub async fn open_chat(&self, chat_id: &str) -> Result<(), FlowyError> { - self.chats.entry(chat_id.to_string()).or_insert_with(|| { + #[instrument(skip_all, err)] + pub async fn initialize_after_open_workspace( + &self, + _workspace_id: &Uuid, + ) -> Result<(), FlowyError> { + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); + Ok(()) + } + + pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + self.chats.entry(*chat_id).or_insert_with(|| { Arc::new(Chat::new( self.user_service.user_id().unwrap(), - chat_id.to_string(), + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )) }); - trace!("[AI Plugin] notify open chat: {}", chat_id); - if self.local_ai_controller.is_running() { - self.local_ai_controller.open_chat(chat_id); + if self.local_ai.is_running() { + trace!("[AI Plugin] notify open chat: {}", chat_id); + self.local_ai.open_chat(chat_id); } let user_service = self.user_service.clone(); let cloud_service_wm = self.cloud_service_wm.clone(); let store_preferences = self.store_preferences.clone(); let external_service = self.external_service.clone(); - let chat_id = chat_id.to_string(); + let chat_id = *chat_id; tokio::spawn(async move { match refresh_chat_setting( &user_service, @@ -133,7 +175,12 @@ impl AIManager { .await { Ok(settings) => { - let _ = sync_chat_documents(user_service, external_service, settings.rag_ids).await; + let rag_ids = settings + .rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); + let _ = sync_chat_documents(user_service, external_service, rag_ids).await; }, Err(err) => { error!("failed to refresh chat settings: {}", err); @@ -144,19 +191,19 @@ impl AIManager { Ok(()) } - pub async fn close_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn close_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { trace!("close chat: {}", chat_id); - self.local_ai_controller.close_chat(chat_id); + self.local_ai.close_chat(chat_id); Ok(()) } - pub async fn delete_chat(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn delete_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { if let Some((_, chat)) = self.chats.remove(chat_id) { chat.close(); - if self.local_ai_controller.is_running() { + if self.local_ai.is_running() { info!("[AI Plugin] notify close chat: {}", chat_id); - self.local_ai_controller.close_chat(chat_id); + self.local_ai.close_chat(chat_id); } } Ok(()) @@ -184,8 +231,8 @@ impl AIManager { pub async fn create_chat( &self, uid: &i64, - parent_view_id: &str, - chat_id: &str, + parent_view_id: &Uuid, + chat_id: &Uuid, ) -> Result, FlowyError> { let workspace_id = self.user_service.workspace_id()?; let rag_ids = self @@ -197,61 +244,330 @@ impl AIManager { self .cloud_service_wm - .create_chat(uid, &workspace_id, chat_id, rag_ids) + .create_chat(uid, &workspace_id, chat_id, rag_ids, "", json!({})) .await?; - save_chat(self.user_service.sqlite_connection(*uid)?, chat_id)?; let chat = Arc::new(Chat::new( - self.user_service.user_id().unwrap(), - chat_id.to_string(), + self.user_service.user_id()?, + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.to_string(), chat.clone()); + self.chats.insert(*chat_id, chat.clone()); Ok(chat) } - pub async fn stream_chat_message<'a>( - &'a self, - params: &'a StreamMessageParams<'a>, + pub async fn stream_chat_message( + &self, + params: StreamMessageParams, ) -> Result { - let chat = self.get_or_create_chat_instance(params.chat_id).await?; - let question = chat.stream_chat_message(params).await?; + let chat = self.get_or_create_chat_instance(¶ms.chat_id).await?; + let ai_model = self.get_active_model(¶ms.chat_id.to_string()).await; + let question = chat.stream_chat_message(¶ms, ai_model).await?; let _ = self .external_service - .notify_did_send_message(params.chat_id, params.message) + .notify_did_send_message(¶ms.chat_id, ¶ms.message) .await; Ok(question) } pub async fn stream_regenerate_response( &self, - chat_id: &str, + chat_id: &Uuid, answer_message_id: i64, answer_stream_port: i64, format: Option, + model: Option, ) -> FlowyResult<()> { let chat = self.get_or_create_chat_instance(chat_id).await?; let question_message_id = chat - .get_question_id_from_answer_id(answer_message_id) + .get_question_id_from_answer_id(chat_id, answer_message_id) .await?; + + let model = model.map_or_else( + || { + self + .store_preferences + .get_object::(&ai_available_models_key(&chat_id.to_string())) + }, + |model| Some(model.into()), + ); chat - .stream_regenerate_response(question_message_id, answer_stream_port, format) + .stream_regenerate_response(question_message_id, answer_stream_port, format, model) .await?; Ok(()) } - pub async fn get_or_create_chat_instance(&self, chat_id: &str) -> Result, FlowyError> { + pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + let previous_model = self.local_ai.get_local_ai_setting().chat_model_name; + self.local_ai.update_local_ai_setting(setting).await?; + let current_model = self.local_ai.get_local_ai_setting().chat_model_name; + + if previous_model != current_model { + info!( + "[AI Plugin] update global active model, previous: {}, current: {}", + previous_model, current_model + ); + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let model = AIModel::local(current_model, "".to_string()); + self.update_selected_model(source_key, model).await?; + } + + Ok(()) + } + + async fn get_workspace_select_model(&self) -> FlowyResult { + let workspace_id = self.user_service.workspace_id()?; + let model = self + .cloud_service_wm + .get_workspace_default_model(&workspace_id) + .await?; + + if model.is_empty() { + return Ok(DEFAULT_AI_MODEL_NAME.to_string()); + } + Ok(model) + } + + async fn get_server_available_models(&self) -> FlowyResult> { + let workspace_id = self.user_service.workspace_id()?; + let now = timestamp(); + + // First, try reading from the cache with expiration check + let should_fetch = { + let cached_models = self.server_models.read().await; + cached_models.models.is_empty() || cached_models.timestamp.map_or(true, |ts| now - ts >= 300) + }; + + if !should_fetch { + // Cache is still valid, return cached data + let cached_models = self.server_models.read().await; + return Ok(cached_models.models.clone()); + } + + // Cache miss or expired: fetch from the cloud. + match self + .cloud_service_wm + .get_available_models(&workspace_id) + .await + { + Ok(list) => { + let models = list.models; + if let Err(err) = self.update_models_cache(&models, now).await { + error!("Failed to update models cache: {}", err); + } + + Ok(models) + }, + Err(err) => { + error!("Failed to fetch available models: {}", err); + + // Return cached data if available, even if expired + let cached_models = self.server_models.read().await; + if !cached_models.models.is_empty() { + info!("Returning expired cached models due to fetch failure"); + return Ok(cached_models.models.clone()); + } + + // If no cached data, return empty list + Ok(Vec::new()) + }, + } + } + + async fn update_models_cache( + &self, + models: &[AvailableModel], + timestamp: i64, + ) -> FlowyResult<()> { + match self.server_models.try_write() { + Ok(mut cache) => { + cache.models = models.to_vec(); + cache.timestamp = Some(timestamp); + Ok(()) + }, + Err(_) => { + // Handle lock acquisition failure + Err(FlowyError::internal().with_context("Failed to acquire write lock for models cache")) + }, + } + } + + pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> { + info!( + "[Model Selection] update {} selected model: {:?}", + source, model + ); + let source_key = ai_available_models_key(&source); + self + .store_preferences + .set_object::(&source_key, &model)?; + + chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel) + .payload(AIModelPB::from(model)) + .send(); + Ok(()) + } + + #[instrument(skip_all, level = "debug")] + pub async fn toggle_local_ai(&self) -> FlowyResult<()> { + let enabled = self.local_ai.toggle_local_ai().await?; + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + if enabled { + if let Some(name) = self.local_ai.get_plugin_chat_model() { + info!("Set global active model to local ai: {}", name); + let model = AIModel::local(name, "".to_string()); + self.update_selected_model(source_key, model).await?; + } + } else { + info!("Set global active model to default"); + let global_active_model = self.get_workspace_select_model().await?; + let models = self.get_server_available_models().await?; + if let Some(model) = models.into_iter().find(|m| m.name == global_active_model) { + self + .update_selected_model(source_key, AIModel::from(model)) + .await?; + } + } + + Ok(()) + } + + pub async fn get_active_model(&self, source: &str) -> Option { + let mut model = self + .store_preferences + .get_object::(&ai_available_models_key(source)); + + if model.is_none() { + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + model = Some(AIModel::local(local_model, "".to_string())); + } + } + + model + } + + pub async fn get_available_models(&self, source: String) -> FlowyResult { + let is_local_mode = self.user_service.is_local_model().await?; + if is_local_mode { + let mut selected_model = AIModel::default(); + let mut models = vec![]; + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + selected_model = model.clone(); + models.push(model); + } + + Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model: AIModelPB::from(selected_model), + }) + } else { + // Build the models list from server models and mark them as non-local. + let mut models: Vec = self + .get_server_available_models() + .await? + .into_iter() + .map(AIModel::from) + .collect(); + + trace!("[Model Selection]: Available models: {:?}", models); + let mut current_active_local_ai_model = None; + + // If user enable local ai, then add local ai model to the list. + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + current_active_local_ai_model = Some(model.clone()); + trace!("[Model Selection] current local ai model: {}", model.name); + models.push(model); + } + + if models.is_empty() { + return Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model: AIModelPB::default(), + }); + } + + // Global active model is the model selected by the user in the workspace settings. + let mut server_active_model = self + .get_workspace_select_model() + .await + .map(|m| AIModel::server(m, "".to_string())) + .unwrap_or_else(|_| AIModel::default()); + + trace!( + "[Model Selection] server active model: {:?}", + server_active_model + ); + + let mut user_selected_model = server_active_model.clone(); + // when current select model is deprecated, reset the model to default + if !models.iter().any(|m| m.name == server_active_model.name) { + server_active_model = AIModel::default(); + } + + let source_key = ai_available_models_key(&source); + // We use source to identify user selected model. source can be document id or chat id. + match self.store_preferences.get_object::(&source_key) { + None => { + // when there is selected model and current local ai is active, then use local ai + if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { + user_selected_model = local_ai_model.clone(); + } + }, + Some(mut model) => { + trace!("[Model Selection] user previous select model: {:?}", model); + // If source is provided, try to get the user-selected model from the store. User selected + // model will be used as the active model if it exists. + if model.is_local { + if let Some(local_ai_model) = ¤t_active_local_ai_model { + if local_ai_model.name != model.name { + model = local_ai_model.clone(); + } + } + } + + user_selected_model = model; + }, + } + + // If user selected model is not available in the list, use the global active model. + let active_model = models + .iter() + .find(|m| m.name == user_selected_model.name) + .cloned() + .or(Some(server_active_model.clone())); + + // Update the stored preference if a different model is used. + if let Some(ref active_model) = active_model { + if active_model.name != user_selected_model.name { + self + .store_preferences + .set_object::(&source_key, &active_model.clone())?; + } + } + + trace!("[Model Selection] final active model: {:?}", active_model); + let selected_model = AIModelPB::from(active_model.unwrap_or_default()); + Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model, + }) + } + } + + pub async fn get_or_create_chat_instance(&self, chat_id: &Uuid) -> Result, FlowyError> { let chat = self.chats.get(chat_id).as_deref().cloned(); match chat { None => { let chat = Arc::new(Chat::new( - self.user_service.user_id().unwrap(), - chat_id.to_string(), + self.user_service.user_id()?, + *chat_id, self.user_service.clone(), self.cloud_service_wm.clone(), )); - self.chats.insert(chat_id.to_string(), chat.clone()); + self.chats.insert(*chat_id, chat.clone()); Ok(chat) }, Some(chat) => Ok(chat), @@ -275,8 +591,8 @@ impl AIManager { pub async fn load_prev_chat_messages( &self, - chat_id: &str, - limit: i64, + chat_id: &Uuid, + limit: u64, before_message_id: Option, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -288,8 +604,8 @@ impl AIManager { pub async fn load_latest_chat_messages( &self, - chat_id: &str, - limit: i64, + chat_id: &Uuid, + limit: u64, after_message_id: Option, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -301,17 +617,18 @@ impl AIManager { pub async fn get_related_questions( &self, - chat_id: &str, + chat_id: &Uuid, message_id: i64, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; - let resp = chat.get_related_question(message_id).await?; + let ai_model = self.get_active_model(&chat_id.to_string()).await; + let resp = chat.get_related_question(message_id, ai_model).await?; Ok(resp) } pub async fn generate_answer( &self, - chat_id: &str, + chat_id: &Uuid, question_message_id: i64, ) -> Result { let chat = self.get_or_create_chat_instance(chat_id).await?; @@ -319,19 +636,19 @@ impl AIManager { Ok(resp) } - pub async fn stop_stream(&self, chat_id: &str) -> Result<(), FlowyError> { + pub async fn stop_stream(&self, chat_id: &Uuid) -> Result<(), FlowyError> { let chat = self.get_or_create_chat_instance(chat_id).await?; chat.stop_stream_message().await; Ok(()) } - pub async fn chat_with_file(&self, chat_id: &str, file_path: PathBuf) -> FlowyResult<()> { + pub async fn chat_with_file(&self, chat_id: &Uuid, file_path: PathBuf) -> FlowyResult<()> { let chat = self.get_or_create_chat_instance(chat_id).await?; chat.index_file(file_path).await?; Ok(()) } - pub async fn get_rag_ids(&self, chat_id: &str) -> FlowyResult> { + pub async fn get_rag_ids(&self, chat_id: &Uuid) -> FlowyResult> { if let Some(settings) = self .store_preferences .get_object::(&setting_store_key(chat_id)) @@ -349,9 +666,8 @@ impl AIManager { Ok(settings.rag_ids) } - pub async fn update_rag_ids(&self, chat_id: &str, rag_ids: Vec) -> FlowyResult<()> { + pub async fn update_rag_ids(&self, chat_id: &Uuid, rag_ids: Vec) -> FlowyResult<()> { info!("[Chat] update chat:{} rag ids: {:?}", chat_id, rag_ids); - let workspace_id = self.user_service.workspace_id()?; let update_setting = UpdateChatParams { name: None, @@ -364,7 +680,6 @@ impl AIManager { .await?; let chat_setting_store_key = setting_store_key(chat_id); - if let Some(settings) = self .store_preferences .get_object::(&chat_setting_store_key) @@ -382,6 +697,10 @@ impl AIManager { let user_service = self.user_service.clone(); let external_service = self.external_service.clone(); + let rag_ids = rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); sync_chat_documents(user_service, external_service, rag_ids).await?; Ok(()) } @@ -390,7 +709,7 @@ impl AIManager { async fn sync_chat_documents( user_service: Arc, external_service: Arc, - rag_ids: Vec, + rag_ids: Vec, ) -> FlowyResult<()> { if rag_ids.is_empty() { return Ok(()); @@ -420,26 +739,11 @@ async fn sync_chat_documents( Ok(()) } -fn save_chat(conn: DBConnection, chat_id: &str) -> FlowyResult<()> { - let row = ChatTable { - chat_id: chat_id.to_string(), - created_at: timestamp(), - name: "".to_string(), - local_files: "".to_string(), - metadata: "".to_string(), - local_enabled: false, - sync_to_cloud: false, - }; - - insert_chat(conn, &row)?; - Ok(()) -} - async fn refresh_chat_setting( user_service: &Arc, - cloud_service: &Arc, + cloud_service: &Arc, store_preferences: &Arc, - chat_id: &str, + chat_id: &Uuid, ) -> FlowyResult { info!("[Chat] refresh chat:{} setting", chat_id); let workspace_id = user_service.workspace_id()?; @@ -451,7 +755,7 @@ async fn refresh_chat_setting( error!("failed to set chat settings: {}", err); } - chat_notification_builder(chat_id, ChatNotification::DidUpdateChatSettings) + chat_notification_builder(chat_id.to_string(), ChatNotification::DidUpdateChatSettings) .payload(ChatSettingsPB { rag_ids: settings.rag_ids.clone(), }) @@ -460,6 +764,6 @@ async fn refresh_chat_setting( Ok(settings) } -fn setting_store_key(chat_id: &str) -> String { +fn setting_store_key(chat_id: &Uuid) -> String { format!("chat_settings_{}", chat_id) } diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs index f6b90e9b00..3180227ed0 100644 --- a/frontend/rust-lib/flowy-ai/src/chat.rs +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -3,18 +3,18 @@ use crate::entities::{ ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, }; -use crate::middleware::chat_service_mw::AICloudServiceMiddleware; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; use crate::notification::{chat_notification_builder, ChatNotification}; -use crate::persistence::{ - insert_chat_messages, select_chat_messages, select_message_where_match_reply_message_id, - ChatMessageTable, -}; use crate::stream_message::StreamMessage; use allo_isolate::Isolate; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, + AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, }; -use flowy_error::{FlowyError, FlowyResult}; +use flowy_ai_pub::persistence::{ + select_answer_where_match_reply_message_id, select_chat_messages, upsert_chat_messages, + ChatMessageTable, +}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::DBConnection; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; @@ -23,6 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicI64}; use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use tracing::{error, instrument, trace}; +use uuid::Uuid; enum PrevMessageState { HasMore, @@ -31,10 +32,10 @@ enum PrevMessageState { } pub struct Chat { - chat_id: String, + chat_id: Uuid, uid: i64, user_service: Arc, - chat_service: Arc, + chat_service: Arc, prev_message_state: Arc>, latest_message_id: Arc, stop_stream: Arc, @@ -44,9 +45,9 @@ pub struct Chat { impl Chat { pub fn new( uid: i64, - chat_id: String, + chat_id: Uuid, user_service: Arc, - chat_service: Arc, + chat_service: Arc, ) -> Chat { Chat { uid, @@ -62,18 +63,6 @@ impl Chat { pub fn close(&self) {} - #[allow(dead_code)] - pub async fn pull_latest_message(&self, limit: i64) { - let latest_message_id = self - .latest_message_id - .load(std::sync::atomic::Ordering::Relaxed); - if latest_message_id > 0 { - let _ = self - .load_remote_chat_messages(limit, None, Some(latest_message_id)) - .await; - } - } - pub async fn stop_stream_message(&self) { self .stop_stream @@ -81,16 +70,16 @@ impl Chat { } #[instrument(level = "info", skip_all, err)] - pub async fn stream_chat_message<'a>( - &'a self, - params: &'a StreamMessageParams<'a>, + pub async fn stream_chat_message( + &self, + params: &StreamMessageParams, + preferred_ai_model: Option, ) -> Result { trace!( - "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, metadata={:?}, format={:?}", + "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, format={:?}", self.chat_id, params.message, params.message_type, - params.metadata, params.format, ); @@ -113,9 +102,8 @@ impl Chat { .create_question( &workspace_id, &self.chat_id, - params.message, + ¶ms.message, params.message_type.clone(), - ¶ms.metadata, ) .await .map_err(|err| { @@ -126,20 +114,10 @@ impl Chat { let _ = question_sink .send(StreamMessage::MessageId(question.message_id).to_string()) .await; - if let Err(err) = self - .chat_service - .index_message_metadata(&self.chat_id, ¶ms.metadata, &mut question_sink) - .await - { - error!("Failed to index file: {}", err); - } - let _ = question_sink.send(StreamMessage::Done.to_string()).await; // Save message to disk - save_and_notify_message(uid, &self.chat_id, &self.user_service, question.clone())?; - - let format = params.format.clone().unwrap_or_default().into(); - + notify_message(&self.chat_id, question.clone())?; + let format = params.format.clone().map(Into::into).unwrap_or_default(); self.stream_response( params.answer_stream_port, answer_stream_buffer, @@ -147,6 +125,7 @@ impl Chat { workspace_id, question.message_id, format, + preferred_ai_model, ); let question_pb = ChatMessagePB::from(question); @@ -159,6 +138,7 @@ impl Chat { question_id: i64, answer_stream_port: i64, format: Option, + ai_model: Option, ) -> FlowyResult<()> { trace!( "[Chat] regenerate and stream chat message: chat_id={}", @@ -171,7 +151,7 @@ impl Chat { .store(false, std::sync::atomic::Ordering::SeqCst); self.stream_buffer.lock().await.clear(); - let format = format.unwrap_or_default().into(); + let format = format.map(Into::into).unwrap_or_default(); let answer_stream_buffer = self.stream_buffer.clone(); let uid = self.user_service.user_id()?; @@ -184,28 +164,30 @@ impl Chat { workspace_id, question_id, format, + ai_model, ); Ok(()) } + #[allow(clippy::too_many_arguments)] fn stream_response( &self, answer_stream_port: i64, answer_stream_buffer: Arc>, - uid: i64, - workspace_id: String, + _uid: i64, + workspace_id: Uuid, question_id: i64, format: ResponseFormat, + ai_model: Option, ) { let stop_stream = self.stop_stream.clone(); - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); - let user_service = self.user_service.clone(); tokio::spawn(async move { let mut answer_sink = IsolateSink::new(Isolate::new(answer_stream_port)); match cloud_service - .stream_answer(&workspace_id, &chat_id, question_id, format) + .stream_answer(&workspace_id, &chat_id, question_id, format, ai_model) .await { Ok(mut stream) => { @@ -219,16 +201,20 @@ impl Chat { match message { QuestionStreamValue::Answer { value } => { answer_stream_buffer.lock().await.push_str(&value); - // trace!("[Chat] stream answer: {}", value); - if let Err(err) = answer_sink.send(format!("data:{}", value)).await { - error!("Failed to stream answer: {}", err); + if let Err(err) = answer_sink + .send(StreamMessage::OnData(value).to_string()) + .await + { + error!("Failed to stream answer via IsolateSink: {}", err); } }, QuestionStreamValue::Metadata { value } => { if let Ok(s) = serde_json::to_string(&value) { // trace!("[Chat] stream metadata: {}", s); answer_stream_buffer.lock().await.set_metadata(value); - let _ = answer_sink.send(format!("metadata:{}", s)).await; + let _ = answer_sink + .send(StreamMessage::Metadata(s).to_string()) + .await; } }, QuestionStreamValue::KeepAlive => { @@ -237,16 +223,23 @@ impl Chat { } }, Err(err) => { - error!("[Chat] failed to stream answer: {}", err); - let _ = answer_sink.send(format!("error:{}", err)).await; - let pb = ChatMessageErrorPB { - chat_id: chat_id.clone(), - error_message: err.to_string(), - }; - chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) - .payload(pb) - .send(); - return Err(err); + if err.code == ErrorCode::RequestTimeout || err.code == ErrorCode::Internal { + error!("[Chat] unexpected stream error: {}", err); + let _ = answer_sink.send(StreamMessage::Done.to_string()).await; + } else { + error!("[Chat] failed to stream answer: {}", err); + let _ = answer_sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; + let pb = ChatMessageErrorPB { + chat_id: chat_id.to_string(), + error_message: err.to_string(), + }; + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) + .payload(pb) + .send(); + return Err(err); + } }, } } @@ -259,22 +252,36 @@ impl Chat { let _ = answer_sink .send("AI_IMAGE_RESPONSE_LIMIT".to_string()) .await; + } else if err.is_ai_max_required() { + let _ = answer_sink + .send(format!("AI_MAX_REQUIRED:{}", err.msg)) + .await; + } else if err.is_local_ai_not_ready() { + let _ = answer_sink + .send(format!("LOCAL_AI_NOT_READY:{}", err.msg)) + .await; + } else if err.is_local_ai_disabled() { + let _ = answer_sink + .send(format!("LOCAL_AI_DISABLED:{}", err.msg)) + .await; } else { - let _ = answer_sink.send(format!("error:{}", err)).await; + let _ = answer_sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; } let pb = ChatMessageErrorPB { - chat_id: chat_id.clone(), + chat_id: chat_id.to_string(), error_message: err.to_string(), }; - chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError) + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) .payload(pb) .send(); return Err(err); }, } - chat_notification_builder(&chat_id, ChatNotification::FinishStreaming).send(); + chat_notification_builder(chat_id, ChatNotification::FinishStreaming).send(); trace!("[Chat] finish streaming"); if answer_stream_buffer.lock().await.is_empty() { @@ -291,7 +298,7 @@ impl Chat { metadata, ) .await?; - save_and_notify_message(uid, &chat_id, &user_service, answer)?; + notify_message(&chat_id, answer)?; Ok::<(), FlowyError>(()) }); } @@ -310,7 +317,7 @@ impl Chat { /// - `before_message_id` is the first message ID in the current chat messages. pub async fn load_prev_chat_messages( &self, - limit: i64, + limit: u64, before_message_id: Option, ) -> Result { trace!( @@ -319,9 +326,9 @@ impl Chat { limit, before_message_id ); - let messages = self - .load_local_chat_messages(limit, None, before_message_id) - .await?; + + let offset = before_message_id.map_or(MessageCursor::NextBack, MessageCursor::BeforeMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; // If the number of messages equals the limit, then no need to load more messages from remote if messages.len() == limit as usize { @@ -330,7 +337,7 @@ impl Chat { has_more: true, total: 0, }; - chat_notification_builder(&self.chat_id, ChatNotification::DidLoadPrevChatMessage) + chat_notification_builder(self.chat_id, ChatNotification::DidLoadPrevChatMessage) .payload(pb.clone()) .send(); return Ok(pb); @@ -358,7 +365,7 @@ impl Chat { pub async fn load_latest_chat_messages( &self, - limit: i64, + limit: u64, after_message_id: Option, ) -> Result { trace!( @@ -367,9 +374,8 @@ impl Chat { limit, after_message_id, ); - let messages = self - .load_local_chat_messages(limit, after_message_id, None) - .await?; + let offset = after_message_id.map_or(MessageCursor::NextBack, MessageCursor::AfterMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; trace!( "[Chat] Loaded local chat messages: chat_id={}, messages={}", @@ -391,7 +397,7 @@ impl Chat { async fn load_remote_chat_messages( &self, - limit: i64, + limit: u64, before_message_id: Option, after_message_id: Option, ) -> FlowyResult<()> { @@ -403,7 +409,7 @@ impl Chat { after_message_id ); let workspace_id = self.user_service.workspace_id()?; - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); let user_service = self.user_service.clone(); let uid = self.uid; @@ -416,7 +422,7 @@ impl Chat { _ => MessageCursor::NextBack, }; match cloud_service - .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit as u64) + .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit) .await { Ok(resp) => { @@ -425,6 +431,7 @@ impl Chat { user_service.sqlite_connection(uid)?, &chat_id, resp.messages.clone(), + true, ) { error!("Failed to save chat:{} messages: {}", chat_id, err); } @@ -451,11 +458,11 @@ impl Chat { } else { *prev_message_state.write().await = PrevMessageState::NoMore; } - chat_notification_builder(&chat_id, ChatNotification::DidLoadPrevChatMessage) + chat_notification_builder(chat_id, ChatNotification::DidLoadPrevChatMessage) .payload(pb) .send(); } else { - chat_notification_builder(&chat_id, ChatNotification::DidLoadLatestChatMessage) + chat_notification_builder(chat_id, ChatNotification::DidLoadLatestChatMessage) .payload(pb) .send(); } @@ -469,19 +476,21 @@ impl Chat { pub async fn get_question_id_from_answer_id( &self, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { let conn = self.user_service.sqlite_connection(self.uid)?; - let local_result = select_message_where_match_reply_message_id(conn, answer_message_id)? - .map(|message| message.message_id); + let local_result = + select_answer_where_match_reply_message_id(conn, &chat_id.to_string(), answer_message_id)? + .map(|message| message.message_id); if let Some(message_id) = local_result { return Ok(message_id); } let workspace_id = self.user_service.workspace_id()?; - let chat_id = self.chat_id.clone(); + let chat_id = self.chat_id; let cloud_service = self.chat_service.clone(); let question = cloud_service @@ -494,11 +503,12 @@ impl Chat { pub async fn get_related_question( &self, message_id: i64, + ai_model: Option, ) -> Result { let workspace_id = self.user_service.workspace_id()?; let resp = self .chat_service - .get_related_message(&workspace_id, &self.chat_id, message_id) + .get_related_message(&workspace_id, &self.chat_id, message_id, ai_model) .await?; trace!( @@ -523,26 +533,19 @@ impl Chat { .get_answer(&workspace_id, &self.chat_id, question_message_id) .await?; - save_and_notify_message(self.uid, &self.chat_id, &self.user_service, answer.clone())?; + notify_message(&self.chat_id, answer.clone())?; let pb = ChatMessagePB::from(answer); Ok(pb) } async fn load_local_chat_messages( &self, - limit: i64, - after_message_id: Option, - before_message_id: Option, + limit: u64, + offset: MessageCursor, ) -> Result, FlowyError> { let conn = self.user_service.sqlite_connection(self.uid)?; - let records = select_chat_messages( - conn, - &self.chat_id, - limit, - after_message_id, - before_message_id, - )?; - let messages = records + let rows = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?.messages; + let messages = rows .into_iter() .map(|record| ChatMessagePB { message_id: record.message_id, @@ -579,7 +582,7 @@ impl Chat { ); self .chat_service - .index_file( + .embed_file( &self.user_service.workspace_id()?, &file_path, &self.chat_id, @@ -599,8 +602,9 @@ impl Chat { fn save_chat_message_disk( conn: DBConnection, - chat_id: &str, + chat_id: &Uuid, messages: Vec, + is_sync: bool, ) -> FlowyResult<()> { let records = messages .into_iter() @@ -612,10 +616,11 @@ fn save_chat_message_disk( author_type: message.author.author_type as i64, author_id: message.author.author_id.to_string(), reply_message_id: message.reply_message_id, - metadata: Some(serde_json::to_string(&message.meta_data).unwrap_or_default()), + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, }) .collect::>(); - insert_chat_messages(conn, &records)?; + upsert_chat_messages(conn, &records)?; Ok(()) } @@ -652,18 +657,8 @@ impl StringBuffer { } } -pub(crate) fn save_and_notify_message( - uid: i64, - chat_id: &str, - user_service: &Arc, - message: ChatMessage, -) -> Result<(), FlowyError> { +pub(crate) fn notify_message(chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { trace!("[Chat] save answer: answer={:?}", message); - save_chat_message_disk( - user_service.sqlite_connection(uid)?, - chat_id, - vec![message.clone()], - )?; let pb = ChatMessagePB::from(message); chat_notification_builder(chat_id, ChatNotification::DidReceiveChatMessage) .payload(pb) diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs index fd799b411f..31acde4ae7 100644 --- a/frontend/rust-lib/flowy-ai/src/completion.rs +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -1,19 +1,23 @@ use crate::ai_manager::AIUserService; use crate::entities::{CompleteTextPB, CompleteTextTaskPB, CompletionTypePB}; use allo_isolate::Isolate; +use std::str::FromStr; use dashmap::DashMap; use flowy_ai_pub::cloud::{ - ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionType, + AIModel, ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionStreamValue, + CompletionType, CustomPrompt, }; use flowy_error::{FlowyError, FlowyResult}; use futures::{SinkExt, StreamExt}; use lib_infra::isolate_stream::IsolateSink; +use crate::stream_message::StreamMessage; use std::sync::{Arc, Weak}; use tokio::select; -use tracing::info; +use tracing::{error, info}; +use uuid::Uuid; pub struct AICompletion { tasks: Arc>>, @@ -36,14 +40,30 @@ impl AICompletion { pub async fn create_complete_task( &self, complete: CompleteTextPB, + preferred_model: Option, ) -> FlowyResult { + if matches!(complete.completion_type, CompletionTypePB::CustomPrompt) + && complete.custom_prompt.is_none() + { + return Err( + FlowyError::invalid_data() + .with_context("custom_prompt is required when completion_type is CustomPrompt"), + ); + } + let workspace_id = self .user_service .upgrade() .ok_or_else(FlowyError::internal)? .workspace_id()?; let (tx, rx) = tokio::sync::mpsc::channel(1); - let task = CompletionTask::new(workspace_id, complete, self.cloud_service.clone(), rx); + let task = CompletionTask::new( + workspace_id, + complete, + preferred_model, + self.cloud_service.clone(), + rx, + ); let task_id = task.task_id.clone(); self.tasks.insert(task_id.clone(), tx); @@ -59,17 +79,19 @@ impl AICompletion { } pub struct CompletionTask { - workspace_id: String, + workspace_id: Uuid, task_id: String, stop_rx: tokio::sync::mpsc::Receiver<()>, context: CompleteTextPB, cloud_service: Weak, + preferred_model: Option, } impl CompletionTask { pub fn new( - workspace_id: String, + workspace_id: Uuid, context: CompleteTextPB, + preferred_model: Option, cloud_service: Weak, stop_rx: tokio::sync::mpsc::Receiver<()>, ) -> Self { @@ -79,6 +101,7 @@ impl CompletionTask { context, cloud_service, stop_rx, + preferred_model, } } @@ -88,68 +111,96 @@ impl CompletionTask { if let Some(cloud_service) = self.cloud_service.upgrade() { let complete_type = match self.context.completion_type { - CompletionTypePB::UnknownCompletionType | CompletionTypePB::ImproveWriting => { - CompletionType::ImproveWriting - }, + CompletionTypePB::ImproveWriting => CompletionType::ImproveWriting, CompletionTypePB::SpellingAndGrammar => CompletionType::SpellingAndGrammar, CompletionTypePB::MakeShorter => CompletionType::MakeShorter, CompletionTypePB::MakeLonger => CompletionType::MakeLonger, CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting, + CompletionTypePB::ExplainSelected => CompletionType::Explain, + CompletionTypePB::UserQuestion => CompletionType::UserQuestion, + CompletionTypePB::CustomPrompt => CompletionType::CustomPrompt, }; let _ = sink.send("start:".to_string()).await; - let params = CompleteTextParams { - text: self.context.text, - completion_type: Some(complete_type), - custom_prompt: None, - metadata: Some(CompletionMetadata { - object_id: self.context.object_id, - rag_ids: Some(self.context.rag_ids), - }), - }; + let completion_history = Some(self.context.history.iter().map(Into::into).collect()); + let format = self.context.format.map(Into::into).unwrap_or_default(); + if let Ok(object_id) = Uuid::from_str(&self.context.object_id) { + let params = CompleteTextParams { + text: self.context.text, + completion_type: Some(complete_type), + metadata: Some(CompletionMetadata { + object_id, + workspace_id: Some(self.workspace_id), + rag_ids: Some(self.context.rag_ids), + completion_history, + custom_prompt: self + .context + .custom_prompt + .map(|v| CustomPrompt { system: v }), + }), + format, + }; - info!("start completion: {:?}", params); - match cloud_service - .stream_complete(&self.workspace_id, params) - .await - { - Ok(mut stream) => loop { - select! { - _ = self.stop_rx.recv() => { - return; - }, - result = stream.next() => { + info!("start completion: {:?}", params); + match cloud_service + .stream_complete(&self.workspace_id, params, self.preferred_model) + .await + { + Ok(mut stream) => loop { + select! { + _ = self.stop_rx.recv() => { + return; + }, + result = stream.next() => { match result { - Some(Ok(data)) => { - let s = String::from_utf8(data.to_vec()).unwrap_or_default(); - let _ = sink.send(format!("data:{}", s)).await; - }, - Some(Err(error)) => { - handle_error(&mut sink, error).await; - return; - }, - None => { - let _ = sink.send(format!("finish:{}", self.task_id)).await; - return; - }, + Some(Ok(data)) => { + match data { + CompletionStreamValue::Answer{ value } => { + let _ = sink.send(format!("data:{}", value)).await; + } + CompletionStreamValue::Comment{ value } => { + let _ = sink.send(format!("comment:{}", value)).await; + } + } + }, + Some(Err(error)) => { + handle_error(&mut sink, error).await; + return; + }, + None => { + let _ = sink.send(format!("finish:{}", self.task_id)).await; + return; + }, } - } - } - }, - Err(error) => { - handle_error(&mut sink, error).await; - }, + } + } + }, + Err(error) => { + handle_error(&mut sink, error).await; + }, + } + } else { + error!("Invalid uuid: {}", self.context.object_id); } } }); } } -async fn handle_error(sink: &mut IsolateSink, error: FlowyError) { - if error.is_ai_response_limit_exceeded() { + +async fn handle_error(sink: &mut IsolateSink, err: FlowyError) { + if err.is_ai_response_limit_exceeded() { let _ = sink.send("AI_RESPONSE_LIMIT".to_string()).await; - } else if error.is_ai_image_response_limit_exceeded() { + } else if err.is_ai_image_response_limit_exceeded() { let _ = sink.send("AI_IMAGE_RESPONSE_LIMIT".to_string()).await; + } else if err.is_ai_max_required() { + let _ = sink.send(format!("AI_MAX_REQUIRED:{}", err.msg)).await; + } else if err.is_local_ai_not_ready() { + let _ = sink.send(format!("LOCAL_AI_NOT_READY:{}", err.msg)).await; + } else if err.is_local_ai_disabled() { + let _ = sink.send(format!("LOCAL_AI_DISABLED:{}", err.msg)).await; } else { - let _ = sink.send(format!("error:{}", error)).await; + let _ = sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; } } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index a5b9778f06..5a4aecbbd7 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -1,14 +1,14 @@ -use crate::local_ai::local_llm_chat::LLMModelInfo; -use appflowy_plugin::core::plugin::RunningState; -use std::collections::HashMap; - -use crate::local_ai::local_llm_resource::PendingResource; +use crate::local_ai::controller::LocalAISetting; +use crate::local_ai::resource::PendingResource; +use af_plugin::core::plugin::RunningState; use flowy_ai_pub::cloud::{ - ChatMessage, ChatMessageMetadata, ChatMessageType, LLMModel, OutputContent, OutputLayout, + AIModel, ChatMessage, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use lib_infra::validator_fn::required_not_empty_str; +use std::collections::HashMap; +use uuid::Uuid; use validator::Validate; #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -70,20 +70,16 @@ pub struct StreamChatPayloadPB { #[pb(index = 6, one_of)] pub format: Option, - - #[pb(index = 7)] - pub metadata: Vec, } #[derive(Default, Debug)] -pub struct StreamMessageParams<'a> { - pub chat_id: &'a str, - pub message: &'a str, +pub struct StreamMessageParams { + pub chat_id: Uuid, + pub message: String, pub message_type: ChatMessageType, pub answer_stream_port: i64, pub question_stream_port: i64, pub format: Option, - pub metadata: Vec, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -100,6 +96,9 @@ pub struct RegenerateResponsePB { #[pb(index = 4, one_of)] pub format: Option, + + #[pb(index = 5, one_of)] + pub model: Option, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -182,6 +181,82 @@ pub struct ChatMessageListPB { pub total: i64, } +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ServerAvailableModelsPB { + #[pb(index = 1)] + pub models: Vec, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_default: bool, + + #[pb(index = 3)] + pub desc: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct AvailableModelsQueryPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct UpdateSelectedModelPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, + + #[pb(index = 2)] + pub selected_model: AIModelPB, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelsPB { + #[pb(index = 1)] + pub models: Vec, + + #[pb(index = 2)] + pub selected_model: AIModelPB, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AIModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_local: bool, + + #[pb(index = 3)] + pub desc: String, +} + +impl From for AIModelPB { + fn from(model: AIModel) -> Self { + Self { + name: model.name, + is_local: model.is_local, + desc: model.desc, + } + } +} + +impl From for AIModel { + fn from(value: AIModelPB) -> Self { + AIModel { + name: value.name, + is_local: value.is_local, + desc: value.desc, + } + } +} + impl From for ChatMessageListPB { fn from(repeated_chat_message: RepeatedChatMessage) -> Self { let messages = repeated_chat_message @@ -239,7 +314,7 @@ impl From for ChatMessagePB { author_type: chat_message.author.author_type as i64, author_id: chat_message.author.author_id.to_string(), reply_message_id: None, - metadata: Some(serde_json::to_string(&chat_message.meta_data).unwrap_or_default()), + metadata: Some(serde_json::to_string(&chat_message.metadata).unwrap_or_default()), } } } @@ -303,24 +378,6 @@ impl From for RepeatedRelatedQuestionPB { } } -#[derive(Debug, Clone, Default, ProtoBuf)] -pub struct LLMModelInfoPB { - #[pb(index = 1)] - pub selected_model: LLMModelPB, - - #[pb(index = 2)] - pub models: Vec, -} - -impl From for LLMModelInfoPB { - fn from(value: LLMModelInfo) -> Self { - LLMModelInfoPB { - selected_model: LLMModelPB::from(value.selected_model), - models: value.models.into_iter().map(LLMModelPB::from).collect(), - } - } -} - #[derive(Debug, Clone, Default, ProtoBuf)] pub struct LLMModelPB { #[pb(index = 1)] @@ -359,14 +416,23 @@ pub struct CompleteTextPB { #[pb(index = 2)] pub completion_type: CompletionTypePB, - #[pb(index = 3)] - pub stream_port: i64, + #[pb(index = 3, one_of)] + pub format: Option, #[pb(index = 4)] - pub object_id: String, + pub stream_port: i64, #[pb(index = 5)] + pub object_id: String, + + #[pb(index = 6)] pub rag_ids: Vec, + + #[pb(index = 7)] + pub history: Vec, + + #[pb(index = 8, one_of)] + pub custom_prompt: Option, } #[derive(Default, ProtoBuf, Clone, Debug)] @@ -377,13 +443,37 @@ pub struct CompleteTextTaskPB { #[derive(Clone, Debug, ProtoBuf_Enum, Default)] pub enum CompletionTypePB { - UnknownCompletionType = 0, #[default] - ImproveWriting = 1, - SpellingAndGrammar = 2, - MakeShorter = 3, - MakeLonger = 4, - ContinueWriting = 5, + UserQuestion = 0, + ExplainSelected = 1, + ContinueWriting = 2, + SpellingAndGrammar = 3, + ImproveWriting = 4, + MakeShorter = 5, + MakeLonger = 6, + CustomPrompt = 7, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct CompletionRecordPB { + #[pb(index = 1)] + pub role: ChatMessageTypePB, + + #[pb(index = 2)] + pub content: String, +} + +impl From<&CompletionRecordPB> for CompletionMessage { + fn from(value: &CompletionRecordPB) -> Self { + CompletionMessage { + role: match value.role { + // Coerce ChatMessageTypePB::System to AI + ChatMessageTypePB::System => "ai".to_string(), + ChatMessageTypePB::User => "human".to_string(), + }, + content: value.content.clone(), + } + } } #[derive(Default, ProtoBuf, Clone, Debug)] @@ -413,17 +503,6 @@ pub struct ChatFilePB { pub chat_id: String, } -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct DownloadLLMPB { - #[pb(index = 1)] - pub progress_stream: i64, -} - -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct DownloadTaskPB { - #[pb(index = 1)] - pub task_id: String, -} #[derive(Default, ProtoBuf, Clone, Debug)] pub struct LocalModelStatePB { #[pb(index = 1)] @@ -442,18 +521,6 @@ pub struct LocalModelStatePB { pub is_downloading: bool, } -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalModelResourcePB { - #[pb(index = 1)] - pub is_ready: bool, - - #[pb(index = 2)] - pub pending_resources: Vec, - - #[pb(index = 3)] - pub is_downloading: bool, -} - #[derive(Default, ProtoBuf, Clone, Debug)] pub struct PendingResourcePB { #[pb(index = 1)] @@ -472,40 +539,33 @@ pub struct PendingResourcePB { #[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] pub enum PendingResourceTypePB { #[default] - OfflineApp = 0, - AIModel = 1, + LocalAIAppRes = 0, + ModelRes = 1, } impl From for PendingResourceTypePB { fn from(value: PendingResource) -> Self { match value { - PendingResource::OfflineApp { .. } => PendingResourceTypePB::OfflineApp, - PendingResource::ModelInfoRes { .. } => PendingResourceTypePB::AIModel, + PendingResource::PluginExecutableNotReady { .. } => PendingResourceTypePB::LocalAIAppRes, + _ => PendingResourceTypePB::ModelRes, } } } -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalAIPluginStatePB { - #[pb(index = 1)] - pub state: RunningStatePB, - - #[pb(index = 2)] - pub offline_ai_ready: bool, -} - #[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] pub enum RunningStatePB { #[default] - Connecting = 0, - Connected = 1, - Running = 2, - Stopped = 3, + ReadyToRun = 0, + Connecting = 1, + Connected = 2, + Running = 3, + Stopped = 4, } impl From for RunningStatePB { fn from(value: RunningState) -> Self { match value { + RunningState::ReadyToConnect => RunningStatePB::ReadyToRun, RunningState::Connecting => RunningStatePB::Connecting, RunningState::Connected { .. } => RunningStatePB::Connected, RunningState::Running { .. } => RunningStatePB::Running, @@ -519,30 +579,18 @@ impl From for RunningStatePB { pub struct LocalAIPB { #[pb(index = 1)] pub enabled: bool, -} -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalAIChatPB { - #[pb(index = 1)] - pub enabled: bool, - - #[pb(index = 2)] - pub file_enabled: bool, + #[pb(index = 2, one_of)] + pub lack_of_resource: Option, #[pb(index = 3)] - pub plugin_state: LocalAIPluginStatePB, -} + pub state: RunningStatePB, -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalModelStoragePB { - #[pb(index = 1)] - pub file_path: String, -} + #[pb(index = 4, one_of)] + pub plugin_version: Option, -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct OfflineAIPB { - #[pb(index = 1)] - pub link: String, + #[pb(index = 5)] + pub plugin_downloaded: bool, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] @@ -577,6 +625,9 @@ pub struct UpdateChatSettingsPB { #[pb(index = 2)] pub rag_ids: Vec, + + #[pb(index = 3)] + pub chat_model: String, } #[derive(Debug, Default, Clone, ProtoBuf)] @@ -626,3 +677,74 @@ impl From for ResponseFormat { } } } + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LocalAISettingPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub server_url: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_model_name: String, + + #[pb(index = 3)] + #[validate(custom(function = "required_not_empty_str"))] + pub embedding_model_name: String, +} + +impl From for LocalAISettingPB { + fn from(value: LocalAISetting) -> Self { + LocalAISettingPB { + server_url: value.ollama_server_url, + chat_model_name: value.chat_model_name, + embedding_model_name: value.embedding_model_name, + } + } +} + +impl From for LocalAISetting { + fn from(value: LocalAISettingPB) -> Self { + LocalAISetting { + ollama_server_url: value.server_url, + chat_model_name: value.chat_model_name, + embedding_model_name: value.embedding_model_name, + } + } +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct LackOfAIResourcePB { + #[pb(index = 1)] + pub resource_type: LackOfAIResourceTypePB, + + #[pb(index = 2)] + pub missing_model_names: Vec, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum LackOfAIResourceTypePB { + #[default] + PluginExecutableNotReady = 0, + OllamaServerNotReady = 1, + MissingModel = 2, +} + +impl From for LackOfAIResourcePB { + fn from(value: PendingResource) -> Self { + match value { + PendingResource::PluginExecutableNotReady => Self { + resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady, + missing_model_names: vec![], + }, + PendingResource::OllamaServerNotReady => Self { + resource_type: LackOfAIResourceTypePB::OllamaServerNotReady, + missing_model_names: vec![], + }, + PendingResource::MissingModel(model_name) => Self { + resource_type: LackOfAIResourceTypePB::MissingModel, + missing_model_names: vec![model_name], + }, + } + } +} diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index d8ebd0e93b..f85858b1c2 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -1,20 +1,15 @@ -use std::fs; -use std::path::PathBuf; - -use crate::ai_manager::AIManager; +use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY}; use crate::completion::AICompletion; use crate::entities::*; -use crate::local_ai::local_llm_chat::LLMModelInfo; -use crate::notification::{ - chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, -}; -use allo_isolate::Isolate; -use flowy_ai_pub::cloud::{ChatMessageMetadata, ChatMessageType, ChatRAGData, ContextLoader}; +use crate::util::ai_available_models_key; +use flowy_ai_pub::cloud::{AIModel, ChatMessageType}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use lib_infra::isolate_stream::IsolateSink; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::trace; +use uuid::Uuid; use validator::Validate; fn upgrade_ai_manager(ai_manager: AFPluginState>) -> FlowyResult> { @@ -39,7 +34,6 @@ pub(crate) async fn stream_chat_message_handler( answer_stream_port, question_stream_port, format, - metadata, } = data; let message_type = match message_type { @@ -47,44 +41,18 @@ pub(crate) async fn stream_chat_message_handler( ChatMessageTypePB::User => ChatMessageType::User, }; - let metadata = metadata - .into_iter() - .map(|metadata| { - let (content_type, content_len) = match metadata.loader_type { - ContextLoaderTypePB::Txt => (ContextLoader::Text, metadata.data.len()), - ContextLoaderTypePB::Markdown => (ContextLoader::Markdown, metadata.data.len()), - ContextLoaderTypePB::PDF => (ContextLoader::PDF, 0), - ContextLoaderTypePB::UnknownLoaderType => (ContextLoader::Unknown, 0), - }; - - ChatMessageMetadata { - data: ChatRAGData { - content: metadata.data, - content_type, - size: content_len as i64, - }, - id: metadata.id, - name: metadata.name.clone(), - source: metadata.source, - extra: None, - } - }) - .collect::>(); - - trace!("Stream chat message with metadata: {:?}", metadata); - + let chat_id = Uuid::from_str(&chat_id)?; let params = StreamMessageParams { - chat_id: &chat_id, - message: &message, + chat_id, + message, message_type, answer_stream_port, question_stream_port, format, - metadata, }; let ai_manager = upgrade_ai_manager(ai_manager)?; - let result = ai_manager.stream_chat_message(¶ms).await?; + let result = ai_manager.stream_chat_message(params).await?; data_result_ok(result) } @@ -94,19 +62,54 @@ pub(crate) async fn regenerate_response_handler( ai_manager: AFPluginState>, ) -> FlowyResult<()> { let data = data.try_into_inner()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let ai_manager = upgrade_ai_manager(ai_manager)?; ai_manager .stream_regenerate_response( - &data.chat_id, + &chat_id, data.answer_message_id, data.answer_stream_port, data.format, + data.model, ) .await?; Ok(()) } +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_server_model_list_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let models = ai_manager.get_available_models(source_key).await?; + data_result_ok(models) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_chat_models_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let models = ai_manager.get_available_models(data.source).await?; + data_result_ok(models) +} + +pub(crate) async fn update_selected_model_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager + .update_selected_model(data.source, AIModel::from(data.selected_model)) + .await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn load_prev_message_handler( data: AFPluginData, @@ -116,8 +119,9 @@ pub(crate) async fn load_prev_message_handler( let data = data.into_inner(); data.validate()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_prev_chat_messages(&data.chat_id, data.limit, data.before_message_id) + .load_prev_chat_messages(&chat_id, data.limit as u64, data.before_message_id) .await?; data_result_ok(messages) } @@ -131,8 +135,9 @@ pub(crate) async fn load_next_message_handler( let data = data.into_inner(); data.validate()?; + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .load_latest_chat_messages(&data.chat_id, data.limit, data.after_message_id) + .load_latest_chat_messages(&chat_id, data.limit as u64, data.after_message_id) .await?; data_result_ok(messages) } @@ -144,8 +149,9 @@ pub(crate) async fn get_related_question_handler( ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; let messages = ai_manager - .get_related_questions(&data.chat_id, data.message_id) + .get_related_questions(&chat_id, data.message_id) .await?; data_result_ok(messages) } @@ -157,8 +163,9 @@ pub(crate) async fn get_answer_handler( ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; let message = ai_manager - .generate_answer(&data.chat_id, data.message_id) + .generate_answer(&chat_id, data.message_id) .await?; data_result_ok(message) } @@ -172,56 +179,20 @@ pub(crate) async fn stop_stream_handler( data.validate()?; let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.stop_stream(&data.chat_id).await?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.stop_stream(&chat_id).await?; Ok(()) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn refresh_local_ai_info_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let model_info = ai_manager.local_ai_controller.refresh_model_info().await; - if model_info.is_err() { - if let Some(llm_model) = ai_manager.local_ai_controller.get_current_model() { - let model_info = LLMModelInfo { - selected_model: llm_model.clone(), - models: vec![llm_model], - }; - return data_result_ok(model_info.into()); - } - } - data_result_ok(model_info?.into()) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn update_local_llm_model_handler( - data: AFPluginData, - ai_manager: AFPluginState>, -) -> DataResult { - let data = data.into_inner(); - let ai_manager = upgrade_ai_manager(ai_manager)?; - let state = ai_manager - .local_ai_controller - .select_local_llm(data.llm_id) - .await?; - data_result_ok(state) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_local_llm_state_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let state = ai_manager.local_ai_controller.get_local_llm_state().await?; - data_result_ok(state) -} - pub(crate) async fn start_complete_text_handler( data: AFPluginData, + ai_manager: AFPluginState>, tools: AFPluginState>, ) -> DataResult { - let task = tools.create_complete_task(data.into_inner()).await?; + let data = data.into_inner(); + let ai_manager = upgrade_ai_manager(ai_manager)?; + let ai_model = ai_manager.get_active_model(&data.object_id).await; + let task = tools.create_complete_task(data, ai_model).await?; data_result_ok(task) } @@ -279,113 +250,17 @@ pub(crate) async fn chat_file_handler( tracing::debug!("File size: {} bytes", file_size); let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.chat_with_file(&data.chat_id, file_path).await?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.chat_with_file(&chat_id, file_path).await?; Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn download_llm_resource_handler( - data: AFPluginData, - ai_manager: AFPluginState>, -) -> DataResult { - let data = data.into_inner(); - let ai_manager = upgrade_ai_manager(ai_manager)?; - let text_sink = IsolateSink::new(Isolate::new(data.progress_stream)); - let task_id = ai_manager - .local_ai_controller - .start_downloading(text_sink) - .await?; - data_result_ok(DownloadTaskPB { task_id }) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn cancel_download_llm_resource_handler( +pub(crate) async fn restart_local_ai_handler( ai_manager: AFPluginState>, ) -> Result<(), FlowyError> { let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.local_ai_controller.cancel_download()?; - Ok(()) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_plugin_state_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let state = ai_manager.local_ai_controller.get_chat_plugin_state(); - data_result_ok(state) -} -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn toggle_local_ai_chat_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let enabled = ai_manager - .local_ai_controller - .toggle_local_ai_chat() - .await?; - let file_enabled = ai_manager.local_ai_controller.is_rag_enabled(); - let plugin_state = ai_manager.local_ai_controller.get_chat_plugin_state(); - let pb = LocalAIChatPB { - enabled, - file_enabled, - plugin_state, - }; - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateLocalChatAI, - ) - .payload(pb.clone()) - .send(); - data_result_ok(pb) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn toggle_local_ai_chat_file_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let enabled = ai_manager.local_ai_controller.is_chat_enabled(); - let file_enabled = ai_manager - .local_ai_controller - .toggle_local_ai_chat_rag() - .await?; - let plugin_state = ai_manager.local_ai_controller.get_chat_plugin_state(); - let pb = LocalAIChatPB { - enabled, - file_enabled, - plugin_state, - }; - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateLocalChatAI, - ) - .payload(pb.clone()) - .send(); - - data_result_ok(pb) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_local_ai_chat_state_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let enabled = ai_manager.local_ai_controller.is_chat_enabled(); - let file_enabled = ai_manager.local_ai_controller.is_rag_enabled(); - let plugin_state = ai_manager.local_ai_controller.get_chat_plugin_state(); - data_result_ok(LocalAIChatPB { - enabled, - file_enabled, - plugin_state, - }) -} -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn restart_local_ai_chat_handler( - ai_manager: AFPluginState>, -) -> Result<(), FlowyError> { - let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager.local_ai_controller.restart_chat_plugin(); + ai_manager.local_ai.restart_plugin().await; Ok(()) } @@ -394,8 +269,9 @@ pub(crate) async fn toggle_local_ai_handler( ai_manager: AFPluginState>, ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; - let enabled = ai_manager.local_ai_controller.toggle_local_ai().await?; - data_result_ok(LocalAIPB { enabled }) + ai_manager.toggle_local_ai().await?; + let state = ai_manager.local_ai.get_local_ai_state().await; + data_result_ok(state) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -403,31 +279,8 @@ pub(crate) async fn get_local_ai_state_handler( ai_manager: AFPluginState>, ) -> DataResult { let ai_manager = upgrade_ai_manager(ai_manager)?; - let enabled = ai_manager.local_ai_controller.is_enabled(); - data_result_ok(LocalAIPB { enabled }) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_model_storage_directory_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let file_path = ai_manager - .local_ai_controller - .get_model_storage_directory()?; - data_result_ok(LocalModelStoragePB { file_path }) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_offline_app_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let link = ai_manager - .local_ai_controller - .get_offline_ai_app_download_link() - .await?; - data_result_ok(OfflineAIPB { link }) + let state = ai_manager.local_ai.get_local_ai_state().await; + data_result_ok(state) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -457,6 +310,7 @@ pub(crate) async fn get_chat_settings_handler( ai_manager: AFPluginState>, ) -> DataResult { let chat_id = data.try_into_inner()?.value; + let chat_id = Uuid::from_str(&chat_id)?; let ai_manager = upgrade_ai_manager(ai_manager)?; let rag_ids = ai_manager.get_rag_ids(&chat_id).await?; let pb = ChatSettingsPB { rag_ids }; @@ -470,9 +324,29 @@ pub(crate) async fn update_chat_settings_handler( ) -> FlowyResult<()> { let params = data.try_into_inner()?; let ai_manager = upgrade_ai_manager(ai_manager)?; - ai_manager - .update_rag_ids(¶ms.chat_id.value, params.rag_ids) - .await?; + let chat_id = Uuid::from_str(¶ms.chat_id.value)?; + ai_manager.update_rag_ids(&chat_id, params.rag_ids).await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all)] +pub(crate) async fn get_local_ai_setting_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let setting = ai_manager.local_ai.get_local_ai_setting(); + let pb = LocalAISettingPB::from(setting); + data_result_ok(pb) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_local_ai_setting_handler( + ai_manager: AFPluginState>, + data: AFPluginData, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager.update_local_ai_setting(data.into()).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index c27e94c334..5020836a30 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -10,8 +10,9 @@ use crate::ai_manager::AIManager; use crate::event_handler::*; pub fn init(ai_manager: Weak) -> AFPlugin { - let user_service = Arc::downgrade(&ai_manager.upgrade().unwrap().user_service); - let cloud_service = Arc::downgrade(&ai_manager.upgrade().unwrap().cloud_service_wm); + let strong_ai_manager = ai_manager.upgrade().unwrap(); + let user_service = Arc::downgrade(&strong_ai_manager.user_service); + let cloud_service = Arc::downgrade(&strong_ai_manager.cloud_service_wm); let ai_tools = Arc::new(AICompletion::new(cloud_service, user_service)); AFPlugin::new() .name("flowy-ai") @@ -23,43 +24,28 @@ pub fn init(ai_manager: Weak) -> AFPlugin { .event(AIEvent::GetRelatedQuestion, get_related_question_handler) .event(AIEvent::GetAnswerForQuestion, get_answer_handler) .event(AIEvent::StopStream, stop_stream_handler) - .event( - AIEvent::RefreshLocalAIModelInfo, - refresh_local_ai_info_handler, - ) - .event(AIEvent::UpdateLocalLLM, update_local_llm_model_handler) - .event(AIEvent::GetLocalLLMState, get_local_llm_state_handler) .event(AIEvent::CompleteText, start_complete_text_handler) .event(AIEvent::StopCompleteText, stop_complete_text_handler) .event(AIEvent::ChatWithFile, chat_file_handler) - .event(AIEvent::DownloadLLMResource, download_llm_resource_handler) - .event( - AIEvent::CancelDownloadLLMResource, - cancel_download_llm_resource_handler, - ) - .event(AIEvent::GetLocalAIPluginState, get_plugin_state_handler) - .event(AIEvent::ToggleLocalAIChat, toggle_local_ai_chat_handler) - .event( - AIEvent::GetLocalAIChatState, - get_local_ai_chat_state_handler, - ) - .event(AIEvent::RestartLocalAIChat, restart_local_ai_chat_handler) + .event(AIEvent::RestartLocalAI, restart_local_ai_handler) .event(AIEvent::ToggleLocalAI, toggle_local_ai_handler) .event(AIEvent::GetLocalAIState, get_local_ai_state_handler) + .event(AIEvent::GetLocalAISetting, get_local_ai_setting_handler) .event( - AIEvent::ToggleChatWithFile, - toggle_local_ai_chat_file_handler, + AIEvent::UpdateLocalAISetting, + update_local_ai_setting_handler, ) .event( - AIEvent::GetModelStorageDirectory, - get_model_storage_directory_handler, + AIEvent::GetServerAvailableModels, + get_server_model_list_handler, ) - .event(AIEvent::GetOfflineAIAppLink, get_offline_app_handler) .event(AIEvent::CreateChatContext, create_chat_context_handler) .event(AIEvent::GetChatInfo, create_chat_context_handler) .event(AIEvent::GetChatSettings, get_chat_settings_handler) .event(AIEvent::UpdateChatSettings, update_chat_settings_handler) .event(AIEvent::RegenerateResponse, regenerate_response_handler) + .event(AIEvent::GetAvailableModels, get_chat_models_handler) + .event(AIEvent::UpdateSelectedModel, update_selected_model_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -84,15 +70,6 @@ pub enum AIEvent { #[event(input = "ChatMessageIdPB", output = "ChatMessagePB")] GetAnswerForQuestion = 5, - #[event(input = "LLMModelPB", output = "LocalModelResourcePB")] - UpdateLocalLLM = 6, - - #[event(output = "LocalModelResourcePB")] - GetLocalLLMState = 7, - - #[event(output = "LLMModelInfoPB")] - RefreshLocalAIModelInfo = 8, - #[event(input = "CompleteTextPB", output = "CompleteTextTaskPB")] CompleteText = 9, @@ -102,26 +79,10 @@ pub enum AIEvent { #[event(input = "ChatFilePB")] ChatWithFile = 11, - #[event(input = "DownloadLLMPB", output = "DownloadTaskPB")] - DownloadLLMResource = 12, - - #[event()] - CancelDownloadLLMResource = 13, - - #[event(output = "LocalAIPluginStatePB")] - GetLocalAIPluginState = 14, - - #[event(output = "LocalAIChatPB")] - ToggleLocalAIChat = 15, - - /// Return Local AI Chat State - #[event(output = "LocalAIChatPB")] - GetLocalAIChatState = 16, - /// Restart local AI chat. When plugin quit or user terminate in task manager or activity monitor, /// the plugin will need to restart. #[event()] - RestartLocalAIChat = 17, + RestartLocalAI = 17, /// Enable or disable local AI #[event(output = "LocalAIPB")] @@ -131,15 +92,6 @@ pub enum AIEvent { #[event(output = "LocalAIPB")] GetLocalAIState = 19, - #[event()] - ToggleChatWithFile = 20, - - #[event(output = "LocalModelStoragePB")] - GetModelStorageDirectory = 21, - - #[event(output = "OfflineAIPB")] - GetOfflineAIAppLink = 22, - #[event(input = "CreateChatContextPB")] CreateChatContext = 23, @@ -154,4 +106,19 @@ pub enum AIEvent { #[event(input = "RegenerateResponsePB")] RegenerateResponse = 27, + + #[event(output = "AvailableModelsPB")] + GetServerAvailableModels = 28, + + #[event(output = "LocalAISettingPB")] + GetLocalAISetting = 29, + + #[event(input = "LocalAISettingPB")] + UpdateLocalAISetting = 30, + + #[event(input = "AvailableModelsQueryPB", output = "AvailableModelsPB")] + GetAvailableModels = 31, + + #[event(input = "UpdateSelectedModelPB")] + UpdateSelectedModel = 32, } diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs index be6c743d86..5b582b2577 100644 --- a/frontend/rust-lib/flowy-ai/src/lib.rs +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -5,9 +5,14 @@ pub mod ai_manager; mod chat; mod completion; pub mod entities; -mod local_ai; +pub mod local_ai; + +// #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +// pub mod mcp; + mod middleware; pub mod notification; -mod persistence; +pub mod offline; mod protobuf; mod stream_message; +mod util; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs new file mode 100644 index 0000000000..b9dc7a73c1 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -0,0 +1,623 @@ +use crate::ai_manager::AIUserService; +use crate::entities::{LocalAIPB, RunningStatePB}; +use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; +use crate::notification::{ + chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, +}; +use af_plugin::manager::PluginManager; +use anyhow::Error; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; +use futures::Sink; +use lib_infra::async_trait::async_trait; +use std::collections::HashMap; + +use crate::stream_message::StreamMessage; +use af_local_ai::ollama_plugin::OllamaAIPlugin; +use af_plugin::core::path::is_plugin_ready; +use af_plugin::core::plugin::RunningState; +use arc_swap::ArcSwapOption; +use futures_util::SinkExt; +use lib_infra::util::get_operating_system; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use tokio::select; +use tokio_stream::StreamExt; +use tracing::{debug, error, info, instrument, warn}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LocalAISetting { + pub ollama_server_url: String, + pub chat_model_name: String, + pub embedding_model_name: String, +} + +impl Default for LocalAISetting { + fn default() -> Self { + Self { + ollama_server_url: "http://localhost:11434".to_string(), + chat_model_name: "llama3.1".to_string(), + embedding_model_name: "nomic-embed-text".to_string(), + } + } +} + +const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v1"; + +pub struct LocalAIController { + ai_plugin: Arc, + resource: Arc, + current_chat_id: ArcSwapOption, + store_preferences: Weak, + user_service: Arc, +} + +impl Deref for LocalAIController { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.ai_plugin + } +} + +impl LocalAIController { + pub fn new( + plugin_manager: Arc, + store_preferences: Weak, + user_service: Arc, + ) -> Self { + debug!( + "[AI Plugin] init local ai controller, thread: {:?}", + std::thread::current().id() + ); + + // Create the core plugin and resource controller + let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); + let res_impl = LLMResourceServiceImpl { + store_preferences: store_preferences.clone(), + }; + let local_ai_resource = Arc::new(LocalAIResourceController::new( + user_service.clone(), + res_impl, + )); + // Subscribe to state changes + let mut running_state_rx = local_ai.subscribe_running_state(); + + let cloned_llm_res = Arc::clone(&local_ai_resource); + let cloned_store_preferences = store_preferences.clone(); + let cloned_local_ai = Arc::clone(&local_ai); + let cloned_user_service = Arc::clone(&user_service); + + // Spawn a background task to listen for plugin state changes + tokio::spawn(async move { + while let Some(state) = running_state_rx.next().await { + // Skip if we can’t get workspace_id + let Ok(workspace_id) = cloned_user_service.workspace_id() else { + continue; + }; + + let key = local_ai_enabled_key(&workspace_id); + info!("[AI Plugin] state: {:?}", state); + + // Read whether plugin is enabled from store; default to true + if let Some(store_preferences) = cloned_store_preferences.upgrade() { + let enabled = store_preferences.get_bool(&key).unwrap_or(true); + // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled + let (plugin_downloaded, lack_of_resource) = + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + // Possibly check plugin readiness and resource concurrency in parallel, + // but here we do it sequentially for clarity. + let downloaded = is_plugin_ready(); + let resource_lack = cloned_llm_res.get_lack_of_resource().await; + (downloaded, resource_lack) + } else { + (false, None) + }; + + // If plugin is running, retrieve version + let plugin_version = if matches!(state, RunningState::Running { .. }) { + match cloned_local_ai.plugin_info().await { + Ok(info) => Some(info.version), + Err(_) => None, + } + } else { + None + }; + + // Broadcast the new local AI state + let new_state = RunningStatePB::from(state); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded, + lack_of_resource, + state: new_state, + plugin_version, + }) + .send(); + } else { + warn!("[AI Plugin] store preferences is dropped"); + } + } + }); + + Self { + ai_plugin: local_ai, + resource: local_ai_resource, + current_chat_id: ArcSwapOption::default(), + store_preferences, + user_service, + } + } + #[instrument(level = "debug", skip_all)] + pub async fn observe_plugin_resource(&self) { + debug!( + "[AI Plugin] init plugin when first run. thread: {:?}", + std::thread::current().id() + ); + let sys = get_operating_system(); + if !sys.is_desktop() { + return; + } + async fn try_init_plugin( + resource: &Arc, + ai_plugin: &Arc, + ) { + if let Err(err) = initialize_ai_plugin(ai_plugin, resource, None).await { + error!("[AI Plugin] failed to setup plugin: {:?}", err); + } + } + + // Clone what is needed for the background task. + let resource_clone = self.resource.clone(); + let ai_plugin_clone = self.ai_plugin.clone(); + let mut resource_notify = self.resource.subscribe_resource_notify(); + let mut app_state_watcher = self.resource.subscribe_app_state(); + tokio::spawn(async move { + loop { + select! { + _ = app_state_watcher.recv() => { + info!("[AI Plugin] app state changed, try to init plugin"); + try_init_plugin(&resource_clone, &ai_plugin_clone).await; + }, + _ = resource_notify.recv() => { + info!("[AI Plugin] resource changed, try to init plugin"); + try_init_plugin(&resource_clone, &ai_plugin_clone).await; + }, + else => break, + } + } + }); + } + + pub async fn reload(&self) -> FlowyResult<()> { + let is_enabled = self.is_enabled(); + self.toggle_plugin(is_enabled).await?; + Ok(()) + } + + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } + + /// Indicate whether the local AI plugin is running. + pub fn is_running(&self) -> bool { + if !self.is_enabled() { + return false; + } + self.ai_plugin.get_plugin_running_state().is_running() + } + + /// Indicate whether the local AI is enabled. + /// AppFlowy store the value in local storage isolated by workspace id. Each workspace can have + /// different settings. + pub fn is_enabled(&self) -> bool { + if !get_operating_system().is_desktop() { + return false; + } + + if let Ok(key) = self + .user_service + .workspace_id() + .map(|workspace_id| local_ai_enabled_key(&workspace_id)) + { + match self.upgrade_store_preferences() { + Ok(store) => store.get_bool(&key).unwrap_or(false), + Err(_) => false, + } + } else { + false + } + } + + pub fn get_plugin_chat_model(&self) -> Option { + if !self.is_enabled() { + return None; + } + Some(self.resource.get_llm_setting().chat_model_name) + } + + pub fn open_chat(&self, chat_id: &Uuid) { + if !self.is_enabled() { + return; + } + + // Only keep one chat open at a time. Since loading multiple models at the same time will cause + // memory issues. + if let Some(current_chat_id) = self.current_chat_id.load().as_ref() { + debug!("[AI Plugin] close previous chat: {}", current_chat_id); + self.close_chat(current_chat_id); + } + + self.current_chat_id.store(Some(Arc::new(*chat_id))); + let chat_id = chat_id.to_string(); + let weak_ctrl = Arc::downgrade(&self.ai_plugin); + tokio::spawn(async move { + if let Some(ctrl) = weak_ctrl.upgrade() { + if let Err(err) = ctrl.create_chat(&chat_id).await { + error!("[AI Plugin] failed to open chat: {:?}", err); + } + } + }); + } + + pub fn close_chat(&self, chat_id: &Uuid) { + if !self.is_running() { + return; + } + info!("[AI Plugin] notify close chat: {}", chat_id); + let weak_ctrl = Arc::downgrade(&self.ai_plugin); + let chat_id = chat_id.to_string(); + tokio::spawn(async move { + if let Some(ctrl) = weak_ctrl.upgrade() { + if let Err(err) = ctrl.close_chat(&chat_id).await { + error!("[AI Plugin] failed to close chat: {:?}", err); + } + } + }); + } + + pub fn get_local_ai_setting(&self) -> LocalAISetting { + self.resource.get_llm_setting() + } + + pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + info!( + "[AI Plugin] update local ai setting: {:?}, thread: {:?}", + setting, + std::thread::current().id() + ); + + if self.resource.set_llm_setting(setting).await.is_ok() { + self.reload().await?; + } + Ok(()) + } + + #[instrument(level = "debug", skip_all)] + pub async fn get_local_ai_state(&self) -> LocalAIPB { + let start = std::time::Instant::now(); + let enabled = self.is_enabled(); + + // If not enabled, return immediately. + if !enabled { + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + start.elapsed(), + std::thread::current().id() + ); + return LocalAIPB { + enabled: false, + plugin_downloaded: false, + state: RunningStatePB::from(RunningState::ReadyToConnect), + lack_of_resource: None, + plugin_version: None, + }; + } + + let plugin_downloaded = is_plugin_ready(); + let state = self.ai_plugin.get_plugin_running_state(); + + // If the plugin is running, run both requests in parallel. + // Otherwise, only fetch the resource info. + let (plugin_version, lack_of_resource) = if matches!(state, RunningState::Running { .. }) { + // Launch both futures at once + let plugin_info_fut = self.ai_plugin.plugin_info(); + let resource_fut = self.resource.get_lack_of_resource(); + + let (plugin_info_res, resource_res) = tokio::join!(plugin_info_fut, resource_fut); + let plugin_version = plugin_info_res.ok().map(|info| info.version); + (plugin_version, resource_res) + } else { + let resource_res = self.resource.get_lack_of_resource().await; + (None, resource_res) + }; + + let elapsed = start.elapsed(); + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + elapsed, + std::thread::current().id() + ); + + LocalAIPB { + enabled, + plugin_downloaded, + state: RunningStatePB::from(state), + lack_of_resource, + plugin_version, + } + } + #[instrument(level = "debug", skip_all)] + pub async fn restart_plugin(&self) { + if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, None).await { + error!("[AI Plugin] failed to setup plugin: {:?}", err); + } + } + + pub fn get_model_storage_directory(&self) -> FlowyResult { + self + .resource + .user_model_folder() + .map(|path| path.to_string_lossy().to_string()) + } + + pub async fn toggle_local_ai(&self) -> FlowyResult { + let workspace_id = self.user_service.workspace_id()?; + let key = local_ai_enabled_key(&workspace_id); + let store_preferences = self.upgrade_store_preferences()?; + let enabled = !store_preferences.get_bool(&key).unwrap_or(true); + store_preferences.set_bool(&key, enabled)?; + self.toggle_plugin(enabled).await?; + Ok(enabled) + } + + // #[instrument(level = "debug", skip_all)] + // pub async fn index_message_metadata( + // &self, + // chat_id: &Uuid, + // metadata_list: &[ChatMessageMetadata], + // index_process_sink: &mut (impl Sink + Unpin), + // ) -> FlowyResult<()> { + // if !self.is_enabled() { + // info!("[AI Plugin] local ai is disabled, skip indexing"); + // return Ok(()); + // } + // + // for metadata in metadata_list { + // let mut file_metadata = HashMap::new(); + // file_metadata.insert("id".to_string(), json!(&metadata.id)); + // file_metadata.insert("name".to_string(), json!(&metadata.name)); + // file_metadata.insert("source".to_string(), json!(&metadata.source)); + // + // let file_path = Path::new(&metadata.data.content); + // if !file_path.exists() { + // return Err( + // FlowyError::record_not_found().with_context(format!("File not found: {:?}", file_path)), + // ); + // } + // info!( + // "[AI Plugin] embed file: {:?}, with metadata: {:?}", + // file_path, file_metadata + // ); + // + // match &metadata.data.content_type { + // ContextLoader::Unknown => { + // error!( + // "[AI Plugin] unsupported content type: {:?}", + // metadata.data.content_type + // ); + // }, + // ContextLoader::Text | ContextLoader::Markdown | ContextLoader::PDF => { + // self + // .process_index_file( + // chat_id, + // file_path.to_path_buf(), + // &file_metadata, + // index_process_sink, + // ) + // .await?; + // }, + // } + // } + // + // Ok(()) + // } + + #[allow(dead_code)] + async fn process_index_file( + &self, + chat_id: &Uuid, + file_path: PathBuf, + index_metadata: &HashMap, + index_process_sink: &mut (impl Sink + Unpin), + ) -> Result<(), FlowyError> { + let file_name = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let _ = index_process_sink + .send( + StreamMessage::StartIndexFile { + file_name: file_name.clone(), + } + .to_string(), + ) + .await; + + let result = self + .ai_plugin + .embed_file( + &chat_id.to_string(), + file_path, + Some(index_metadata.clone()), + ) + .await; + match result { + Ok(_) => { + let _ = index_process_sink + .send(StreamMessage::EndIndexFile { file_name }.to_string()) + .await; + }, + Err(err) => { + let _ = index_process_sink + .send(StreamMessage::IndexFileError { file_name }.to_string()) + .await; + error!("[AI Plugin] failed to index file: {:?}", err); + }, + } + + Ok(()) + } + + #[instrument(level = "debug", skip_all)] + async fn toggle_plugin(&self, enabled: bool) -> FlowyResult<()> { + info!( + "[AI Plugin] enable: {}, thread id: {:?}", + enabled, + std::thread::current().id() + ); + if enabled { + let (tx, rx) = tokio::sync::oneshot::channel(); + if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, Some(tx)).await { + error!("[AI Plugin] failed to initialize local ai: {:?}", err); + } + let _ = rx.await; + } else { + if let Err(err) = self.ai_plugin.destroy_plugin().await { + error!("[AI Plugin] failed to destroy plugin: {:?}", err); + } + + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded: true, + state: RunningStatePB::Stopped, + lack_of_resource: None, + plugin_version: None, + }) + .send(); + } + Ok(()) + } +} + +#[instrument(level = "debug", skip_all, err)] +async fn initialize_ai_plugin( + plugin: &Arc, + llm_resource: &Arc, + ret: Option>, +) -> FlowyResult<()> { + let lack_of_resource = llm_resource.get_lack_of_resource().await; + + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled: true, + plugin_downloaded: true, + state: RunningStatePB::ReadyToRun, + lack_of_resource: lack_of_resource.clone(), + plugin_version: None, + }) + .send(); + + if let Some(lack_of_resource) = lack_of_resource { + info!( + "[AI Plugin] lack of resource: {:?} to initialize plugin, thread: {:?}", + lack_of_resource, + std::thread::current().id() + ); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::LocalAIResourceUpdated, + ) + .payload(lack_of_resource) + .send(); + + return Ok(()); + } + + if let Err(err) = plugin.destroy_plugin().await { + error!( + "[AI Plugin] failed to destroy plugin when lack of resource: {:?}", + err + ); + } + + let plugin = plugin.clone(); + let cloned_llm_res = llm_resource.clone(); + tokio::task::spawn_blocking(move || { + futures::executor::block_on(async move { + match cloned_llm_res.get_plugin_config(true).await { + Ok(config) => { + info!( + "[AI Plugin] initialize plugin with config: {:?}, thread: {:?}", + config, + std::thread::current().id() + ); + + match plugin.init_plugin(config).await { + Ok(_) => {}, + Err(err) => error!("[AI Plugin] failed to setup plugin: {:?}", err), + } + + if let Some(ret) = ret { + let _ = ret.send(()); + } + }, + Err(err) => { + error!("[AI Plugin] failed to get plugin config: {:?}", err); + }, + }; + }) + }); + + Ok(()) +} + +pub struct LLMResourceServiceImpl { + store_preferences: Weak, +} + +impl LLMResourceServiceImpl { + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } +} +#[async_trait] +impl LLMResourceService for LLMResourceServiceImpl { + fn store_setting(&self, setting: LocalAISetting) -> Result<(), Error> { + let store_preferences = self.upgrade_store_preferences()?; + store_preferences.set_object(LOCAL_AI_SETTING_KEY, &setting)?; + Ok(()) + } + + fn retrieve_setting(&self) -> Option { + let store_preferences = self.upgrade_store_preferences().ok()?; + store_preferences.get_object::(LOCAL_AI_SETTING_KEY) + } +} + +const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled"; +fn local_ai_enabled_key(workspace_id: &Uuid) -> String { + format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id) +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_chat.rs b/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_chat.rs deleted file mode 100644 index ece0bdddda..0000000000 --- a/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_chat.rs +++ /dev/null @@ -1,599 +0,0 @@ -use crate::ai_manager::AIUserService; -use crate::entities::{LocalAIPluginStatePB, LocalModelResourcePB, RunningStatePB}; -use crate::local_ai::local_llm_resource::{LLMResourceService, LocalAIResourceController}; -use crate::notification::{ - chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, -}; -use anyhow::Error; -use appflowy_local_ai::chat_plugin::{AIPluginConfig, AppFlowyLocalAI}; -use appflowy_plugin::manager::PluginManager; -use appflowy_plugin::util::is_apple_silicon; -use flowy_ai_pub::cloud::{ - AppFlowyOfflineAI, ChatCloudService, ChatMessageMetadata, ContextLoader, LLMModel, LocalAIConfig, - SubscriptionPlan, -}; -use flowy_error::{FlowyError, FlowyResult}; -use flowy_sqlite::kv::KVStorePreferences; -use futures::Sink; -use lib_infra::async_trait::async_trait; -use std::collections::HashMap; - -use crate::stream_message::StreamMessage; -use arc_swap::ArcSwapOption; -use futures_util::SinkExt; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::select; -use tokio_stream::StreamExt; -use tracing::{debug, error, info, instrument, trace, warn}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct LLMSetting { - pub app: AppFlowyOfflineAI, - pub llm_model: LLMModel, -} - -pub struct LLMModelInfo { - pub selected_model: LLMModel, - pub models: Vec, -} - -const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled"; -const APPFLOWY_LOCAL_AI_CHAT_ENABLED: &str = "appflowy_local_ai_chat_enabled"; -const APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED: &str = "appflowy_local_ai_chat_rag_enabled"; -const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v0"; - -pub struct LocalAIController { - local_ai: Arc, - local_ai_resource: Arc, - current_chat_id: ArcSwapOption, - store_preferences: Arc, - user_service: Arc, - cloud_service: Arc, -} - -impl Deref for LocalAIController { - type Target = Arc; - - fn deref(&self) -> &Self::Target { - &self.local_ai - } -} - -impl LocalAIController { - pub fn new( - plugin_manager: Arc, - store_preferences: Arc, - user_service: Arc, - cloud_service: Arc, - ) -> Self { - let local_ai = Arc::new(AppFlowyLocalAI::new(plugin_manager)); - let res_impl = LLMResourceServiceImpl { - user_service: user_service.clone(), - cloud_service: cloud_service.clone(), - store_preferences: store_preferences.clone(), - }; - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - let llm_res = Arc::new(LocalAIResourceController::new( - user_service.clone(), - res_impl, - tx, - )); - let current_chat_id = ArcSwapOption::default(); - - let mut running_state_rx = local_ai.subscribe_running_state(); - let cloned_llm_res = llm_res.clone(); - tokio::spawn(async move { - while let Some(state) = running_state_rx.next().await { - info!("[AI Plugin] state: {:?}", state); - let offline_ai_ready = cloned_llm_res.is_offline_app_ready(); - let new_state = RunningStatePB::from(state); - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateChatPluginState, - ) - .payload(LocalAIPluginStatePB { - state: new_state, - offline_ai_ready, - }) - .send(); - } - }); - - let this = Self { - local_ai, - local_ai_resource: llm_res, - current_chat_id, - store_preferences, - user_service, - cloud_service, - }; - - let rag_enabled = this.is_rag_enabled(); - let cloned_llm_chat = this.local_ai.clone(); - let cloned_llm_res = this.local_ai_resource.clone(); - let mut offline_ai_watch = this.local_ai_resource.subscribe_offline_app_state(); - tokio::spawn(async move { - let init_fn = || { - if let Ok(chat_config) = cloned_llm_res.get_chat_config(rag_enabled) { - if let Err(err) = initialize_ai_plugin(&cloned_llm_chat, chat_config, None) { - error!("[AI Plugin] failed to setup plugin: {:?}", err); - } - } - }; - - loop { - select! { - _ = offline_ai_watch.recv() => { - init_fn(); - }, - _ = rx.recv() => { - init_fn(); - }, - else => { break; } - } - } - }); - - if this.can_init_plugin() { - let result = this - .local_ai_resource - .get_chat_config(this.is_rag_enabled()); - if let Ok(chat_config) = result { - if let Err(err) = initialize_ai_plugin(&this.local_ai, chat_config, None) { - error!("[AI Plugin] failed to setup plugin: {:?}", err); - } - } - } - - this - } - pub async fn refresh(&self) -> FlowyResult<()> { - let is_enabled = self.is_enabled(); - self.enable_chat_plugin(is_enabled).await?; - - if is_enabled { - let local_ai = self.local_ai.clone(); - let workspace_id = self.user_service.workspace_id()?; - let cloned_service = self.cloud_service.clone(); - let store_preferences = self.store_preferences.clone(); - tokio::spawn(async move { - let key = local_ai_enabled_key(&workspace_id); - match cloned_service.get_workspace_plan(&workspace_id).await { - Ok(plans) => { - trace!("[AI Plugin] workspace:{} plans: {:?}", workspace_id, plans); - if !plans.contains(&SubscriptionPlan::AiLocal) { - info!( - "disable local ai plugin for workspace: {}. reason: no plan found", - workspace_id - ); - let _ = store_preferences.set_bool(&key, false); - let _ = local_ai.destroy_chat_plugin().await; - } - }, - Err(err) => { - warn!("[AI Plugin]: failed to get workspace plan: {:?}", err); - }, - } - }); - } - - Ok(()) - } - - pub async fn refresh_model_info(&self) -> FlowyResult { - self.local_ai_resource.refresh_llm_resource().await - } - - /// Returns true if the local AI is enabled and ready to use. - pub fn can_init_plugin(&self) -> bool { - self.is_enabled() && self.local_ai_resource.is_resource_ready() - } - - /// Indicate whether the local AI plugin is running. - pub fn is_running(&self) -> bool { - if !self.is_enabled() { - return false; - } - self.local_ai.get_plugin_running_state().is_ready() - } - - /// Indicate whether the local AI is enabled. - /// AppFlowy store the value in local storage isolated by workspace id. Each workspace can have - /// different settings. - pub fn is_enabled(&self) -> bool { - if let Ok(key) = self - .user_service - .workspace_id() - .map(|workspace_id| local_ai_enabled_key(&workspace_id)) - { - self.store_preferences.get_bool(&key).unwrap_or(true) - } else { - false - } - } - - /// Indicate whether the local AI chat is enabled. In the future, we can support multiple - /// AI plugin. - pub fn is_chat_enabled(&self) -> bool { - self - .store_preferences - .get_bool(APPFLOWY_LOCAL_AI_CHAT_ENABLED) - .unwrap_or(true) - } - - pub fn is_rag_enabled(&self) -> bool { - self - .store_preferences - .get_bool(APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED) - .unwrap_or(true) - } - - pub fn open_chat(&self, chat_id: &str) { - if !self.is_enabled() { - return; - } - - // Only keep one chat open at a time. Since loading multiple models at the same time will cause - // memory issues. - if let Some(current_chat_id) = self.current_chat_id.load().as_ref() { - debug!("[AI Plugin] close previous chat: {}", current_chat_id); - self.close_chat(current_chat_id); - } - - self - .current_chat_id - .store(Some(Arc::new(chat_id.to_string()))); - let chat_id = chat_id.to_string(); - let weak_ctrl = Arc::downgrade(&self.local_ai); - tokio::spawn(async move { - if let Some(ctrl) = weak_ctrl.upgrade() { - if let Err(err) = ctrl.create_chat(&chat_id).await { - error!("[AI Plugin] failed to open chat: {:?}", err); - } - } - }); - } - - pub fn close_chat(&self, chat_id: &str) { - if !self.is_running() { - return; - } - info!("[AI Plugin] notify close chat: {}", chat_id); - let weak_ctrl = Arc::downgrade(&self.local_ai); - let chat_id = chat_id.to_string(); - tokio::spawn(async move { - if let Some(ctrl) = weak_ctrl.upgrade() { - if let Err(err) = ctrl.close_chat(&chat_id).await { - error!("[AI Plugin] failed to close chat: {:?}", err); - } - } - }); - } - - pub async fn select_local_llm(&self, llm_id: i64) -> FlowyResult { - if !self.is_enabled() { - return Err(FlowyError::local_ai_unavailable()); - } - - if let Some(model) = self.local_ai_resource.get_selected_model() { - if model.llm_id == llm_id { - return self.local_ai_resource.get_local_llm_state(); - } - } - - let state = self.local_ai_resource.use_local_llm(llm_id)?; - // Re-initialize the plugin if the setting is updated and ready to use - if self.local_ai_resource.is_resource_ready() { - let chat_config = self - .local_ai_resource - .get_chat_config(self.is_rag_enabled())?; - if let Err(err) = initialize_ai_plugin(&self.local_ai, chat_config, None) { - error!("failed to setup plugin: {:?}", err); - } - } - Ok(state) - } - - pub async fn get_local_llm_state(&self) -> FlowyResult { - self.local_ai_resource.get_local_llm_state() - } - - pub fn get_current_model(&self) -> Option { - self.local_ai_resource.get_selected_model() - } - - pub async fn start_downloading(&self, progress_sink: T) -> FlowyResult - where - T: Sink + Unpin + Sync + Send + 'static, - { - let task_id = self - .local_ai_resource - .start_downloading(progress_sink) - .await?; - Ok(task_id) - } - - pub fn cancel_download(&self) -> FlowyResult<()> { - self.local_ai_resource.cancel_download()?; - Ok(()) - } - - pub fn get_chat_plugin_state(&self) -> LocalAIPluginStatePB { - if !self.is_enabled() { - return LocalAIPluginStatePB { - state: RunningStatePB::Stopped, - offline_ai_ready: false, - }; - } - - let offline_ai_ready = self.local_ai_resource.is_offline_app_ready(); - let state = self.local_ai.get_plugin_running_state(); - LocalAIPluginStatePB { - state: RunningStatePB::from(state), - offline_ai_ready, - } - } - - pub fn restart_chat_plugin(&self) { - let rag_enabled = self.is_rag_enabled(); - if let Ok(chat_config) = self.local_ai_resource.get_chat_config(rag_enabled) { - if let Err(err) = initialize_ai_plugin(&self.local_ai, chat_config, None) { - error!("[AI Plugin] failed to setup plugin: {:?}", err); - } - } - } - - pub fn get_model_storage_directory(&self) -> FlowyResult { - self - .local_ai_resource - .user_model_folder() - .map(|path| path.to_string_lossy().to_string()) - } - - pub async fn get_offline_ai_app_download_link(&self) -> FlowyResult { - self - .local_ai_resource - .get_offline_ai_app_download_link() - .await - } - - pub async fn toggle_local_ai(&self) -> FlowyResult { - let workspace_id = self.user_service.workspace_id()?; - let key = local_ai_enabled_key(&workspace_id); - let enabled = !self.store_preferences.get_bool(&key).unwrap_or(true); - self.store_preferences.set_bool(&key, enabled)?; - - // when enable local ai. we need to check if chat is enabled, if enabled, we need to init chat plugin - // otherwise, we need to destroy the plugin - if enabled { - let chat_enabled = self - .store_preferences - .get_bool(APPFLOWY_LOCAL_AI_CHAT_ENABLED) - .unwrap_or(true); - - if self.local_ai_resource.is_resource_ready() { - self.enable_chat_plugin(chat_enabled).await?; - } - } else { - let _ = self.enable_chat_plugin(false).await; - } - Ok(enabled) - } - - pub async fn toggle_local_ai_chat(&self) -> FlowyResult { - let enabled = !self - .store_preferences - .get_bool(APPFLOWY_LOCAL_AI_CHAT_ENABLED) - .unwrap_or(true); - self - .store_preferences - .set_bool(APPFLOWY_LOCAL_AI_CHAT_ENABLED, enabled)?; - self.enable_chat_plugin(enabled).await?; - - Ok(enabled) - } - - pub async fn toggle_local_ai_chat_rag(&self) -> FlowyResult { - let enabled = !self - .store_preferences - .get_bool_or_default(APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED); - self - .store_preferences - .set_bool(APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED, enabled)?; - Ok(enabled) - } - pub async fn index_message_metadata( - &self, - chat_id: &str, - metadata_list: &[ChatMessageMetadata], - index_process_sink: &mut (impl Sink + Unpin), - ) -> FlowyResult<()> { - if !self.is_enabled() { - return Ok(()); - } - - for metadata in metadata_list { - if let Err(err) = metadata.data.validate() { - error!( - "[AI Plugin] invalid metadata: {:?}, error: {:?}", - metadata, err - ); - continue; - } - - let mut index_metadata = HashMap::new(); - index_metadata.insert("id".to_string(), json!(&metadata.id)); - index_metadata.insert("name".to_string(), json!(&metadata.name)); - index_metadata.insert("at_name".to_string(), json!(format!("@{}", &metadata.name))); - index_metadata.insert("source".to_string(), json!(&metadata.source)); - match &metadata.data.content_type { - ContextLoader::Unknown => { - error!( - "[AI Plugin] unsupported content type: {:?}", - metadata.data.content_type - ); - }, - ContextLoader::Text | ContextLoader::Markdown => { - trace!("[AI Plugin]: index text: {}", metadata.data.content); - self - .process_index_file( - chat_id, - None, - Some(metadata.data.content.clone()), - metadata, - &index_metadata, - index_process_sink, - ) - .await?; - }, - ContextLoader::PDF => { - trace!("[AI Plugin]: index pdf file: {}", metadata.data.content); - let file_path = Path::new(&metadata.data.content); - if file_path.exists() { - self - .process_index_file( - chat_id, - Some(file_path.to_path_buf()), - None, - metadata, - &index_metadata, - index_process_sink, - ) - .await?; - } - }, - } - } - - Ok(()) - } - - async fn process_index_file( - &self, - chat_id: &str, - file_path: Option, - content: Option, - metadata: &ChatMessageMetadata, - index_metadata: &HashMap, - index_process_sink: &mut (impl Sink + Unpin), - ) -> Result<(), FlowyError> { - let _ = index_process_sink - .send( - StreamMessage::StartIndexFile { - file_name: metadata.name.clone(), - } - .to_string(), - ) - .await; - - let result = self - .index_file(chat_id, file_path, content, Some(index_metadata.clone())) - .await; - match result { - Ok(_) => { - let _ = index_process_sink - .send( - StreamMessage::EndIndexFile { - file_name: metadata.name.clone(), - } - .to_string(), - ) - .await; - }, - Err(err) => { - let _ = index_process_sink - .send( - StreamMessage::IndexFileError { - file_name: metadata.name.clone(), - } - .to_string(), - ) - .await; - error!("[AI Plugin] failed to index file: {:?}", err); - }, - } - - Ok(()) - } - - async fn enable_chat_plugin(&self, enabled: bool) -> FlowyResult<()> { - info!("[AI Plugin] enable chat plugin: {}", enabled); - if enabled { - let (tx, rx) = tokio::sync::oneshot::channel(); - let chat_config = self - .local_ai_resource - .get_chat_config(self.is_rag_enabled())?; - if let Err(err) = initialize_ai_plugin(&self.local_ai, chat_config, Some(tx)) { - error!("[AI Plugin] failed to initialize local ai: {:?}", err); - } - let _ = rx.await; - } else if let Err(err) = self.local_ai.destroy_chat_plugin().await { - error!("[AI Plugin] failed to destroy plugin: {:?}", err); - } - Ok(()) - } -} - -#[instrument(level = "debug", skip_all, err)] -fn initialize_ai_plugin( - llm_chat: &Arc, - mut chat_config: AIPluginConfig, - ret: Option>, -) -> FlowyResult<()> { - let llm_chat = llm_chat.clone(); - - tokio::spawn(async move { - info!("[AI Plugin] config: {:?}", chat_config); - if is_apple_silicon().await.unwrap_or(false) { - chat_config = chat_config.with_device("gpu"); - } - match llm_chat.init_chat_plugin(chat_config).await { - Ok(_) => {}, - Err(err) => error!("[AI Plugin] failed to setup plugin: {:?}", err), - } - - if let Some(ret) = ret { - let _ = ret.send(()); - } - }); - Ok(()) -} - -pub struct LLMResourceServiceImpl { - user_service: Arc, - cloud_service: Arc, - store_preferences: Arc, -} -#[async_trait] -impl LLMResourceService for LLMResourceServiceImpl { - async fn fetch_local_ai_config(&self) -> Result { - let workspace_id = self.user_service.workspace_id()?; - let config = self - .cloud_service - .get_local_ai_config(&workspace_id) - .await?; - Ok(config) - } - - fn store_setting(&self, setting: LLMSetting) -> Result<(), Error> { - self - .store_preferences - .set_object(LOCAL_AI_SETTING_KEY, &setting)?; - Ok(()) - } - - fn retrieve_setting(&self) -> Option { - self - .store_preferences - .get_object::(LOCAL_AI_SETTING_KEY) - } -} - -fn local_ai_enabled_key(workspace_id: &str) -> String { - format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id) -} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_resource.rs deleted file mode 100644 index 90dc328a6d..0000000000 --- a/frontend/rust-lib/flowy-ai/src/local_ai/local_llm_resource.rs +++ /dev/null @@ -1,534 +0,0 @@ -use crate::ai_manager::AIUserService; -use crate::entities::{LocalModelResourcePB, PendingResourcePB, PendingResourceTypePB}; -use crate::local_ai::local_llm_chat::{LLMModelInfo, LLMSetting}; -use crate::local_ai::model_request::download_model; - -use appflowy_local_ai::chat_plugin::AIPluginConfig; -use flowy_ai_pub::cloud::{LLMModel, LocalAIConfig, ModelInfo}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use futures::Sink; -use futures_util::SinkExt; -use lib_infra::async_trait::async_trait; - -use arc_swap::ArcSwapOption; -use lib_infra::util::{get_operating_system, OperatingSystem}; -use std::path::PathBuf; -use std::sync::Arc; - -use crate::local_ai::watch::offline_app_path; -#[cfg(target_os = "macos")] -use crate::local_ai::watch::{watch_offline_app, WatchContext}; -use tokio::fs::{self}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, error, info, instrument, trace, warn}; - -#[async_trait] -pub trait LLMResourceService: Send + Sync + 'static { - /// Get local ai configuration from remote server - async fn fetch_local_ai_config(&self) -> Result; - fn store_setting(&self, setting: LLMSetting) -> Result<(), anyhow::Error>; - fn retrieve_setting(&self) -> Option; -} - -const LLM_MODEL_DIR: &str = "models"; -const DOWNLOAD_FINISH: &str = "finish"; - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum WatchDiskEvent { - Create, - Remove, -} - -pub enum PendingResource { - OfflineApp, - ModelInfoRes(Vec), -} -#[derive(Clone)] -pub struct DownloadTask { - cancel_token: CancellationToken, - tx: tokio::sync::broadcast::Sender, -} -impl DownloadTask { - pub fn new() -> Self { - let (tx, _) = tokio::sync::broadcast::channel(100); - let cancel_token = CancellationToken::new(); - Self { cancel_token, tx } - } - - pub fn cancel(&self) { - self.cancel_token.cancel(); - } -} - -pub struct LocalAIResourceController { - user_service: Arc, - resource_service: Arc, - llm_setting: ArcSwapOption, - // The ai_config will be set when user try to get latest local ai config from server - ai_config: ArcSwapOption, - download_task: Arc>, - resource_notify: tokio::sync::mpsc::Sender<()>, - #[cfg(target_os = "macos")] - #[allow(dead_code)] - offline_app_disk_watch: Option, - offline_app_state_sender: tokio::sync::broadcast::Sender, -} - -impl LocalAIResourceController { - pub fn new( - user_service: Arc, - resource_service: impl LLMResourceService, - resource_notify: tokio::sync::mpsc::Sender<()>, - ) -> Self { - let (offline_app_state_sender, _) = tokio::sync::broadcast::channel(1); - let llm_setting = resource_service.retrieve_setting().map(Arc::new); - #[cfg(target_os = "macos")] - let mut offline_app_disk_watch: Option = None; - - #[cfg(target_os = "macos")] - { - match watch_offline_app() { - Ok((new_watcher, mut rx)) => { - let sender = offline_app_state_sender.clone(); - tokio::spawn(async move { - while let Some(event) = rx.recv().await { - if let Err(err) = sender.send(event) { - error!("[LLM Resource] Failed to send offline app state: {:?}", err); - } - } - }); - offline_app_disk_watch = Some(new_watcher); - }, - Err(err) => { - error!("[LLM Resource] Failed to watch offline app path: {:?}", err); - }, - } - } - - Self { - user_service, - resource_service: Arc::new(resource_service), - llm_setting: ArcSwapOption::new(llm_setting), - ai_config: Default::default(), - download_task: Default::default(), - resource_notify, - #[cfg(target_os = "macos")] - offline_app_disk_watch, - offline_app_state_sender, - } - } - - #[allow(dead_code)] - pub fn subscribe_offline_app_state(&self) -> tokio::sync::broadcast::Receiver { - self.offline_app_state_sender.subscribe() - } - - fn set_llm_setting(&self, llm_setting: LLMSetting) { - self.llm_setting.store(Some(llm_setting.into())); - } - - /// Returns true when all resources are downloaded and ready to use. - pub fn is_resource_ready(&self) -> bool { - match self.calculate_pending_resources() { - Ok(res) => res.is_empty(), - Err(_) => false, - } - } - - pub fn is_offline_app_ready(&self) -> bool { - offline_app_path().exists() - } - - pub async fn get_offline_ai_app_download_link(&self) -> FlowyResult { - let ai_config = self.fetch_ai_config().await?; - Ok(ai_config.plugin.url) - } - - /// Retrieves model information and updates the current model settings. - #[instrument(level = "debug", skip_all, err)] - pub async fn refresh_llm_resource(&self) -> FlowyResult { - let ai_config = self.fetch_ai_config().await?; - if ai_config.models.is_empty() { - return Err(FlowyError::local_ai().with_context("No model found")); - } - - self.ai_config.store(Some(ai_config.clone().into())); - let selected_model = self.select_model(&ai_config)?; - - let llm_setting = LLMSetting { - app: ai_config.plugin.clone(), - llm_model: selected_model.clone(), - }; - self.set_llm_setting(llm_setting.clone()); - self.resource_service.store_setting(llm_setting)?; - - Ok(LLMModelInfo { - selected_model, - models: ai_config.models, - }) - } - - #[instrument(level = "info", skip_all, err)] - pub fn use_local_llm(&self, llm_id: i64) -> FlowyResult { - let (app, llm_model) = self - .ai_config - .load() - .as_ref() - .and_then(|config| { - config - .models - .iter() - .find(|model| model.llm_id == llm_id) - .cloned() - .map(|model| (config.plugin.clone(), model)) - }) - .ok_or_else(|| FlowyError::local_ai().with_context("No local ai config found"))?; - - let llm_setting = LLMSetting { - app, - llm_model: llm_model.clone(), - }; - - trace!("[LLM Resource] Selected AI setting: {:?}", llm_setting); - self.set_llm_setting(llm_setting.clone()); - self.resource_service.store_setting(llm_setting)?; - self.get_local_llm_state() - } - - pub fn get_local_llm_state(&self) -> FlowyResult { - let state = self - .check_resource() - .ok_or_else(|| FlowyError::local_ai().with_context("No local ai config found"))?; - Ok(state) - } - - #[instrument(level = "debug", skip_all)] - fn check_resource(&self) -> Option { - trace!("[LLM Resource] Checking local ai resources"); - - let pending_resources = self.calculate_pending_resources().ok()?; - let is_ready = pending_resources.is_empty(); - let is_downloading = self.download_task.load().is_some(); - let pending_resources: Vec<_> = pending_resources - .into_iter() - .flat_map(|res| match res { - PendingResource::OfflineApp => vec![PendingResourcePB { - name: "AppFlowy Plugin".to_string(), - file_size: "0 GB".to_string(), - requirements: "".to_string(), - res_type: PendingResourceTypePB::OfflineApp, - }], - PendingResource::ModelInfoRes(model_infos) => model_infos - .into_iter() - .map(|model_info| PendingResourcePB { - name: model_info.name, - file_size: bytes_to_readable_format(model_info.file_size as u64), - requirements: model_info.requirements, - res_type: PendingResourceTypePB::AIModel, - }) - .collect::>(), - }) - .collect(); - - let resource = LocalModelResourcePB { - is_ready, - pending_resources, - is_downloading, - }; - - debug!("[LLM Resource] Local AI resources state: {:?}", resource); - Some(resource) - } - - /// Returns true when all resources are downloaded and ready to use. - pub fn calculate_pending_resources(&self) -> FlowyResult> { - match self.llm_setting.load().as_ref() { - None => Err(FlowyError::local_ai().with_context("Can't find any llm config")), - Some(llm_setting) => { - let mut resources = vec![]; - let app_path = offline_app_path(); - if !app_path.exists() { - trace!("[LLM Resource] offline app not found: {:?}", app_path); - resources.push(PendingResource::OfflineApp); - } - - let chat_model = self.model_path(&llm_setting.llm_model.chat_model.file_name)?; - if !chat_model.exists() { - resources.push(PendingResource::ModelInfoRes(vec![llm_setting - .llm_model - .chat_model - .clone()])); - } - - let embedding_model = self.model_path(&llm_setting.llm_model.embedding_model.file_name)?; - if !embedding_model.exists() { - resources.push(PendingResource::ModelInfoRes(vec![llm_setting - .llm_model - .embedding_model - .clone()])); - } - - Ok(resources) - }, - } - } - - #[instrument(level = "info", skip_all, err)] - pub async fn start_downloading(&self, mut progress_sink: T) -> FlowyResult - where - T: Sink + Unpin + Sync + Send + 'static, - { - let task_id = uuid::Uuid::new_v4().to_string(); - let weak_download_task = Arc::downgrade(&self.download_task); - let resource_notify = self.resource_notify.clone(); - // notify download progress to client. - let progress_notify = |mut rx: tokio::sync::broadcast::Receiver| { - tokio::spawn(async move { - while let Ok(value) = rx.recv().await { - let is_finish = value == DOWNLOAD_FINISH; - if let Err(err) = progress_sink.send(value).await { - warn!("Failed to send progress: {:?}", err); - break; - } - - if is_finish { - info!("notify download finish, need to reload resources"); - let _ = resource_notify.send(()).await; - if let Some(download_task) = weak_download_task.upgrade() { - if let Some(task) = download_task.swap(None) { - task.cancel(); - } - } - break; - } - } - }); - }; - - // return immediately if download task already exists - { - let guard = self.download_task.load(); - if let Some(download_task) = &*guard { - trace!( - "Download task already exists, return the task id: {}", - task_id - ); - progress_notify(download_task.tx.subscribe()); - return Ok(task_id); - } - } - - // If download task is not exists, create a new download task. - info!("[LLM Resource] Start new download task"); - let llm_setting = self - .llm_setting - .load_full() - .ok_or_else(|| FlowyError::local_ai().with_context("No local ai config found"))?; - - let download_task = Arc::new(DownloadTask::new()); - self.download_task.store(Some(download_task.clone())); - progress_notify(download_task.tx.subscribe()); - - let model_dir = self.user_model_folder()?; - if !model_dir.exists() { - fs::create_dir_all(&model_dir).await.map_err(|err| { - FlowyError::local_ai().with_context(format!("Failed to create model dir: {:?}", err)) - })?; - } - - tokio::spawn(async move { - // After download the plugin, start downloading models - let chat_model_file = ( - model_dir.join(&llm_setting.llm_model.chat_model.file_name), - &llm_setting.llm_model.chat_model.file_name, - &llm_setting.llm_model.chat_model.name, - &llm_setting.llm_model.chat_model.download_url, - ); - let embedding_model_file = ( - model_dir.join(&llm_setting.llm_model.embedding_model.file_name), - &llm_setting.llm_model.embedding_model.file_name, - &llm_setting.llm_model.embedding_model.name, - &llm_setting.llm_model.embedding_model.download_url, - ); - for (file_path, file_name, model_name, url) in [chat_model_file, embedding_model_file] { - if file_path.exists() { - continue; - } - - info!("[LLM Resource] Downloading model: {:?}", file_name); - let plugin_progress_tx = download_task.tx.clone(); - let cloned_model_name = model_name.clone(); - let progress = Arc::new(move |downloaded, total_size| { - let progress = (downloaded as f64 / total_size as f64).clamp(0.0, 1.0); - if plugin_progress_tx.receiver_count() == 0 { - return; - } - - if let Err(err) = - plugin_progress_tx.send(format!("{}:progress:{}", cloned_model_name, progress)) - { - warn!("Failed to send progress: {:?}", err); - } - }); - match download_model( - url, - &model_dir, - file_name, - Some(progress), - Some(download_task.cancel_token.clone()), - ) - .await - { - Ok(_) => info!("[LLM Resource] Downloaded model: {:?}", file_name), - Err(err) => { - error!( - "[LLM Resource] Failed to download model for given url: {:?}, error: {:?}", - url, err - ); - download_task - .tx - .send(format!("error:failed to download {}", model_name))?; - continue; - }, - } - } - info!("[LLM Resource] All resources downloaded"); - download_task.tx.send(DOWNLOAD_FINISH.to_string())?; - Ok::<_, anyhow::Error>(()) - }); - - Ok(task_id) - } - - pub fn cancel_download(&self) -> FlowyResult<()> { - if let Some(cancel_token) = self.download_task.swap(None) { - info!("[LLM Resource] Cancel download"); - cancel_token.cancel(); - } - - Ok(()) - } - - #[instrument(level = "info", skip_all)] - pub fn get_chat_config(&self, rag_enabled: bool) -> FlowyResult { - if !self.is_resource_ready() { - return Err(FlowyError::local_ai().with_context("Local AI resources are not ready")); - } - - let llm_setting = self - .llm_setting - .load_full() - .ok_or_else(|| FlowyError::local_ai().with_context("No local llm setting found"))?; - - let model_dir = self.user_model_folder()?; - let bin_path = match get_operating_system() { - OperatingSystem::MacOS => { - let path = offline_app_path(); - if !path.exists() { - return Err(FlowyError::new( - ErrorCode::AIOfflineNotInstalled, - format!("AppFlowy Offline not installed at path: {:?}", path), - )); - } - path - }, - _ => { - return Err( - FlowyError::local_ai_unavailable() - .with_context("Local AI not available on current platform"), - ); - }, - }; - - let chat_model_path = model_dir.join(&llm_setting.llm_model.chat_model.file_name); - let mut config = AIPluginConfig::new(bin_path, chat_model_path)?; - - if rag_enabled { - let resource_dir = self.resource_dir()?; - let embedding_model_path = model_dir.join(&llm_setting.llm_model.embedding_model.file_name); - let persist_directory = resource_dir.join("vectorstore"); - if !persist_directory.exists() { - std::fs::create_dir_all(&persist_directory)?; - } - config.set_rag_enabled(&embedding_model_path, &persist_directory)?; - } - - if cfg!(debug_assertions) { - config = config.with_verbose(true); - } - trace!("[AI Chat] use config: {:?}", config); - Ok(config) - } - - /// Fetches the local AI configuration from the resource service. - async fn fetch_ai_config(&self) -> FlowyResult { - self - .resource_service - .fetch_local_ai_config() - .await - .map_err(|err| { - error!("[LLM Resource] Failed to fetch local ai config: {:?}", err); - FlowyError::local_ai() - .with_context("Can't retrieve model info. Please try again later".to_string()) - }) - } - - pub fn get_selected_model(&self) -> Option { - let setting = self.llm_setting.load(); - Some(setting.as_ref()?.llm_model.clone()) - } - - /// Selects the appropriate model based on the current settings or defaults to the first model. - fn select_model(&self, ai_config: &LocalAIConfig) -> FlowyResult { - let llm_setting = self.llm_setting.load(); - let selected_model = match &*llm_setting { - None => ai_config.models[0].clone(), - Some(llm_setting) => { - match ai_config - .models - .iter() - .find(|model| model.llm_id == llm_setting.llm_model.llm_id) - { - None => ai_config.models[0].clone(), - Some(llm_model) => { - if llm_model != &llm_setting.llm_model { - info!( - "[LLM Resource] existing model is different from remote, replace with remote model" - ); - } - llm_model.clone() - }, - } - }, - }; - Ok(selected_model) - } - - pub(crate) fn user_model_folder(&self) -> FlowyResult { - self.resource_dir().map(|dir| dir.join(LLM_MODEL_DIR)) - } - - fn model_path(&self, model_file_name: &str) -> FlowyResult { - self - .user_model_folder() - .map(|dir| dir.join(model_file_name)) - } - - pub(crate) fn resource_dir(&self) -> FlowyResult { - let user_data_dir = self.user_service.application_root_dir()?; - Ok(user_data_dir.join("ai")) - } -} -fn bytes_to_readable_format(bytes: u64) -> String { - const BYTES_IN_GIGABYTE: u64 = 1024 * 1024 * 1024; - const BYTES_IN_MEGABYTE: u64 = 1024 * 1024; - - if bytes >= BYTES_IN_GIGABYTE { - let gigabytes = (bytes as f64) / (BYTES_IN_GIGABYTE as f64); - format!("{:.1} GB", gigabytes) - } else { - let megabytes = (bytes as f64) / (BYTES_IN_MEGABYTE as f64); - format!("{:.2} MB", megabytes) - } -} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs b/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs index 5daddf881b..c0fd967d43 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs @@ -1,6 +1,6 @@ -pub mod local_llm_chat; -pub mod local_llm_resource; -mod model_request; +pub mod controller; +mod request; +pub mod resource; pub mod stream_util; pub mod watch; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/model_request.rs b/frontend/rust-lib/flowy-ai/src/local_ai/request.rs similarity index 72% rename from frontend/rust-lib/flowy-ai/src/local_ai/model_request.rs rename to frontend/rust-lib/flowy-ai/src/local_ai/request.rs index c37a6f04ff..6d4bd3289d 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/model_request.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/request.rs @@ -12,6 +12,7 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use tokio_util::sync::CancellationToken; use tracing::{instrument, trace}; +#[allow(dead_code)] type ProgressCallback = Arc; #[instrument(level = "trace", skip_all, err)] @@ -95,6 +96,7 @@ pub async fn download_model( Ok(download_path) } +#[allow(dead_code)] async fn make_request( client: &Client, url: &str, @@ -114,46 +116,3 @@ async fn make_request( } Ok(response) } - -#[cfg(test)] -mod test { - use super::*; - use std::env::temp_dir; - #[tokio::test] - async fn retrieve_gpt4all_model_test() { - for url in [ - // "https://gpt4all.io/models/gguf/all-MiniLM-L6-v2-f16.gguf", - "https://huggingface.co/second-state/All-MiniLM-L6-v2-Embedding-GGUF/resolve/main/all-MiniLM-L6-v2-Q3_K_L.gguf?download=true", - // "https://huggingface.co/MaziyarPanahi/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3.Q4_K_M.gguf?download=true", - ] { - let temp_dir = temp_dir().join("download_llm"); - if !temp_dir.exists() { - fs::create_dir(&temp_dir).await.unwrap(); - } - let file_name = "llm_model.gguf"; - let cancel_token = CancellationToken::new(); - let token = cancel_token.clone(); - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_secs(120)).await; - token.cancel(); - }); - - let download_file = download_model( - url, - &temp_dir, - file_name, - Some(Arc::new(|a, b| { - println!("{}/{}", a, b); - })), - Some(cancel_token), - ).await.unwrap(); - - let file_path = temp_dir.join(file_name); - assert_eq!(download_file, file_path); - - println!("File path: {:?}", file_path); - assert!(file_path.exists()); - std::fs::remove_file(file_path).unwrap(); - } - } -} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs new file mode 100644 index 0000000000..6251ef8de5 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -0,0 +1,289 @@ +use crate::ai_manager::AIUserService; +use crate::local_ai::controller::LocalAISetting; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use lib_infra::async_trait::async_trait; + +use crate::entities::LackOfAIResourcePB; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::local_ai::watch::{watch_offline_app, WatchContext}; +use crate::notification::{ + chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, +}; +use af_local_ai::ollama_plugin::OllamaPluginConfig; +use af_plugin::core::path::{is_plugin_ready, ollama_plugin_path}; +use lib_infra::util::{get_operating_system, OperatingSystem}; +use reqwest::Client; +use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tracing::{error, info, instrument, trace}; + +#[derive(Debug, Deserialize)] +struct TagsResponse { + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + name: String, +} + +#[async_trait] +pub trait LLMResourceService: Send + Sync + 'static { + /// Get local ai configuration from remote server + fn store_setting(&self, setting: LocalAISetting) -> Result<(), anyhow::Error>; + fn retrieve_setting(&self) -> Option; +} + +const LLM_MODEL_DIR: &str = "models"; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum WatchDiskEvent { + Create, + Remove, +} + +#[derive(Debug, Clone)] +pub enum PendingResource { + PluginExecutableNotReady, + OllamaServerNotReady, + MissingModel(String), +} + +pub struct LocalAIResourceController { + user_service: Arc, + resource_service: Arc, + resource_notify: tokio::sync::broadcast::Sender<()>, + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[allow(dead_code)] + app_disk_watch: Option, + app_state_sender: tokio::sync::broadcast::Sender, +} + +impl LocalAIResourceController { + pub fn new( + user_service: Arc, + resource_service: impl LLMResourceService, + ) -> Self { + let (resource_notify, _) = tokio::sync::broadcast::channel(1); + let (app_state_sender, _) = tokio::sync::broadcast::channel(1); + #[cfg(any(target_os = "macos", target_os = "linux"))] + let mut offline_app_disk_watch: Option = None; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + match watch_offline_app() { + Ok((new_watcher, mut rx)) => { + let sender = app_state_sender.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + if let Err(err) = sender.send(event) { + error!("[LLM Resource] Failed to send offline app state: {:?}", err); + } + } + }); + offline_app_disk_watch = Some(new_watcher); + }, + Err(err) => { + error!("[LLM Resource] Failed to watch offline app path: {:?}", err); + }, + } + } + + Self { + user_service, + resource_service: Arc::new(resource_service), + #[cfg(any(target_os = "macos", target_os = "linux"))] + app_disk_watch: offline_app_disk_watch, + app_state_sender, + resource_notify, + } + } + + pub fn subscribe_resource_notify(&self) -> tokio::sync::broadcast::Receiver<()> { + self.resource_notify.subscribe() + } + + pub fn subscribe_app_state(&self) -> tokio::sync::broadcast::Receiver { + self.app_state_sender.subscribe() + } + + /// Returns true when all resources are downloaded and ready to use. + pub async fn is_resource_ready(&self) -> bool { + let sys = get_operating_system(); + if !sys.is_desktop() { + return false; + } + + self + .calculate_pending_resources() + .await + .is_ok_and(|r| r.is_none()) + } + + /// Retrieves model information and updates the current model settings. + pub fn get_llm_setting(&self) -> LocalAISetting { + self.resource_service.retrieve_setting().unwrap_or_default() + } + + #[instrument(level = "info", skip_all, err)] + pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + self.resource_service.store_setting(setting)?; + if let Some(resource) = self.calculate_pending_resources().await? { + let resource = LackOfAIResourcePB::from(resource); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::LocalAIResourceUpdated, + ) + .payload(resource.clone()) + .send(); + return Err(FlowyError::local_ai().with_context(format!("{:?}", resource))); + } + Ok(()) + } + + pub async fn get_lack_of_resource(&self) -> Option { + self + .calculate_pending_resources() + .await + .ok()? + .map(Into::into) + } + + pub async fn calculate_pending_resources(&self) -> FlowyResult> { + let app_path = ollama_plugin_path(); + if !is_plugin_ready() { + trace!("[LLM Resource] offline app not found: {:?}", app_path); + return Ok(Some(PendingResource::PluginExecutableNotReady)); + } + + let setting = self.get_llm_setting(); + let client = Client::builder().timeout(Duration::from_secs(5)).build()?; + + match client.get(&setting.ollama_server_url).send().await { + Ok(resp) if resp.status().is_success() => { + info!( + "[LLM Resource] Ollama server is running at {}", + setting.ollama_server_url + ); + }, + _ => { + info!( + "[LLM Resource] Ollama server is not responding at {}", + setting.ollama_server_url + ); + return Ok(Some(PendingResource::OllamaServerNotReady)); + }, + } + + let required_models = vec![setting.chat_model_name, setting.embedding_model_name]; + + // Query the /api/tags endpoint to get a structured list of locally available models. + let tags_url = format!("{}/api/tags", setting.ollama_server_url); + + match client.get(&tags_url).send().await { + Ok(resp) if resp.status().is_success() => { + let tags: TagsResponse = resp.json().await.inspect_err(|e| { + log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") + })?; + // Check if each of our required models exists in the list of available models + trace!("[LLM Resource] ollama available models: {:?}", tags.models); + for required in &required_models { + if !tags + .models + .iter() + .any(|m| m.name == *required || m.name == format!("{}:latest", required)) + { + log::trace!( + "[LLM Resource] required model '{}' not found in API response", + required + ); + return Ok(Some(PendingResource::MissingModel(required.clone()))); + } + } + }, + _ => { + error!( + "[LLM Resource] Failed to fetch models from {} (GET /api/tags)", + setting.ollama_server_url + ); + return Ok(Some(PendingResource::OllamaServerNotReady)); + }, + } + + Ok(None) + } + + #[instrument(level = "info", skip_all)] + pub async fn get_plugin_config(&self, rag_enabled: bool) -> FlowyResult { + if !self.is_resource_ready().await { + return Err(FlowyError::new( + ErrorCode::AppFlowyLAINotReady, + "AppFlowyLAI not found", + )); + } + + let llm_setting = self.get_llm_setting(); + let bin_path = match get_operating_system() { + OperatingSystem::MacOS | OperatingSystem::Windows | OperatingSystem::Linux => { + ollama_plugin_path() + }, + _ => { + return Err( + FlowyError::local_ai_unavailable() + .with_context("Local AI not available on current platform"), + ); + }, + }; + + let mut config = OllamaPluginConfig::new( + bin_path, + "af_ollama_plugin".to_string(), + llm_setting.chat_model_name.clone(), + llm_setting.embedding_model_name.clone(), + Some(llm_setting.ollama_server_url.clone()), + )?; + + //config = config.with_log_level("debug".to_string()); + + if rag_enabled { + let resource_dir = self.resource_dir()?; + let persist_directory = resource_dir.join("vectorstore"); + if !persist_directory.exists() { + std::fs::create_dir_all(&persist_directory)?; + } + config.set_rag_enabled(&persist_directory)?; + } + + if cfg!(debug_assertions) { + config = config.with_verbose(true); + } + trace!("[AI Chat] config: {:?}", config); + Ok(config) + } + + pub(crate) fn user_model_folder(&self) -> FlowyResult { + self.resource_dir().map(|dir| dir.join(LLM_MODEL_DIR)) + } + + pub(crate) fn resource_dir(&self) -> FlowyResult { + let user_data_dir = self.user_service.application_root_dir()?; + Ok(user_data_dir.join("ai")) + } +} + +#[allow(dead_code)] +fn bytes_to_readable_format(bytes: u64) -> String { + const BYTES_IN_GIGABYTE: u64 = 1024 * 1024 * 1024; + const BYTES_IN_MEGABYTE: u64 = 1024 * 1024; + + if bytes >= BYTES_IN_GIGABYTE { + let gigabytes = (bytes as f64) / (BYTES_IN_GIGABYTE as f64); + format!("{:.1} GB", gigabytes) + } else { + let megabytes = (bytes as f64) / (BYTES_IN_MEGABYTE as f64); + format!("{:.2} MB", megabytes) + } +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs index 76eb01ea6f..fbe4157c8c 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs @@ -1,4 +1,4 @@ -use appflowy_plugin::error::PluginError; +use af_plugin::error::PluginError; use flowy_ai_pub::cloud::QuestionStreamValue; use flowy_error::FlowyError; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs index cee8f1d381..2baed3f0a5 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs @@ -1,4 +1,5 @@ -use crate::local_ai::local_llm_resource::WatchDiskEvent; +use crate::local_ai::resource::WatchDiskEvent; +use af_plugin::core::path::{install_path, ollama_plugin_path}; use flowy_error::{FlowyError, FlowyResult}; use std::path::PathBuf; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; @@ -20,7 +21,7 @@ pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver| match res { Ok(event) => { if event.paths.iter().any(|path| path == &app_path) { @@ -57,38 +58,3 @@ pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver Option { - None -} - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub(crate) fn install_path() -> Option { - #[cfg(target_os = "windows")] - return None; - - #[cfg(target_os = "macos")] - return Some(PathBuf::from("/usr/local/bin")); - - #[cfg(target_os = "linux")] - return None; -} - -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] -pub(crate) fn offline_app_path() -> PathBuf { - PathBuf::new() -} - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -pub(crate) fn offline_app_path() -> PathBuf { - let offline_app = "appflowy_ai_plugin"; - #[cfg(target_os = "windows")] - return PathBuf::from(format!("/usr/local/bin/{}", offline_app)); - - #[cfg(target_os = "macos")] - return PathBuf::from(format!("/usr/local/bin/{}", offline_app)); - - #[cfg(target_os = "linux")] - return PathBuf::from(format!("/usr/local/bin/{}", offline_app)); -} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/manager.rs b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs new file mode 100644 index 0000000000..9e40a51f68 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs @@ -0,0 +1,39 @@ +use af_mcp::client::{MCPClient, MCPServerConfig}; +use af_mcp::entities::ToolsList; +use dashmap::DashMap; +use flowy_error::FlowyError; +use std::sync::Arc; + +pub struct MCPClientManager { + stdio_clients: Arc>, +} + +impl MCPClientManager { + pub fn new() -> MCPClientManager { + Self { + stdio_clients: Arc::new(DashMap::new()), + } + } + + pub async fn connect_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + let client = MCPClient::new_stdio(config.clone()).await?; + self.stdio_clients.insert(config.server_cmd, client.clone()); + client.initialize().await?; + Ok(()) + } + + pub async fn remove_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + let client = self.stdio_clients.remove(&config.server_cmd); + if let Some((_, mut client)) = client { + client.stop().await?; + } + Ok(()) + } + + pub async fn tool_list(&self, server_cmd: &str) -> Option { + let client = self.stdio_clients.get(server_cmd)?; + let tools = client.list_tools().await.ok(); + tracing::trace!("{}: tool list: {:?}", server_cmd, tools); + tools + } +} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/mod.rs b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs new file mode 100644 index 0000000000..8f73c8326c --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs @@ -0,0 +1 @@ +mod manager; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs index ae8ce9b8d0..22a2bec674 100644 --- a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -1,93 +1,61 @@ use crate::ai_manager::AIUserService; use crate::entities::{ChatStatePB, ModelTypePB}; -use crate::local_ai::local_llm_chat::LocalAIController; +use crate::local_ai::controller::LocalAIController; use crate::notification::{ chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, }; -use crate::persistence::{select_single_message, ChatMessageTable}; -use appflowy_plugin::error::PluginError; +use af_plugin::error::PluginError; +use flowy_ai_pub::persistence::select_message_content; use std::collections::HashMap; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, LocalAIConfig, MessageCursor, RelatedQuestion, RepeatedChatMessage, - RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, SubscriptionPlan, + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, RelatedQuestion, + RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, }; use flowy_error::{FlowyError, FlowyResult}; -use futures::{stream, Sink, StreamExt, TryStreamExt}; +use futures::{stream, StreamExt, TryStreamExt}; use lib_infra::async_trait::async_trait; use crate::local_ai::stream_util::QuestionStream; -use crate::stream_message::StreamMessage; use flowy_storage_pub::storage::StorageService; -use futures_util::SinkExt; use serde_json::{json, Value}; use std::path::Path; use std::sync::{Arc, Weak}; -use tracing::trace; +use tracing::{info, trace}; +use uuid::Uuid; -pub struct AICloudServiceMiddleware { +pub struct ChatServiceMiddleware { cloud_service: Arc, user_service: Arc, - local_llm_controller: Arc, + local_ai: Arc, + #[allow(dead_code)] storage_service: Weak, } -impl AICloudServiceMiddleware { +impl ChatServiceMiddleware { pub fn new( user_service: Arc, cloud_service: Arc, - local_llm_controller: Arc, + local_ai: Arc, storage_service: Weak, ) -> Self { Self { user_service, cloud_service, - local_llm_controller, + local_ai, storage_service, } } - pub fn is_local_ai_enabled(&self) -> bool { - self.local_llm_controller.is_enabled() - } - - pub async fn index_message_metadata( - &self, - chat_id: &str, - metadata_list: &[ChatMessageMetadata], - index_process_sink: &mut (impl Sink + Unpin), - ) -> Result<(), FlowyError> { - if metadata_list.is_empty() { - return Ok(()); - } - if self.is_local_ai_enabled() { - let _ = index_process_sink - .send(StreamMessage::IndexStart.to_string()) - .await; - - self - .local_llm_controller - .index_message_metadata(chat_id, metadata_list, index_process_sink) - .await?; - let _ = index_process_sink - .send(StreamMessage::IndexEnd.to_string()) - .await; - } else if let Some(_storage_service) = self.storage_service.upgrade() { - // - } - Ok(()) - } - - fn get_message_record(&self, message_id: i64) -> FlowyResult { + fn get_message_content(&self, message_id: i64) -> FlowyResult { let uid = self.user_service.user_id()?; let conn = self.user_service.sqlite_connection(uid)?; - let row = select_single_message(conn, message_id)?.ok_or_else(|| { + let content = select_message_content(conn, message_id)?.ok_or_else(|| { FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) })?; - - Ok(row) + Ok(content) } fn handle_plugin_error(&self, err: PluginError) { @@ -97,7 +65,7 @@ impl AICloudServiceMiddleware { ) { chat_notification_builder( APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateChatPluginState, + ChatNotification::UpdateLocalAIState, ) .payload(ChatStatePB { model_type: ModelTypePB::LocalAI, @@ -109,38 +77,39 @@ impl AICloudServiceMiddleware { } #[async_trait] -impl ChatCloudService for AICloudServiceMiddleware { +impl ChatCloudService for ChatServiceMiddleware { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { self .cloud_service - .create_chat(uid, workspace_id, chat_id, rag_ids) + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) .await } async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { self .cloud_service - .create_question(workspace_id, chat_id, message, message_type, metadata) + .create_question(workspace_id, chat_id, message, message_type) .await } async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -153,50 +122,67 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, question_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { - if self.local_llm_controller.is_running() { - let row = self.get_message_record(question_id)?; - match self - .local_llm_controller - .stream_question(chat_id, &row.content, json!([])) - .await - { - Ok(stream) => Ok(QuestionStream::new(stream).boxed()), - Err(err) => { - self.handle_plugin_error(err); - Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) - }, + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_answer use model: {:?}", ai_model); + if use_local_ai { + if self.local_ai.is_running() { + let content = self.get_message_content(question_id)?; + match self + .local_ai + .stream_question( + &chat_id.to_string(), + &content, + Some(json!(format)), + json!({}), + ) + .await + { + Ok(stream) => Ok(QuestionStream::new(stream).boxed()), + Err(err) => { + self.handle_plugin_error(err); + Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) + }, + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) } } else { self .cloud_service - .stream_answer(workspace_id, chat_id, question_id, format) + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) .await } } async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, - question_message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, ) -> Result { - if self.local_llm_controller.is_running() { - let content = self.get_message_record(question_message_id)?.content; + if self.local_ai.is_running() { + let content = self.get_message_content(question_id)?; match self - .local_llm_controller - .ask_question(chat_id, &content) + .local_ai + .ask_question(&chat_id.to_string(), &content) .await { Ok(answer) => { - // TODO(nathan): metadata let message = self .cloud_service - .create_answer(workspace_id, chat_id, &answer, question_message_id, None) + .create_answer(workspace_id, chat_id, &answer, question_id, None) .await?; Ok(message) }, @@ -208,15 +194,15 @@ impl ChatCloudService for AICloudServiceMiddleware { } else { self .cloud_service - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer(workspace_id, chat_id, question_id) .await } } async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { @@ -228,113 +214,135 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, - answer_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, ) -> Result { self .cloud_service - .get_question_from_answer_id(workspace_id, chat_id, answer_id) + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) .await } async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { - if self.local_llm_controller.is_running() { - let questions = self - .local_llm_controller - .get_related_question(chat_id) - .await - .map_err(|err| FlowyError::local_ai().with_context(err))?; - trace!("LocalAI related questions: {:?}", questions); + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; - let items = questions - .into_iter() - .map(|content| RelatedQuestion { - content, - metadata: None, + if use_local_ai { + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], }) - .collect::>(); - - Ok(RepeatedRelatedQuestion { message_id, items }) + } } else { self .cloud_service - .get_related_message(workspace_id, chat_id, message_id) + .get_related_message(workspace_id, chat_id, message_id, ai_model) .await } } async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, + ai_model: Option, ) -> Result { - if self.local_llm_controller.is_running() { - match self - .local_llm_controller - .complete_text(¶ms.text, params.completion_type.unwrap() as u8) - .await - { - Ok(stream) => Ok( - stream - .map_err(|err| FlowyError::local_ai().with_context(err)) + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_complete use custom model: {:?}", ai_model); + if use_local_ai { + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), + ) + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) .boxed(), - ), - Err(err) => { - self.handle_plugin_error(err); - Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) - }, + ), + Err(err) => { + self.handle_plugin_error(err); + Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) + }, + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) } } else { self .cloud_service - .stream_complete(workspace_id, params) + .stream_complete(workspace_id, params, ai_model) .await } } - async fn index_file( + async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError> { - if self.local_llm_controller.is_running() { + if self.local_ai.is_running() { self - .local_llm_controller - .index_file(chat_id, Some(file_path.to_path_buf()), None, metadata) + .local_ai + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) .await .map_err(|err| FlowyError::local_ai().with_context(err))?; Ok(()) } else { self .cloud_service - .index_file(workspace_id, file_path, chat_id, metadata) + .embed_file(workspace_id, file_path, chat_id, metadata) .await } } - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { - self.cloud_service.get_local_ai_config(workspace_id).await - } - - async fn get_workspace_plan( - &self, - workspace_id: &str, - ) -> Result, FlowyError> { - self.cloud_service.get_workspace_plan(workspace_id).await - } - async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { self .cloud_service @@ -344,8 +352,8 @@ impl ChatCloudService for AICloudServiceMiddleware { async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self @@ -353,4 +361,15 @@ impl ChatCloudService for AICloudServiceMiddleware { .update_chat_settings(workspace_id, chat_id, params) .await } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self.cloud_service.get_available_models(workspace_id).await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } } diff --git a/frontend/rust-lib/flowy-ai/src/notification.rs b/frontend/rust-lib/flowy-ai/src/notification.rs index c7857dbc8a..6fbf3a8e7a 100644 --- a/frontend/rust-lib/flowy-ai/src/notification.rs +++ b/frontend/rust-lib/flowy-ai/src/notification.rs @@ -1,5 +1,6 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; +use tracing::trace; const CHAT_OBSERVABLE_SOURCE: &str = "Chat"; pub const APPFLOWY_AI_NOTIFICATION_KEY: &str = "appflowy_ai_plugin"; @@ -12,9 +13,10 @@ pub enum ChatNotification { DidReceiveChatMessage = 3, StreamChatMessageError = 4, FinishStreaming = 5, - UpdateChatPluginState = 6, - UpdateLocalChatAI = 7, - DidUpdateChatSettings = 8, + UpdateLocalAIState = 6, + DidUpdateChatSettings = 7, + LocalAIResourceUpdated = 8, + DidUpdateSelectedModel = 9, } impl std::convert::From for i32 { @@ -30,14 +32,20 @@ impl std::convert::From for ChatNotification { 3 => ChatNotification::DidReceiveChatMessage, 4 => ChatNotification::StreamChatMessageError, 5 => ChatNotification::FinishStreaming, - 6 => ChatNotification::UpdateChatPluginState, - 7 => ChatNotification::UpdateLocalChatAI, + 6 => ChatNotification::UpdateLocalAIState, + 7 => ChatNotification::DidUpdateChatSettings, + 8 => ChatNotification::LocalAIResourceUpdated, _ => ChatNotification::Unknown, } } } -#[tracing::instrument(level = "trace")] -pub(crate) fn chat_notification_builder(id: &str, ty: ChatNotification) -> NotificationBuilder { - NotificationBuilder::new(id, ty, CHAT_OBSERVABLE_SOURCE) +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn chat_notification_builder( + id: T, + ty: ChatNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("chat_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, CHAT_OBSERVABLE_SOURCE) } diff --git a/frontend/rust-lib/flowy-ai/src/offline/mod.rs b/frontend/rust-lib/flowy-ai/src/offline/mod.rs new file mode 100644 index 0000000000..e55b43fdb2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/mod.rs @@ -0,0 +1 @@ +pub mod offline_message_sync; diff --git a/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs new file mode 100644 index 0000000000..8d7e8d2e42 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs @@ -0,0 +1,258 @@ +use crate::ai_manager::AIUserService; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, + StreamAnswer, StreamComplete, UpdateChatParams, +}; +use flowy_ai_pub::persistence::{ + update_chat_is_sync, update_chat_message_is_sync, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, +}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use uuid::Uuid; + +pub struct AutoSyncChatService { + cloud_service: Arc, + user_service: Arc, +} + +impl AutoSyncChatService { + pub fn new( + cloud_service: Arc, + user_service: Arc, + ) -> Self { + Self { + cloud_service, + user_service, + } + } + + async fn upsert_message( + &self, + chat_id: &Uuid, + message: ChatMessage, + is_sync: bool, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, is_sync); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } + + #[allow(dead_code)] + async fn update_message_is_sync( + &self, + chat_id: &Uuid, + message_id: i64, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + update_chat_message_is_sync(conn, &chat_id.to_string(), message_id, true)?; + Ok(()) + } +} + +#[async_trait] +impl ChatCloudService for AutoSyncChatService { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: Value, + ) -> Result<(), FlowyError> { + let conn = self.user_service.sqlite_connection(*uid)?; + let chat = ChatTable::new( + chat_id.to_string(), + metadata.clone(), + rag_ids.clone(), + false, + ); + upsert_chat(conn, &chat)?; + + if self + .cloud_service + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) + .await + .is_ok() + { + let conn = self.user_service.sqlite_connection(*uid)?; + update_chat_is_sync(conn, &chat_id.to_string(), true)?; + } + Ok(()) + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = self + .cloud_service + .create_question(workspace_id, chat_id, message, message_type) + .await?; + self.upsert_message(chat_id, message.clone(), true).await?; + // TODO: implement background sync + // self + // .update_message_is_sync(chat_id, message.message_id) + // .await?; + Ok(message) + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let message = self + .cloud_service + .create_answer(workspace_id, chat_id, message, question_id, metadata) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) + .await + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let message = self + .cloud_service + .get_answer(workspace_id, chat_id, question_id) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + self + .cloud_service + .get_chat_messages(workspace_id, chat_id, offset, limit) + .await + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + self + .cloud_service + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) + .await + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + self + .cloud_service + .get_related_message(workspace_id, chat_id, message_id, ai_model) + .await + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_complete(workspace_id, params, ai_model) + .await + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + self + .cloud_service + .embed_file(workspace_id, file_path, chat_id, metadata) + .await + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + // TODO: implement background sync + self + .cloud_service + .get_chat_settings(workspace_id, chat_id) + .await + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + // TODO: implement background sync + self + .cloud_service + .update_chat_settings(workspace_id, chat_id, params) + .await + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self.cloud_service.get_available_models(workspace_id).await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } +} diff --git a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs deleted file mode 100644 index aa4dd8215d..0000000000 --- a/frontend/rust-lib/flowy-ai/src/persistence/chat_message_sql.rs +++ /dev/null @@ -1,94 +0,0 @@ -use flowy_error::{FlowyError, FlowyResult}; -use flowy_sqlite::upsert::excluded; -use flowy_sqlite::{ - diesel, insert_into, - query_dsl::*, - schema::{chat_message_table, chat_message_table::dsl}, - DBConnection, ExpressionMethods, Identifiable, Insertable, OptionalExtension, QueryResult, - Queryable, -}; - -#[derive(Queryable, Insertable, Identifiable)] -#[diesel(table_name = chat_message_table)] -#[diesel(primary_key(message_id))] -pub struct ChatMessageTable { - pub message_id: i64, - pub chat_id: String, - pub content: String, - pub created_at: i64, - pub author_type: i64, - pub author_id: String, - pub reply_message_id: Option, - pub metadata: Option, -} - -pub fn insert_chat_messages( - mut conn: DBConnection, - new_messages: &[ChatMessageTable], -) -> FlowyResult<()> { - conn.immediate_transaction(|conn| { - for message in new_messages { - let _ = insert_into(chat_message_table::table) - .values(message) - .on_conflict(chat_message_table::message_id) - .do_update() - .set(( - chat_message_table::content.eq(excluded(chat_message_table::content)), - chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), - chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), - chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), - chat_message_table::reply_message_id.eq(excluded(chat_message_table::reply_message_id)), - )) - .execute(conn)?; - } - Ok::<(), FlowyError>(()) - })?; - - Ok(()) -} - -pub fn select_chat_messages( - mut conn: DBConnection, - chat_id_val: &str, - limit_val: i64, - after_message_id: Option, - before_message_id: Option, -) -> QueryResult> { - let mut query = dsl::chat_message_table - .filter(chat_message_table::chat_id.eq(chat_id_val)) - .into_boxed(); - if let Some(after_message_id) = after_message_id { - query = query.filter(chat_message_table::message_id.gt(after_message_id)); - } - - if let Some(before_message_id) = before_message_id { - query = query.filter(chat_message_table::message_id.lt(before_message_id)); - } - query = query - .order((chat_message_table::message_id.desc(),)) - .limit(limit_val); - - let messages: Vec = query.load::(&mut *conn)?; - Ok(messages) -} - -pub fn select_single_message( - mut conn: DBConnection, - message_id_val: i64, -) -> QueryResult> { - let message = dsl::chat_message_table - .filter(chat_message_table::message_id.eq(message_id_val)) - .first::(&mut *conn) - .optional()?; - Ok(message) -} - -pub fn select_message_where_match_reply_message_id( - mut conn: DBConnection, - answer_message_id_val: i64, -) -> QueryResult> { - dsl::chat_message_table - .filter(chat_message_table::reply_message_id.eq(answer_message_id_val)) - .first::(&mut *conn) - .optional() -} diff --git a/frontend/rust-lib/flowy-ai/src/stream_message.rs b/frontend/rust-lib/flowy-ai/src/stream_message.rs index c507262b85..3f7b37bd34 100644 --- a/frontend/rust-lib/flowy-ai/src/stream_message.rs +++ b/frontend/rust-lib/flowy-ai/src/stream_message.rs @@ -1,10 +1,14 @@ use std::fmt::Display; +#[allow(dead_code)] pub enum StreamMessage { MessageId(i64), IndexStart, IndexEnd, Text(String), + OnData(String), + OnError(String), + Metadata(String), Done, StartIndexFile { file_name: String }, EndIndexFile { file_name: String }, @@ -20,7 +24,10 @@ impl Display for StreamMessage { StreamMessage::Text(text) => { write!(f, "data:{}", text) }, + StreamMessage::OnData(message) => write!(f, "data:{message}"), + StreamMessage::OnError(message) => write!(f, "error:{message}"), StreamMessage::Done => write!(f, "done:"), + StreamMessage::Metadata(s) => write!(f, "metadata:{s}"), StreamMessage::StartIndexFile { file_name } => { write!(f, "start_index_file:{}", file_name) }, diff --git a/frontend/rust-lib/flowy-ai/src/util.rs b/frontend/rust-lib/flowy-ai/src/util.rs new file mode 100644 index 0000000000..a181d1b1d3 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/util.rs @@ -0,0 +1,3 @@ +pub fn ai_available_models_key(object_id: &str) -> String { + format!("ai_models_{}", object_id) +} diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index d1668524cb..b4e7bd5fec 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -32,13 +32,13 @@ collab = { workspace = true } #collab = { workspace = true, features = ["verbose_log"] } diesel.workspace = true -uuid.workspace = true flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } client-api.workspace = true flowy-ai = { workspace = true } flowy-ai-pub = { workspace = true } -appflowy-local-ai = { version = "0.1.0" } +af-local-ai = { workspace = true } +af-plugin = { workspace = true } tracing.workspace = true @@ -56,10 +56,10 @@ lib-infra = { workspace = true } serde.workspace = true serde_json.workspace = true serde_repr.workspace = true -futures.workspace = true -walkdir = "2.4.0" +uuid.workspace = true sysinfo = "0.30.5" semver = { version = "1.0.22", features = ["serde"] } +url = "2.5.0" [features] profiling = ["console-subscriber", "tokio/tracing"] @@ -74,14 +74,6 @@ dart = [ "flowy-ai/dart", "flowy-storage/dart", ] -ts = [ - "flowy-user/tauri_ts", - "flowy-folder/tauri_ts", - "flowy-search/tauri_ts", - "flowy-database2/ts", - "flowy-ai/tauri_ts", - "flowy-storage/tauri_ts", -] openssl_vendored = ["flowy-sqlite/openssl_vendored"] # Enable/Disable AppFlowy Verbose Log Configuration diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 2b379ab63a..2bad578627 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -1,9 +1,10 @@ use std::fmt; -use std::path::Path; +use std::path::{Path, PathBuf}; use base64::Engine; use semver::Version; use tracing::{error, info}; +use url::Url; use crate::log_filter::create_log_filter; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; @@ -28,7 +29,25 @@ pub struct AppFlowyCoreConfig { pub(crate) log_filter: String, pub cloud_config: Option, } +impl AppFlowyCoreConfig { + pub fn ensure_path(&self) { + let create_if_needed = |path_str: &str, label: &str| { + let dir = std::path::Path::new(path_str); + if !dir.exists() { + match std::fs::create_dir_all(dir) { + Ok(_) => info!("Created {} path: {}", label, path_str), + Err(err) => error!( + "Failed to create {} path: {}. Error: {}", + label, path_str, err + ), + } + } + }; + create_if_needed(&self.storage_path, "storage"); + create_if_needed(&self.application_path, "application"); + } +} impl fmt::Debug for AppFlowyCoreConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = f.debug_struct("AppFlowy Configuration"); @@ -46,30 +65,60 @@ impl fmt::Debug for AppFlowyCoreConfig { } fn make_user_data_folder(root: &str, url: &str) -> String { - // Isolate the user data folder by using the base url of AppFlowy cloud. This is to avoid - // the user data folder being shared by different AppFlowy cloud. - let storage_path = if !url.is_empty() { - let server_base64 = URL_SAFE_ENGINE.encode(url); - format!("{}_{}", root, server_base64) + // If a URL is provided, try to parse it and extract the domain name. + // This isolates the user data folder by the domain, which prevents data sharing + // between different AppFlowy cloud instances. + print!("Creating user data folder for URL: {}, root:{}", url, root); + let mut storage_path = if url.is_empty() { + PathBuf::from(root) } else { - root.to_string() + let server_base64 = URL_SAFE_ENGINE.encode(url); + PathBuf::from(format!("{}_{}", root, server_base64)) }; + // Only use new storage path if the old one doesn't exist + if !storage_path.exists() { + let anon_path = format!("{}_anonymous", root); + // We use domain name as suffix to isolate the user data folder since version 0.8.9 + let new_storage_path = if url.is_empty() { + // if the url is empty, then it's anonymous mode + anon_path + } else { + match Url::parse(url) { + Ok(parsed_url) => { + if let Some(domain) = parsed_url.host_str() { + format!("{}_{}", root, domain) + } else { + anon_path + } + }, + Err(_) => anon_path, + } + }; + + storage_path = PathBuf::from(new_storage_path); + } + // Copy the user data folder from the root path to the isolated path // The root path without any suffix is the created by the local version AppFlowy - if !Path::new(&storage_path).exists() && Path::new(root).exists() { - info!("Copy dir from {} to {}", root, storage_path); + if !storage_path.exists() && Path::new(root).exists() { + info!("Copy dir from {} to {:?}", root, storage_path); let src = Path::new(root); - match copy_dir_recursive(src, Path::new(&storage_path)) { - Ok(_) => storage_path, + match copy_dir_recursive(src, &storage_path) { + Ok(_) => storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()), Err(err) => { - // when the copy dir failed, use the root path as the storage path error!("Copy dir failed: {}", err); root.to_string() }, } } else { storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()) } } @@ -83,15 +132,11 @@ impl AppFlowyCoreConfig { name: String, ) -> Self { let cloud_config = AFCloudConfiguration::from_env().ok(); - let mut log_crates = vec![]; + // By default enable sync trace log + let log_crates = vec!["sync_trace_log".to_string()]; let storage_path = match &cloud_config { None => custom_application_path, - Some(config) => { - if config.enable_sync_trace { - log_crates.push("sync_trace_log".to_string()); - } - make_user_data_folder(&custom_application_path, &config.base_url) - }, + Some(config) => make_user_data_folder(&custom_application_path, &config.base_url), }; let log_filter = create_log_filter( diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs index f9e4befeb0..c8c93a7f4c 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -6,8 +6,9 @@ use collab::util::is_change_since_sv; use collab_entity::CollabType; use collab_integrate::persistence::collab_metadata_sql::AFCollabMetadata; use flowy_ai::ai_manager::{AIExternalService, AIManager, AIUserService}; +use flowy_ai::local_ai::controller::LocalAIController; use flowy_ai_pub::cloud::ChatCloudService; -use flowy_error::FlowyError; +use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::ViewLayout; use flowy_folder_pub::cloud::{FolderCloudService, FullSyncCollabParams}; use flowy_folder_pub::query::FolderService; @@ -21,6 +22,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Weak}; use tracing::{error, info}; +use uuid::Uuid; pub struct ChatDepsResolver; @@ -32,6 +34,7 @@ impl ChatDepsResolver { storage_service: Weak, folder_cloud_service: Arc, folder_service: impl FolderService, + local_ai: Arc, ) -> Arc { let user_service = ChatUserServiceImpl(authenticate_user); Arc::new(AIManager::new( @@ -43,6 +46,7 @@ impl ChatDepsResolver { folder_service: Box::new(folder_service), folder_cloud_service, }, + local_ai, )) } } @@ -56,9 +60,9 @@ struct ChatQueryServiceImpl { impl AIExternalService for ChatQueryServiceImpl { async fn query_chat_rag_ids( &self, - parent_view_id: &str, - chat_id: &str, - ) -> Result, FlowyError> { + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError> { let mut ids = self .folder_service .get_surrounding_view_ids_with_view_layout(parent_view_id, ViewLayout::Document) @@ -72,9 +76,9 @@ impl AIExternalService for ChatQueryServiceImpl { } async fn sync_rag_documents( &self, - workspace_id: &str, - rag_ids: Vec, - mut rag_metadata_map: HashMap, + workspace_id: &Uuid, + rag_ids: Vec, + mut rag_metadata_map: HashMap, ) -> Result, FlowyError> { let mut result = Vec::new(); @@ -96,7 +100,7 @@ impl AIExternalService for ChatQueryServiceImpl { if let Ok(prev_sv) = StateVector::decode_v1(&metadata.prev_sync_state_vector) { let collab = Collab::new_with_source( CollabOrigin::Empty, - &rag_id, + &rag_id.to_string(), DataSource::DocStateV1(query_collab.encoded_collab.doc_state.to_vec()), vec![], false, @@ -111,7 +115,7 @@ impl AIExternalService for ChatQueryServiceImpl { // Perform full sync if changes are detected or no state vector is found let params = FullSyncCollabParams { - object_id: rag_id.clone(), + object_id: rag_id, collab_type: CollabType::Document, encoded_collab: query_collab.encoded_collab.clone(), }; @@ -125,7 +129,7 @@ impl AIExternalService for ChatQueryServiceImpl { } else { info!("[Chat] full sync rag document: {}", rag_id); result.push(AFCollabMetadata { - object_id: rag_id, + object_id: rag_id.to_string(), updated_at: timestamp(), prev_sync_state_vector: query_collab.encoded_collab.state_vector.to_vec(), collab_type: CollabType::Document as i32, @@ -136,7 +140,7 @@ impl AIExternalService for ChatQueryServiceImpl { Ok(result) } - async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError> { + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError> { info!( "notify_did_send_message: chat_id: {}, message: {}", chat_id, message @@ -160,16 +164,17 @@ impl ChatUserServiceImpl { } } +#[async_trait] impl AIUserService for ChatUserServiceImpl { fn user_id(&self) -> Result { self.upgrade_user()?.user_id() } - fn device_id(&self) -> Result { - self.upgrade_user()?.device_id() + async fn is_local_model(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs index b01ea49f82..35300563d7 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -1,29 +1,22 @@ +use crate::server_layer::ServerProvider; use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; use client_api::entity::ai_dto::RepeatedRelatedQuestion; -use client_api::entity::search_dto::SearchDocumentResponseItem; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; use collab::core::origin::{CollabClient, CollabOrigin}; use collab::entity::EncodedCollab; use collab::preclude::CollabPlugin; use collab_entity::CollabType; -use flowy_search_pub::cloud::SearchCloudService; -use serde_json::Value; -use std::collections::HashMap; -use std::path::Path; -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::time::Duration; -use tokio_stream::wrappers::WatchStream; -use tracing::{debug, info}; - use collab_integrate::collab_builder::{ CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, }; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, LocalAIConfig, MessageCursor, RepeatedChatMessage, ResponseFormat, - StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, + UpdateChatParams, }; use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, @@ -37,14 +30,24 @@ use flowy_folder_pub::cloud::{ Workspace, WorkspaceRecord, }; use flowy_folder_pub::entities::PublishPayload; +use flowy_search_pub::cloud::SearchCloudService; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserTokenState}; +use flowy_user_pub::entities::{AuthType, UserTokenState}; use lib_infra::async_trait::async_trait; - -use crate::server_layer::{Server, ServerProvider}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio_stream::wrappers::WatchStream; +use tracing::log::error; +use tracing::{debug, info}; +use uuid::Uuid; #[async_trait] impl StorageCloudService for ServerProvider { @@ -82,7 +85,7 @@ impl StorageCloudService for ServerProvider { async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, ) -> FlowyResult { @@ -93,7 +96,7 @@ impl StorageCloudService for ServerProvider { .await } - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)> { + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { self .get_server() .ok()? @@ -104,7 +107,7 @@ impl StorageCloudService for ServerProvider { async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -119,7 +122,7 @@ impl StorageCloudService for ServerProvider { async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -142,7 +145,7 @@ impl StorageCloudService for ServerProvider { async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -183,18 +186,18 @@ impl UserCloudServiceProvider for ServerProvider { } } - /// When user login, the provider type is set by the [Authenticator] and save to disk for next use. + /// When user login, the provider type is set by the [AuthType] and save to disk for next use. /// - /// Each [Authenticator] has a corresponding [Server]. The [Server] is used - /// to create a new [AppFlowyServer] if it doesn't exist. Once the [Server] is set, + /// Each [AuthType] has a corresponding [AuthType]. The [AuthType] is used + /// to create a new [AppFlowyServer] if it doesn't exist. Once the [AuthType] is set, /// it will be used when user open the app again. /// - fn set_user_authenticator(&self, authenticator: &Authenticator) { - self.set_authenticator(authenticator.clone()); + fn set_server_auth_type(&self, auth_type: &AuthType) { + self.set_auth_type(*auth_type); } - fn get_user_authenticator(&self) -> Authenticator { - self.get_authenticator() + fn get_server_auth_type(&self) -> AuthType { + self.get_auth_type() } fn set_network_reachable(&self, reachable: bool) { @@ -208,7 +211,7 @@ impl UserCloudServiceProvider for ServerProvider { self.encryption.set_secret(secret); } - /// Returns the [UserCloudService] base on the current [Server]. + /// Returns the [UserCloudService] base on the current [AuthType]. /// Creates a new [AppFlowyServer] if it doesn't exist. fn get_user_service(&self) -> Result, FlowyError> { let user_service = self.get_server()?.user_service(); @@ -216,9 +219,9 @@ impl UserCloudServiceProvider for ServerProvider { } fn service_url(&self) -> String { - match self.get_server_type() { - Server::Local => "".to_string(), - Server::AppFlowyCloud => AFCloudConfiguration::from_env() + match self.get_auth_type() { + AuthType::Local => "".to_string(), + AuthType::AppFlowyCloud => AFCloudConfiguration::from_env() .map(|config| config.base_url) .unwrap_or_default(), } @@ -233,10 +236,9 @@ impl FolderCloudService for ServerProvider { server.folder_service().create_workspace(uid, &name).await } - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let server = self.get_server()?; - server.folder_service().open_workspace(&workspace_id).await + server.folder_service().open_workspace(workspace_id).await } async fn get_all_workspace(&self) -> Result, FlowyError> { @@ -246,7 +248,7 @@ impl FolderCloudService for ServerProvider { async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError> { let server = self.get_server()?; @@ -272,10 +274,10 @@ impl FolderCloudService for ServerProvider { async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; @@ -287,7 +289,7 @@ impl FolderCloudService for ServerProvider { async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -307,7 +309,7 @@ impl FolderCloudService for ServerProvider { async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -320,8 +322,8 @@ impl FolderCloudService for ServerProvider { async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { let server = self.get_server()?; server @@ -330,15 +332,15 @@ impl FolderCloudService for ServerProvider { .await } - async fn get_publish_info(&self, view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { let server = self.get_server()?; server.folder_service().get_publish_info(view_id).await } async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -350,7 +352,7 @@ impl FolderCloudService for ServerProvider { async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -360,7 +362,7 @@ impl FolderCloudService for ServerProvider { .await } - async fn get_publish_namespace(&self, workspace_id: &str) -> Result { + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { let server = self.get_server()?; server .folder_service() @@ -371,7 +373,7 @@ impl FolderCloudService for ServerProvider { /// List all published views of the current workspace. async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -382,7 +384,7 @@ impl FolderCloudService for ServerProvider { async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let server = self.get_server()?; server @@ -393,7 +395,7 @@ impl FolderCloudService for ServerProvider { async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -403,7 +405,7 @@ impl FolderCloudService for ServerProvider { .await } - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let server = self.get_server()?; server .folder_service() @@ -421,7 +423,7 @@ impl FolderCloudService for ServerProvider { async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError> { self @@ -436,24 +438,22 @@ impl FolderCloudService for ServerProvider { impl DatabaseCloudService for ServerProvider { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; - let database_id = object_id.to_string(); server .database_service() - .get_database_encode_collab(&database_id, collab_type, &workspace_id) + .get_database_encode_collab(object_id, collab_type, workspace_id) .await } async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -465,30 +465,28 @@ impl DatabaseCloudService for ServerProvider { async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; server .database_service() - .batch_get_database_encode_collab(object_ids, object_ty, &workspace_id) + .batch_get_database_encode_collab(object_ids, object_ty, workspace_id) .await } async fn get_database_collab_object_snapshots( &self, - object_id: &str, + object_id: &Uuid, limit: usize, ) -> Result, FlowyError> { let server = self.get_server()?; - let database_id = object_id.to_string(); server .database_service() - .get_database_collab_object_snapshots(&database_id, limit) + .get_database_collab_object_snapshots(object_id, limit) .await } } @@ -497,29 +495,29 @@ impl DatabaseCloudService for ServerProvider { impl DatabaseAIService for ServerProvider { async fn summary_database_row( &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, + _workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { self .get_server()? .database_ai_service() .ok_or_else(FlowyError::not_support)? - .summary_database_row(workspace_id, object_id, summary_row) + .summary_database_row(_workspace_id, _object_id, _summary_row) .await } async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { self .get_server()? .database_ai_service() .ok_or_else(FlowyError::not_support)? - .translate_database_row(workspace_id, translate_row, language) + .translate_database_row(_workspace_id, _translate_row, _language) .await } } @@ -528,8 +526,8 @@ impl DatabaseAIService for ServerProvider { impl DocumentCloudService for ServerProvider { async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -540,7 +538,7 @@ impl DocumentCloudService for ServerProvider { async fn get_document_snapshots( &self, - document_id: &str, + document_id: &Uuid, limit: usize, workspace_id: &str, ) -> Result, FlowyError> { @@ -554,8 +552,8 @@ impl DocumentCloudService for ServerProvider { async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let server = self.get_server()?; server @@ -566,8 +564,8 @@ impl DocumentCloudService for ServerProvider { async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let server = self.get_server()?; @@ -580,12 +578,15 @@ impl DocumentCloudService for ServerProvider { impl CollabCloudPluginProvider for ServerProvider { fn provider_type(&self) -> CollabPluginProviderType { - self.get_server_type().into() + match self.get_auth_type() { + AuthType::Local => CollabPluginProviderType::Local, + AuthType::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, + } } fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { // If the user is local, we don't need to create a sync plugin. - if self.get_server_type().is_local() { + if self.get_auth_type().is_local() { debug!( "User authenticator is local, skip create sync plugin for: {}", context @@ -611,26 +612,37 @@ impl CollabCloudPluginProvider for ServerProvider { collab_object.uid, collab_object.device_id.clone(), )); - let sync_object = SyncObject::new( - &collab_object.object_id, - &collab_object.workspace_id, - collab_object.collab_type, - &collab_object.device_id, - ); - let (sink, stream) = (channel.sink(), channel.stream()); - let sink_config = SinkConfig::new().send_timeout(8); - let sync_plugin = SyncPlugin::new( - origin, - sync_object, - local_collab, - sink, - sink_config, - stream, - Some(channel), - ws_connect_state, - Some(Duration::from_secs(60)), - ); - plugins.push(Box::new(sync_plugin)); + + if let (Ok(object_id), Ok(workspace_id)) = ( + Uuid::from_str(&collab_object.object_id), + Uuid::from_str(&collab_object.workspace_id), + ) { + let sync_object = SyncObject::new( + object_id, + workspace_id, + collab_object.collab_type, + &collab_object.device_id, + ); + let (sink, stream) = (channel.sink(), channel.stream()); + let sink_config = SinkConfig::new().send_timeout(8); + let sync_plugin = SyncPlugin::new( + origin, + sync_object, + local_collab, + sink, + sink_config, + stream, + Some(channel), + ws_connect_state, + Some(Duration::from_secs(60)), + ); + plugins.push(Box::new(sync_plugin)); + } else { + error!( + "Failed to parse collab object id: {}", + collab_object.object_id + ); + } }, Ok(None) => { tracing::error!("🔴Failed to get collab ws channel: channel is none"); @@ -655,39 +667,38 @@ impl ChatCloudService for ServerProvider { async fn create_chat( &self, uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { let server = self.get_server(); server? .chat_service() - .create_chat(uid, workspace_id, chat_id, rag_ids) + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) .await } async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { - let workspace_id = workspace_id.to_string(); - let chat_id = chat_id.to_string(); let message = message.to_string(); self .get_server()? .chat_service() - .create_question(&workspace_id, &chat_id, &message, message_type, metadata) + .create_question(workspace_id, chat_id, &message, message_type) .await } async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -701,24 +712,23 @@ impl ChatCloudService for ServerProvider { async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, - message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { - let workspace_id = workspace_id.to_string(); - let chat_id = chat_id.to_string(); let server = self.get_server()?; server .chat_service() - .stream_answer(&workspace_id, &chat_id, message_id, format) + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) .await } async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { @@ -731,8 +741,8 @@ impl ChatCloudService for ServerProvider { async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { self @@ -744,80 +754,62 @@ impl ChatCloudService for ServerProvider { async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { self .get_server()? .chat_service() - .get_related_message(workspace_id, chat_id, message_id) + .get_related_message(workspace_id, chat_id, message_id, ai_model) .await } async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, - question_message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, ) -> Result { let server = self.get_server(); server? .chat_service() - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer(workspace_id, chat_id, question_id) .await } async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, + ai_model: Option, ) -> Result { - let workspace_id = workspace_id.to_string(); let server = self.get_server()?; server .chat_service() - .stream_complete(&workspace_id, params) + .stream_complete(workspace_id, params, ai_model) .await } - async fn index_file( + async fn embed_file( &self, - workspace_id: &str, + workspace_id: &Uuid, file_path: &Path, - chat_id: &str, + chat_id: &Uuid, metadata: Option>, ) -> Result<(), FlowyError> { self .get_server()? .chat_service() - .index_file(workspace_id, file_path, chat_id, metadata) - .await - } - - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { - self - .get_server()? - .chat_service() - .get_local_ai_config(workspace_id) - .await - } - - async fn get_workspace_plan( - &self, - workspace_id: &str, - ) -> Result, FlowyError> { - self - .get_server()? - .chat_service() - .get_workspace_plan(workspace_id) + .embed_file(workspace_id, file_path, chat_id, metadata) .await } async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { self .get_server()? @@ -828,8 +820,8 @@ impl ChatCloudService for ServerProvider { async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self @@ -838,13 +830,29 @@ impl ChatCloudService for ServerProvider { .update_chat_settings(workspace_id, chat_id, params) .await } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self + .get_server()? + .chat_service() + .get_available_models(workspace_id) + .await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .get_server()? + .chat_service() + .get_workspace_default_model(workspace_id) + .await + } } #[async_trait] impl SearchCloudService for ServerProvider { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError> { let server = self.get_server()?; @@ -853,4 +861,21 @@ impl SearchCloudService for ServerProvider { None => Err(FlowyError::internal().with_context("SearchCloudService not found")), } } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let server = self.get_server()?; + match server.search_service() { + Some(search_service) => { + search_service + .generate_search_summary(workspace_id, query, search_results) + .await + }, + None => Err(FlowyError::internal().with_context("SearchCloudService not found")), + } + } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs index a8827e06b0..078ee7359b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs @@ -13,6 +13,7 @@ use collab_integrate::collab_builder::WorkspaceCollabIntegrate; use lib_infra::util::timestamp; use std::sync::{Arc, Weak}; use tracing::debug; +use uuid::Uuid; pub struct SnapshotDBImpl(pub Weak); @@ -24,7 +25,7 @@ impl SnapshotPersistence for SnapshotDBImpl { collab_type: &CollabType, encoded_v1: Vec, ) -> Result<(), PersistenceError> { - let collab_type = collab_type.clone(); + let collab_type = *collab_type; let object_id = object_id.to_string(); let weak_user = self.0.clone(); tokio::task::spawn_blocking(move || { @@ -222,12 +223,12 @@ impl WorkspaceCollabIntegrateImpl { } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { let workspace_id = self.upgrade_user()?.workspace_id()?; Ok(workspace_id) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok(self.upgrade_user()?.user_config.device_id.clone()) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs index 56ac310300..1bd3223946 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs @@ -1,4 +1,4 @@ -use appflowy_local_ai::ai_ops::{LocalAITranslateItem, LocalAITranslateRowData}; +use af_local_ai::ai_ops::{LocalAITranslateItem, LocalAITranslateRowData}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; use flowy_ai::ai_manager::AIManager; @@ -13,6 +13,7 @@ use lib_infra::async_trait::async_trait; use lib_infra::priority_task::TaskDispatcher; use std::sync::{Arc, Weak}; use tokio::sync::RwLock; +use uuid::Uuid; pub struct DatabaseDepsResolver(); @@ -47,46 +48,46 @@ struct DatabaseAIServiceMiddleware { impl DatabaseAIService for DatabaseAIServiceMiddleware { async fn summary_database_row( &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, + workspace_id: &Uuid, + object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { - if self.ai_manager.local_ai_controller.is_running() { + if self.ai_manager.local_ai.is_running() { self .ai_manager - .local_ai_controller - .summary_database_row(summary_row) + .local_ai + .summary_database_row(_summary_row) .await .map_err(|err| FlowyError::local_ai().with_context(err)) } else { self .ai_service - .summary_database_row(workspace_id, object_id, summary_row) + .summary_database_row(workspace_id, object_id, _summary_row) .await } } async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { - if self.ai_manager.local_ai_controller.is_running() { + if self.ai_manager.local_ai.is_running() { let data = LocalAITranslateRowData { - cells: translate_row + cells: _translate_row .into_iter() .map(|row| LocalAITranslateItem { title: row.title, content: row.content, }) .collect(), - language: language.to_string(), + language: _language.to_string(), include_header: false, }; let resp = self .ai_manager - .local_ai_controller + .local_ai .translate_database_row(data) .await .map_err(|err| FlowyError::local_ai().with_context(err))?; @@ -95,7 +96,7 @@ impl DatabaseAIService for DatabaseAIServiceMiddleware { } else { self .ai_service - .translate_database_row(workspace_id, translate_row, language) + .translate_database_row(_workspace_id, _translate_row, _language) .await } } @@ -121,11 +122,11 @@ impl DatabaseUser for DatabaseUserImpl { self.upgrade_user()?.get_collab_db(uid) } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } - fn workspace_database_object_id(&self) -> Result { + fn workspace_database_object_id(&self) -> Result { self.upgrade_user()?.workspace_database_object_id() } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs index a4203d8268..3527bc42d6 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs @@ -1,5 +1,3 @@ -use std::sync::{Arc, Weak}; - use crate::deps_resolve::CollabSnapshotSql; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; @@ -10,6 +8,8 @@ use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{FlowyError, FlowyResult}; use flowy_storage_pub::storage::StorageService; use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; +use uuid::Uuid; pub struct DocumentDepsResolver(); impl DocumentDepsResolver { @@ -97,7 +97,7 @@ impl DocumentUserService for DocumentUserImpl { .device_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self .0 .upgrade() diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs index f0e6985a78..bee5f19ced 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs @@ -4,6 +4,7 @@ use flowy_storage::manager::{StorageManager, StorageUserService}; use flowy_storage_pub::cloud::StorageCloudService; use flowy_user::services::authenticate_user::AuthenticateUser; use std::sync::{Arc, Weak}; +use uuid::Uuid; pub struct FileStorageResolver; @@ -40,7 +41,7 @@ impl StorageUserService for FileStorageServiceImpl { self.upgrade_user()?.user_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs index 40a7657967..e2791827ee 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs @@ -8,6 +8,7 @@ use flowy_folder::share::ImportType; use flowy_folder::view_operation::{FolderOperationHandler, ImportedData}; use lib_infra::async_trait::async_trait; use std::sync::Arc; +use uuid::Uuid; pub struct ChatFolderOperation(pub Arc); @@ -17,20 +18,20 @@ impl FolderOperationHandler for ChatFolderOperation { "ChatFolderOperationHandler" } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_chat(view_id).await } - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.close_chat(view_id).await } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.delete_chat(view_id).await } - async fn duplicate_view(&self, _view_id: &str) -> Result { - Err(FlowyError::not_support()) + async fn duplicate_view(&self, _view_id: &Uuid) -> Result { + Err(FlowyError::not_support().with_context("Duplicate view")) } async fn create_view_with_view_data( @@ -38,14 +39,14 @@ impl FolderOperationHandler for ChatFolderOperation { _user_id: i64, _params: CreateViewParams, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("Can't create view")) } async fn create_default_view( &self, user_id: i64, - parent_view_id: &str, - view_id: &str, + parent_view_id: &Uuid, + view_id: &Uuid, _name: &str, _layout: ViewLayout, ) -> Result<(), FlowyError> { @@ -59,12 +60,12 @@ impl FolderOperationHandler for ChatFolderOperation { async fn import_from_bytes( &self, _uid: i64, - _view_id: &str, + _view_id: &Uuid, _name: &str, _import_type: ImportType, _bytes: Vec, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("import from data")) } async fn import_from_file_path( @@ -73,6 +74,6 @@ impl FolderOperationHandler for ChatFolderOperation { _name: &str, _path: String, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Err(FlowyError::not_support().with_context("import file from path")) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs index d7d0c4d0cc..edc40c6d5b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use bytes::Bytes; use collab::entity::EncodedCollab; use collab_entity::CollabType; @@ -19,23 +20,31 @@ use lib_infra::async_trait::async_trait; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use uuid::Uuid; pub struct DatabaseFolderOperation(pub Arc); #[async_trait] impl FolderOperationHandler for DatabaseFolderOperation { - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_database_view(view_id).await?; Ok(()) } - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { - self.0.close_database_view(view_id).await?; + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self + .0 + .close_database_view(view_id.to_string().as_str()) + .await?; Ok(()) } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { - match self.0.delete_database_view(view_id).await { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + match self + .0 + .delete_database_view(view_id.to_string().as_str()) + .await + { Ok(_) => tracing::trace!("Delete database view: {}", view_id), Err(e) => tracing::error!("🔴delete database failed: {}", e), } @@ -44,16 +53,20 @@ impl FolderOperationHandler for DatabaseFolderOperation { async fn gather_publish_encode_collab( &self, - user: &Arc, - view_id: &str, + _user: &Arc, + view_id: &Uuid, ) -> Result { - let workspace_id = user.workspace_id()?; + let workspace_id = _user.workspace_id()?; + let view_id_str = view_id.to_string(); // get the collab_object_id for the database. // // the collab object_id for the database is not the view_id, // we should use the view_id to get the database_id - let oid = self.0.get_database_id_with_view_id(view_id).await?; - let row_oids = self.0.get_database_row_ids_with_view_id(view_id).await?; + let oid = self.0.get_database_id_with_view_id(&view_id_str).await?; + let row_oids = self + .0 + .get_database_row_ids_with_view_id(&view_id_str) + .await?; let row_metas = self .0 .get_database_row_metas_with_view_id(view_id, row_oids.clone()) @@ -68,12 +81,12 @@ impl FolderOperationHandler for DatabaseFolderOperation { .collect::>(); let database_metas = self.0.get_all_databases_meta().await; - let uid = user + let uid = _user .user_id() .map_err(|e| e.with_context("unable to get the uid: {}"))?; // get the collab db - let collab_db = user + let collab_db = _user .collab_db(uid) .map_err(|e| e.with_context("unable to get the collab"))?; let collab_db = collab_db.upgrade().ok_or_else(|| { @@ -84,7 +97,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { tokio::task::spawn_blocking(move || { let collab_read_txn = collab_db.read_txn(); - let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id, &oid) + let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), &oid) .map_err(|e| { FlowyError::internal().with_context(format!("load database collab failed: {}", e)) })?; @@ -97,7 +110,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { })?; let database_row_encoded_collabs = - load_collab_by_object_ids(uid, &workspace_id, &collab_read_txn, &row_oids) + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_oids) .0 .into_iter() .map(|(oid, collab)| { @@ -123,7 +136,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { .collect::>(); let database_row_document_encoded_collabs = - load_collab_by_object_ids(uid, &workspace_id, &collab_read_txn, &row_document_ids) + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_document_ids) .0 .into_iter() .map(|(oid, collab)| { @@ -147,7 +160,7 @@ impl FolderOperationHandler for DatabaseFolderOperation { .await? } - async fn duplicate_view(&self, view_id: &str) -> Result { + async fn duplicate_view(&self, view_id: &Uuid) -> Result { Ok(Bytes::from(view_id.to_string())) } @@ -166,14 +179,14 @@ impl FolderOperationHandler for DatabaseFolderOperation { String::from_utf8(data.to_vec()).map_err(|_| FlowyError::invalid_data())?; let encoded_collab = self .0 - .duplicate_database(&duplicated_view_id, ¶ms.view_id) + .duplicate_database(&duplicated_view_id, ¶ms.view_id.to_string()) .await?; Ok(Some(encoded_collab)) }, ViewData::Data(data) => { let encoded_collab = self .0 - .create_database_with_data(¶ms.view_id, data.to_vec()) + .create_database_with_data(¶ms.view_id.to_string(), data.to_vec()) .await?; Ok(Some(encoded_collab)) }, @@ -185,7 +198,9 @@ impl FolderOperationHandler for DatabaseFolderOperation { ViewLayoutPB::Calendar => DatabaseLayoutPB::Calendar, ViewLayoutPB::Grid => DatabaseLayoutPB::Grid, ViewLayoutPB::Document | ViewLayoutPB::Chat => { - return Err(FlowyError::not_support()); + return Err( + FlowyError::invalid_data().with_context("Can't handle document layout type"), + ); }, }; let name = params.name.to_string(); @@ -212,17 +227,18 @@ impl FolderOperationHandler for DatabaseFolderOperation { /// these references views. async fn create_default_view( &self, - _user_id: i64, - _parent_view_id: &str, - view_id: &str, + user_id: i64, + parent_view_id: &Uuid, + view_id: &Uuid, name: &str, layout: ViewLayout, ) -> Result<(), FlowyError> { let name = name.to_string(); + let view_id = view_id.to_string(); let data = match layout { - ViewLayout::Grid => make_default_grid(view_id, &name), - ViewLayout::Board => make_default_board(view_id, &name), - ViewLayout::Calendar => make_default_calendar(view_id, &name), + ViewLayout::Grid => make_default_grid(&view_id, &name), + ViewLayout::Board => make_default_board(&view_id, &name), + ViewLayout::Calendar => make_default_calendar(&view_id, &name), ViewLayout::Document | ViewLayout::Chat => { return Err( FlowyError::internal().with_context(format!("Can't handle {:?} layout type", layout)), @@ -244,9 +260,9 @@ impl FolderOperationHandler for DatabaseFolderOperation { async fn import_from_bytes( &self, - _uid: i64, - view_id: &str, - _name: &str, + uid: i64, + view_id: &Uuid, + name: &str, import_type: ImportType, bytes: Vec, ) -> Result, FlowyError> { diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs index af95b8987d..a843a8eb1f 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs @@ -17,8 +17,10 @@ use flowy_folder::view_operation::{ use lib_dispatch::prelude::ToBytes; use lib_infra::async_trait::async_trait; use std::convert::TryFrom; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; +use uuid::Uuid; pub struct DocumentFolderOperation(pub Arc); #[async_trait] @@ -33,6 +35,7 @@ impl FolderOperationHandler for DocumentFolderOperation { workspace_view_builder: Arc>, ) -> Result<(), FlowyError> { let manager = self.0.clone(); + let mut write_guard = workspace_view_builder.write().await; // Create a view named "Getting started" with an icon ⭐️ and the built-in README data. // Don't modify this code unless you know what you are doing. @@ -45,8 +48,9 @@ impl FolderOperationHandler for DocumentFolderOperation { // create a empty document let json_str = include_str!("../../../assets/read_me.json"); let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); + let view_id = Uuid::from_str(&view.view.id).unwrap(); manager - .create_document(uid, &view.view.id, Some(document_pb.into())) + .create_document(uid, &view_id, Some(document_pb.into())) .await .unwrap(); view @@ -55,18 +59,18 @@ impl FolderOperationHandler for DocumentFolderOperation { Ok(()) } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.open_document(view_id).await?; Ok(()) } /// Close the document view. - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { self.0.close_document(view_id).await?; Ok(()) } - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError> { + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { match self.0.delete_document(view_id).await { Ok(_) => tracing::trace!("Delete document: {}", view_id), Err(e) => tracing::error!("🔴delete document failed: {}", e), @@ -74,7 +78,7 @@ impl FolderOperationHandler for DocumentFolderOperation { Ok(()) } - async fn duplicate_view(&self, view_id: &str) -> Result { + async fn duplicate_view(&self, view_id: &Uuid) -> Result { let data: DocumentDataPB = self.0.get_document_data(view_id).await?.into(); let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?; Ok(data_bytes) @@ -83,10 +87,11 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn gather_publish_encode_collab( &self, user: &Arc, - view_id: &str, + view_id: &Uuid, ) -> Result { let encoded_collab = - get_encoded_collab_v1_from_disk(user, view_id, CollabType::Document).await?; + get_encoded_collab_v1_from_disk(user, view_id.to_string().as_str(), CollabType::Document) + .await?; Ok(GatherEncodedCollab::Document(encoded_collab)) } @@ -112,8 +117,8 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn create_default_view( &self, user_id: i64, - _parent_view_id: &str, - view_id: &str, + _parent_view_id: &Uuid, + view_id: &Uuid, _name: &str, layout: ViewLayout, ) -> Result<(), FlowyError> { @@ -133,7 +138,7 @@ impl FolderOperationHandler for DocumentFolderOperation { async fn import_from_bytes( &self, uid: i64, - view_id: &str, + view_id: &Uuid, _name: &str, _import_type: ImportType, bytes: Vec, diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs index 9bed61d918..02b26e71b6 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs @@ -17,6 +17,7 @@ use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_sqlite::kv::KVStorePreferences; use flowy_user::services::authenticate_user::AuthenticateUser; use flowy_user::services::data_import::load_collab_by_object_id; +use std::str::FromStr; use std::sync::{Arc, Weak}; use crate::deps_resolve::folder_deps::folder_deps_chat_impl::ChatFolderOperation; @@ -25,6 +26,7 @@ use crate::deps_resolve::folder_deps::folder_deps_doc_impl::DocumentFolderOperat use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_folder_pub::query::{FolderQueryService, FolderService, FolderViewEdit, QueryCollab}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct FolderDepsResolver(); #[allow(clippy::too_many_arguments)] @@ -89,7 +91,7 @@ impl FolderUser for FolderUserImpl { self.upgrade_user()?.user_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } @@ -97,8 +99,10 @@ impl FolderUser for FolderUserImpl { self.upgrade_user()?.get_collab_db(uid) } - fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &str) -> FlowyResult { - self.upgrade_user()?.is_collab_on_disk(uid, workspace_id) + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult { + self + .upgrade_user()? + .is_collab_on_disk(uid, workspace_id.to_string().as_str()) } } @@ -124,13 +128,13 @@ impl FolderServiceImpl { #[async_trait] impl FolderViewEdit for FolderServiceImpl { - async fn set_view_title_if_empty(&self, view_id: &str, title: &str) -> FlowyResult<()> { + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()> { if title.is_empty() { return Ok(()); } if let Some(folder_manager) = self.folder_manager.upgrade() { - if let Ok(view) = folder_manager.get_view(view_id).await { + if let Ok(view) = folder_manager.get_view(view_id.to_string().as_str()).await { if view.name.is_empty() { let title = if title.len() > 50 { title.chars().take(50).collect() @@ -160,22 +164,25 @@ impl FolderViewEdit for FolderServiceImpl { impl FolderQueryService for FolderServiceImpl { async fn get_surrounding_view_ids_with_view_layout( &self, - parent_view_id: &str, + parent_view_id: &Uuid, view_layout: ViewLayout, - ) -> Vec { + ) -> Vec { let folder_manager = match self.folder_manager.upgrade() { Some(folder_manager) => folder_manager, None => return vec![], }; - if let Ok(view) = folder_manager.get_view(parent_view_id).await { + if let Ok(view) = folder_manager + .get_view(parent_view_id.to_string().as_str()) + .await + { if view.space_info().is_some() { return vec![]; } } match folder_manager - .get_untrashed_views_belong_to(parent_view_id) + .get_untrashed_views_belong_to(parent_view_id.to_string().as_str()) .await { Ok(views) => { @@ -183,23 +190,24 @@ impl FolderQueryService for FolderServiceImpl { .into_iter() .filter_map(|child| { if child.layout == view_layout { - Some(child.id.clone()) + Uuid::from_str(&child.id).ok() } else { None } }) .collect::>(); - children.push(parent_view_id.to_string()); + children.push(*parent_view_id); children }, _ => vec![], } } - async fn get_collab(&self, object_id: &str, collab_type: CollabType) -> Option { - let encode_collab = get_encoded_collab_v1_from_disk(&self.user, object_id, collab_type.clone()) - .await - .ok(); + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option { + let encode_collab = + get_encoded_collab_v1_from_disk(&self.user, object_id.to_string().as_str(), collab_type) + .await + .ok(); encode_collab.map(|encoded_collab| QueryCollab { collab_type, @@ -229,8 +237,8 @@ async fn get_encoded_collab_v1_from_disk( ) })?; let collab_read_txn = collab_db.read_txn(); - let collab = - load_collab_by_object_id(uid, &collab_read_txn, &workspace_id, view_id).map_err(|e| { + let collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), view_id) + .map_err(|e| { FlowyError::internal().with_context(format!("load document collab failed: {}", e)) })?; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs index b6179a1ad8..73c2844a23 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs @@ -13,6 +13,7 @@ use lib_infra::async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; use tracing::info; +use uuid::Uuid; pub struct UserDepsResolver(); @@ -81,12 +82,13 @@ impl UserWorkspaceService for UserWorkspaceServiceImpl { Ok(()) } - fn did_delete_workspace(&self, workspace_id: String) -> FlowyResult<()> { + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { // The remove_indices_for_workspace should not block the deletion of the workspace // Log the error and continue if let Err(err) = self .folder_manager .remove_indices_for_workspace(workspace_id) + .await { info!("Error removing indices for workspace: {}", err); } diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 4c84e73c08..c2800bd73b 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,20 +1,22 @@ #![allow(unused_doc_comments)] -use flowy_search::folder::indexer::FolderIndexManagerImpl; -use flowy_search::services::manager::SearchManager; -use std::sync::{Arc, Weak}; -use std::time::Duration; -use sysinfo::System; -use tokio::sync::RwLock; -use tracing::{debug, error, event, info, instrument}; - -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabPluginProviderType}; +use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::CollabKVDB; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::FolderManager; -use flowy_server::af_cloud::define::ServerUser; +use flowy_search::folder::indexer::FolderIndexManagerImpl; +use flowy_search::services::manager::SearchManager; +use flowy_server::af_cloud::define::LoggedUser; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use sysinfo::System; +use tokio::sync::RwLock; +use tracing::{debug, error, event, info, instrument}; +use uuid::Uuid; use flowy_sqlite::kv::KVStorePreferences; use flowy_storage::manager::StorageManager; @@ -33,8 +35,10 @@ use crate::config::AppFlowyCoreConfig; use crate::deps_resolve::file_storage_deps::FileStorageResolver; use crate::deps_resolve::*; use crate::log_filter::init_log; -use crate::server_layer::{current_server_type, Server, ServerProvider}; +use crate::server_layer::ServerProvider; use deps_resolve::reminder_deps::CollabInteractImpl; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; use user_state_callback::UserStatusCallbackImpl; pub mod config; @@ -105,6 +109,8 @@ impl AppFlowyCore { #[instrument(skip(config, runtime))] async fn init(config: AppFlowyCoreConfig, runtime: Arc) -> Self { + config.ensure_path(); + // Init the key value database let store_preference = Arc::new(KVStorePreferences::new(&config.storage_path).unwrap()); info!("🔥{:?}", &config); @@ -126,12 +132,10 @@ impl AppFlowyCore { store_preference.clone(), )); - let server_type = current_server_type(); - debug!("🔥runtime:{}, server:{}", runtime, server_type); + debug!("🔥runtime:{}", runtime); let server_provider = Arc::new(ServerProvider::new( config.clone(), - server_type, Arc::downgrade(&store_preference), ServerUserImpl(Arc::downgrade(&authenticate_user)), )); @@ -163,9 +167,9 @@ impl AppFlowyCore { collab_builder .set_snapshot_persistence(Arc::new(SnapshotDBImpl(Arc::downgrade(&authenticate_user)))); - let folder_indexer = Arc::new(FolderIndexManagerImpl::new(Some(Arc::downgrade( + let folder_indexer = Arc::new(FolderIndexManagerImpl::new(Arc::downgrade( &authenticate_user, - )))); + ))); let folder_manager = FolderDepsResolver::resolve( Arc::downgrade(&authenticate_user), @@ -188,6 +192,7 @@ impl AppFlowyCore { Arc::downgrade(&storage_manager.storage_service), server_provider.clone(), folder_query_service.clone(), + server_provider.local_ai.clone(), ); let database_manager = DatabaseDepsResolver::resolve( @@ -248,6 +253,7 @@ impl AppFlowyCore { .await; let user_status_callback = UserStatusCallbackImpl { + user_manager: user_manager.clone(), collab_builder, folder_manager: folder_manager.clone(), database_manager: database_manager.clone(), @@ -255,6 +261,7 @@ impl AppFlowyCore { server_provider: server_provider.clone(), storage_manager: storage_manager.clone(), ai_manager: ai_manager.clone(), + runtime: runtime.clone(), }; let collab_interact_impl = CollabInteractImpl { @@ -307,15 +314,6 @@ impl AppFlowyCore { } } -impl From for CollabPluginProviderType { - fn from(server_type: Server) -> Self { - match server_type { - Server::Local => CollabPluginProviderType::Local, - Server::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, - } - } -} - struct ServerUserImpl(Weak); impl ServerUserImpl { @@ -327,8 +325,32 @@ impl ServerUserImpl { Ok(user) } } -impl ServerUser for ServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + +#[async_trait] +impl LoggedUser for ServerUserImpl { + fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } + + fn user_id(&self) -> FlowyResult { + self.upgrade_user()?.user_id() + } + + async fn is_local_mode(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await + } + + fn get_sqlite_db(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } + + fn get_collab_db(&self, uid: i64) -> Result, FlowyError> { + self.upgrade_user()?.get_collab_db(uid) + } + + fn application_root_dir(&self) -> Result { + Ok(PathBuf::from( + self.upgrade_user()?.get_application_root_dir(), + )) + } } diff --git a/frontend/rust-lib/flowy-core/src/log_filter.rs b/frontend/rust-lib/flowy-core/src/log_filter.rs index 63877862f0..6704ad0507 100644 --- a/frontend/rust-lib/flowy-core/src/log_filter.rs +++ b/frontend/rust-lib/flowy-core/src/log_filter.rs @@ -57,8 +57,8 @@ pub fn create_log_filter( filters.push(format!("lib_infra={}", level)); filters.push(format!("flowy_search={}", level)); filters.push(format!("flowy_chat={}", level)); - filters.push(format!("appflowy_local_ai={}", level)); - filters.push(format!("appflowy_plugin={}", level)); + filters.push(format!("af_local_ai={}", level)); + filters.push(format!("af_plugin={}", level)); filters.push(format!("flowy_ai={}", level)); filters.push(format!("flowy_storage={}", level)); // Enable the frontend logs. DO NOT DISABLE. diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs index 0d304c6063..b666ab4749 100644 --- a/frontend/rust-lib/flowy-core/src/server_layer.rs +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -1,194 +1,134 @@ -use arc_swap::ArcSwapOption; +use crate::AppFlowyCoreConfig; +use af_plugin::manager::PluginManager; +use arc_swap::{ArcSwap, ArcSwapOption}; +use dashmap::mapref::one::Ref; use dashmap::DashMap; -use std::fmt::{Display, Formatter}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::{Arc, Weak}; - -use serde_repr::*; - +use flowy_ai::local_ai::controller::LocalAIController; use flowy_error::{FlowyError, FlowyResult}; -use flowy_server::af_cloud::define::ServerUser; -use flowy_server::af_cloud::AppFlowyCloudServer; -use flowy_server::local_server::{LocalServer, LocalServerDB}; +use flowy_server::af_cloud::{ + define::{AIUserServiceImpl, LoggedUser}, + AppFlowyCloudServer, +}; +use flowy_server::local_server::LocalServer; use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; use flowy_server_pub::AuthenticatorType; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::entities::*; +use std::ops::Deref; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use tracing::info; -use crate::AppFlowyCoreConfig; - -#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum Server { - /// Local server provider. - /// Offline mode, no user authentication and the data is stored locally. - Local = 0, - /// AppFlowy Cloud server provider. - /// See: https://github.com/AppFlowy-IO/AppFlowy-Cloud - AppFlowyCloud = 1, -} - -impl Server { - pub fn is_local(&self) -> bool { - matches!(self, Server::Local) - } -} - -impl Display for Server { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Server::Local => write!(f, "Local"), - Server::AppFlowyCloud => write!(f, "AppFlowyCloud"), - } - } -} - -/// The [ServerProvider] provides list of [AppFlowyServer] base on the [Authenticator]. Using -/// the auth type, the [ServerProvider] will create a new [AppFlowyServer] if it doesn't -/// exist. -/// Each server implements the [AppFlowyServer] trait, which provides the [UserCloudService], etc. pub struct ServerProvider { config: AppFlowyCoreConfig, - providers: DashMap>, - pub(crate) encryption: Arc, - #[allow(dead_code)] - pub(crate) store_preferences: Weak, - pub(crate) user_enable_sync: AtomicBool, + providers: DashMap>, + auth_type: ArcSwap, + logged_user: Arc, + pub local_ai: Arc, + pub uid: Arc>, + pub user_enable_sync: Arc, + pub encryption: Arc, +} - /// The authenticator type of the user. - authenticator: AtomicU8, - user: Arc, - pub(crate) uid: Arc>, +// Our little guard wrapper: +pub struct ServerHandle<'a>(Ref<'a, AuthType, Arc>); + +impl<'a> Deref for ServerHandle<'a> { + type Target = dyn AppFlowyServer; + fn deref(&self) -> &Self::Target { + // `self.0.value()` is an `&Arc` + // so `&**` gives us a `&dyn AppFlowyServer` + &**self.0.value() + } +} + +/// Determine current server type from ENV +pub fn current_server_type() -> AuthType { + match AuthenticatorType::from_env() { + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, + } } impl ServerProvider { pub fn new( config: AppFlowyCoreConfig, - server: Server, store_preferences: Weak, - server_user: impl ServerUser + 'static, + user_service: impl LoggedUser + 'static, ) -> Self { - let user = Arc::new(server_user); - let encryption = EncryptionImpl::new(None); - Self { + let initial_auth = current_server_type(); + let logged_user = Arc::new(user_service) as Arc; + let auth_type = ArcSwap::from(Arc::new(initial_auth)); + let encryption = Arc::new(EncryptionImpl::new(None)) as Arc; + let ai_user = Arc::new(AIUserServiceImpl(Arc::downgrade(&logged_user))); + let plugins = Arc::new(PluginManager::new()); + let local_ai = Arc::new(LocalAIController::new( + plugins, + store_preferences, + ai_user.clone(), + )); + + ServerProvider { config, providers: DashMap::new(), - user_enable_sync: AtomicBool::new(true), - authenticator: AtomicU8::new(Authenticator::from(server) as u8), - encryption: Arc::new(encryption), - store_preferences, + encryption, + user_enable_sync: Arc::new(AtomicBool::new(true)), + auth_type, + logged_user, uid: Default::default(), - user, + local_ai, } } - pub fn get_server_type(&self) -> Server { - match Authenticator::from(self.authenticator.load(Ordering::Acquire) as i32) { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, + pub fn set_auth_type(&self, new_auth_type: AuthType) { + let old_type = self.get_auth_type(); + if old_type != new_auth_type { + info!( + "ServerProvider: auth type from {:?} to {:?}", + old_type, new_auth_type + ); + + self.auth_type.store(Arc::new(new_auth_type)); + if let Some((auth_type, _)) = self.providers.remove(&old_type) { + info!("ServerProvider: remove old auth type: {:?}", auth_type); + } } } - pub fn set_authenticator(&self, authenticator: Authenticator) { - let old_server_type = self.get_server_type(); - self - .authenticator - .store(authenticator as u8, Ordering::Release); - let new_server_type = self.get_server_type(); - - if old_server_type != new_server_type { - self.providers.remove(&old_server_type); - } + pub fn get_auth_type(&self) -> AuthType { + *self.auth_type.load_full().as_ref() } - pub fn get_authenticator(&self) -> Authenticator { - Authenticator::from(self.authenticator.load(Ordering::Acquire) as i32) - } - - /// Returns a [AppFlowyServer] trait implementation base on the provider_type. - pub fn get_server(&self) -> FlowyResult> { - let server_type = self.get_server_type(); - - if let Some(provider) = self.providers.get(&server_type) { - return Ok(provider.value().clone()); + /// Lazily create or fetch an AppFlowyServer instance + pub fn get_server(&self) -> FlowyResult { + let auth_type = self.get_auth_type(); + if let Some(r) = self.providers.get(&auth_type) { + return Ok(ServerHandle(r)); } - let server = match server_type { - Server::Local => { - let local_db = Arc::new(LocalServerDBImpl { - storage_path: self.config.storage_path.clone(), - }); - let server = Arc::new(LocalServer::new(local_db)); - Ok::, FlowyError>(server) - }, - Server::AppFlowyCloud => { - let config = self.config.cloud_config.clone().ok_or_else(|| { - FlowyError::internal().with_context("AppFlowyCloud configuration is missing") - })?; - let server = Arc::new(AppFlowyCloudServer::new( - config, + let server: Arc = match auth_type { + AuthType::Local => Arc::new(LocalServer::new( + self.logged_user.clone(), + self.local_ai.clone(), + )), + AuthType::AppFlowyCloud => { + let cfg = self + .config + .cloud_config + .clone() + .ok_or_else(|| FlowyError::internal().with_context("Missing cloud config"))?; + Arc::new(AppFlowyCloudServer::new( + cfg, self.user_enable_sync.load(Ordering::Acquire), self.config.device_id.clone(), self.config.app_version.clone(), - self.user.clone(), - )); - - Ok::, FlowyError>(server) + Arc::downgrade(&self.logged_user), + )) }, - }?; + }; - self.providers.insert(server_type.clone(), server.clone()); - Ok(server) - } -} - -impl From for Server { - fn from(auth_provider: Authenticator) -> Self { - match auth_provider { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, - } - } -} - -impl From for Authenticator { - fn from(ty: Server) -> Self { - match ty { - Server::Local => Authenticator::Local, - Server::AppFlowyCloud => Authenticator::AppFlowyCloud, - } - } -} -impl From<&Authenticator> for Server { - fn from(auth_provider: &Authenticator) -> Self { - Self::from(auth_provider.clone()) - } -} - -pub fn current_server_type() -> Server { - match AuthenticatorType::from_env() { - AuthenticatorType::Local => Server::Local, - AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, - } -} - -struct LocalServerDBImpl { - #[allow(dead_code)] - storage_path: String, -} - -impl LocalServerDB for LocalServerDBImpl { - fn get_user_profile(&self, _uid: i64) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_profile"), - ) - } - - fn get_user_workspace(&self, _uid: i64) -> Result, FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_workspace"), - ) + self.providers.insert(auth_type, server); + let guard = self.providers.get(&auth_type).unwrap(); + Ok(ServerHandle(guard)) } } diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs index a43c28f90b..3be1bf15ed 100644 --- a/frontend/rust-lib/flowy-core/src/user_state_callback.rs +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -2,24 +2,29 @@ use std::sync::Arc; use anyhow::Context; use client_api::entity::billing_dto::SubscriptionPlan; -use tracing::{event, info}; +use tracing::{error, event, info}; +use crate::server_layer::ServerProvider; use collab_entity::CollabType; use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_ai::ai_manager::AIManager; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use flowy_folder::manager::{FolderInitDataSource, FolderManager}; use flowy_storage::manager::StorageManager; use flowy_user::event_map::UserStatusCallback; +use flowy_user::user_manager::UserManager; use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserProfile, UserWorkspace}; +use flowy_user_pub::entities::{AuthType, UserProfile, UserWorkspace}; +use lib_dispatch::runtime::AFPluginRuntime; use lib_infra::async_trait::async_trait; - -use crate::server_layer::{Server, ServerProvider}; +use uuid::Uuid; pub(crate) struct UserStatusCallbackImpl { + pub(crate) user_manager: Arc, pub(crate) collab_builder: Arc, pub(crate) folder_manager: Arc, pub(crate) database_manager: Arc, @@ -27,22 +32,69 @@ pub(crate) struct UserStatusCallbackImpl { pub(crate) server_provider: Arc, pub(crate) storage_manager: Arc, pub(crate) ai_manager: Arc, + // By default, all callback will run on the caller thread. If you don't want to block the caller + // thread, you can use runtime to spawn a new task. + pub(crate) runtime: Arc, +} + +impl UserStatusCallbackImpl { + fn init_ai_component(&self, workspace_id: String) { + let cloned_ai_manager = self.ai_manager.clone(); + self.runtime.spawn(async move { + if let Err(err) = cloned_ai_manager.initialize(&workspace_id).await { + error!("Failed to initialize AIManager: {:?}", err); + } + }); + } + + async fn folder_init_data_source( + &self, + user_id: i64, + workspace_id: &Uuid, + auth_type: &AuthType, + ) -> FlowyResult { + if self.is_object_exist_on_disk(user_id, workspace_id, workspace_id)? { + return Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: false, + }); + } + let doc_state_result = self + .folder_manager + .cloud_service + .get_folder_doc_state(workspace_id, user_id, CollabType::Folder, workspace_id) + .await; + resolve_data_source(auth_type, doc_state_result) + } + + fn is_object_exist_on_disk( + &self, + user_id: i64, + workspace_id: &Uuid, + object_id: &Uuid, + ) -> FlowyResult { + let db = self + .user_manager + .get_collab_db(user_id)? + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Collab db is not initialized"))?; + let read = db.read_txn(); + let workspace_id = workspace_id.to_string(); + let object_id = object_id.to_string(); + Ok(read.is_exist(user_id, &workspace_id, &object_id)) + } } #[async_trait] impl UserStatusCallback for UserStatusCallbackImpl { - async fn did_init( + async fn on_launch_if_authenticated( &self, user_id: i64, - user_authenticator: &Authenticator, cloud_config: &Option, user_workspace: &UserWorkspace, _device_id: &str, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { - self - .server_provider - .set_user_authenticator(user_authenticator); + let workspace_id = user_workspace.workspace_id()?; if let Some(cloud_config) = cloud_config { self @@ -59,7 +111,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { .folder_manager .initialize( user_id, - &user_workspace.id, + &workspace_id, FolderInitDataSource::LocalDisk { create_if_not_exist: false, }, @@ -67,19 +119,21 @@ impl UserStatusCallback for UserStatusCallbackImpl { .await?; self .database_manager - .initialize(user_id, authenticator == &Authenticator::Local) + .initialize(user_id, auth_type == &AuthType::Local) .await?; self.document_manager.initialize(user_id).await?; - self.ai_manager.initialize(&user_workspace.id).await?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); Ok(()) } - async fn did_sign_in( + async fn on_sign_in( &self, user_id: i64, user_workspace: &UserWorkspace, device_id: &str, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { event!( tracing::Level::TRACE, @@ -87,32 +141,36 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); - + let workspace_id = user_workspace.workspace_id()?; + let data_source = self + .folder_init_data_source(user_id, &workspace_id, auth_type) + .await?; self .folder_manager - .initialize_with_workspace_id(user_id) + .initialize_after_sign_in(user_id, data_source) .await?; self .database_manager - .initialize(user_id, authenticator.is_local()) + .initialize_after_sign_in(user_id, auth_type.is_local()) .await?; - self.document_manager.initialize(user_id).await?; + self + .document_manager + .initialize_after_sign_in(user_id) + .await?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); Ok(()) } - async fn did_sign_up( + async fn on_sign_up( &self, is_new_user: bool, user_profile: &UserProfile, user_workspace: &UserWorkspace, device_id: &str, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { - self - .server_provider - .set_user_authenticator(&user_profile.authenticator); - let server_type = self.server_provider.get_server_type(); - event!( tracing::Level::TRACE, "Notify did sign up: is new: {} user_workspace: {:?}, device_id: {}", @@ -120,96 +178,86 @@ impl UserStatusCallback for UserStatusCallbackImpl { user_workspace, device_id ); - - // In the current implementation, when a user signs up for AppFlowy Cloud, a default workspace - // is automatically created for them. However, for users who sign up through Supabase, the creation - // of the default workspace relies on the client-side operation. This means that the process - // for initializing a default workspace differs depending on the sign-up method used. - let data_source = match self - .folder_manager - .cloud_service - .get_folder_doc_state( - &user_workspace.id, - user_profile.uid, - CollabType::Folder, - &user_workspace.id, - ) - .await - { - Ok(doc_state) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - Server::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), - }, - Err(err) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - Server::AppFlowyCloud => { - return Err(err); - }, - }, - }; + let workspace_id = user_workspace.workspace_id()?; + let data_source = self + .folder_init_data_source(user_profile.uid, &workspace_id, auth_type) + .await?; self .folder_manager - .initialize_with_new_user( + .initialize_after_sign_up( user_profile.uid, &user_profile.token, is_new_user, data_source, - &user_workspace.id, + &workspace_id, ) .await .context("FolderManager error")?; self .database_manager - .initialize_with_new_user(user_profile.uid, authenticator.is_local()) + .initialize_after_sign_up(user_profile.uid, auth_type.is_local()) .await .context("DatabaseManager error")?; self .document_manager - .initialize_with_new_user(user_profile.uid) + .initialize_after_sign_up(user_profile.uid) .await .context("DocumentManager error")?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); Ok(()) } - async fn did_expired(&self, _token: &str, user_id: i64) -> FlowyResult<()> { + async fn on_token_expired(&self, _token: &str, user_id: i64) -> FlowyResult<()> { self.folder_manager.clear(user_id).await; Ok(()) } - async fn open_workspace( + async fn on_workspace_opened( &self, user_id: i64, - user_workspace: &UserWorkspace, - authenticator: &Authenticator, + workspace_id: &Uuid, + _user_workspace: &UserWorkspace, + auth_type: &AuthType, ) -> FlowyResult<()> { + let data_source = self + .folder_init_data_source(user_id, workspace_id, auth_type) + .await?; + self .folder_manager - .initialize_with_workspace_id(user_id) + .initialize_after_open_workspace(user_id, data_source) .await?; self .database_manager - .initialize(user_id, authenticator.is_local()) + .initialize_after_open_workspace(user_id, auth_type.is_local()) .await?; - self.document_manager.initialize(user_id).await?; - self.ai_manager.initialize(&user_workspace.id).await?; - self.storage_manager.initialize(&user_workspace.id).await; + self + .document_manager + .initialize_after_open_workspace(user_id) + .await?; + self + .ai_manager + .initialize_after_open_workspace(workspace_id) + .await?; + self + .storage_manager + .initialize_after_open_workspace(workspace_id) + .await; Ok(()) } - fn did_update_network(&self, reachable: bool) { + fn on_network_status_changed(&self, reachable: bool) { info!("Notify did update network: reachable: {}", reachable); self.collab_builder.update_network(reachable); self.storage_manager.update_network_reachable(reachable); } - fn did_update_plans(&self, plans: Vec) { + fn on_subscription_plans_updated(&self, plans: Vec) { let mut storage_plan_changed = false; for plan in &plans { match plan { @@ -222,7 +270,7 @@ impl UserStatusCallback for UserStatusCallbackImpl { } } - fn did_update_storage_limitation(&self, can_write: bool) { + fn on_storage_permission_updated(&self, can_write: bool) { if can_write { self.storage_manager.enable_storage_write_access(); } else { @@ -230,3 +278,23 @@ impl UserStatusCallback for UserStatusCallbackImpl { } } } + +fn resolve_data_source( + auth_type: &AuthType, + doc_state_result: Result, FlowyError>, +) -> FlowyResult { + match doc_state_result { + Ok(doc_state) => Ok(match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }, + AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + }), + Err(err) => match auth_type { + AuthType::Local => Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }), + AuthType::AppFlowyCloud => Err(err), + }, + } +} diff --git a/frontend/rust-lib/flowy-database-pub/Cargo.toml b/frontend/rust-lib/flowy-database-pub/Cargo.toml index 91426a5c87..088c7b6465 100644 --- a/frontend/rust-lib/flowy-database-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-database-pub/Cargo.toml @@ -9,6 +9,6 @@ edition = "2021" lib-infra = { workspace = true } collab-entity = { workspace = true } collab = { workspace = true } -anyhow.workspace = true client-api = { workspace = true } -flowy-error = { workspace = true } \ No newline at end of file +flowy-error = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database-pub/src/cloud.rs b/frontend/rust-lib/flowy-database-pub/src/cloud.rs index a29cf650c4..8666e6c764 100644 --- a/frontend/rust-lib/flowy-database-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-database-pub/src/cloud.rs @@ -4,8 +4,9 @@ use collab_entity::CollabType; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; -pub type EncodeCollabByOid = HashMap; +pub type EncodeCollabByOid = HashMap; pub type SummaryRowContent = HashMap; pub type TranslateRowContent = Vec; @@ -13,8 +14,8 @@ pub type TranslateRowContent = Vec; pub trait DatabaseAIService: Send + Sync { async fn summary_database_row( &self, - _workspace_id: &str, - _object_id: &str, + _workspace_id: &Uuid, + _object_id: &Uuid, _summary_row: SummaryRowContent, ) -> Result { Ok("".to_string()) @@ -22,7 +23,7 @@ pub trait DatabaseAIService: Send + Sync { async fn translate_database_row( &self, - _workspace_id: &str, + _workspace_id: &Uuid, _translate_row: TranslateRowContent, _language: &str, ) -> Result { @@ -41,29 +42,29 @@ pub trait DatabaseAIService: Send + Sync { pub trait DatabaseCloudService: Send + Sync { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError>; async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result; async fn get_database_collab_object_snapshots( &self, - object_id: &str, + object_id: &Uuid, limit: usize, ) -> Result, FlowyError>; } diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index a6b676512d..ec0eb94210 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -51,6 +51,7 @@ strum_macros = "0.25" validator = { workspace = true, features = ["derive"] } tokio-util.workspace = true moka = { version = "0.12.8", features = ["future"] } +uuid.workspace = true [dev-dependencies] event-integration-test = { path = "../event-integration-test", default-features = false } @@ -62,5 +63,4 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] verbose_log = ["collab-database/verbose_log"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database2/build.rs b/frontend/rust-lib/flowy-database2/build.rs index aeaaee42f3..e10aed7956 100644 --- a/frontend/rust-lib/flowy-database2/build.rs +++ b/frontend/rust-lib/flowy-database2/build.rs @@ -5,19 +5,19 @@ fn main() { flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - #[cfg(feature = "ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } + // #[cfg(feature = "ts")] + // { + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::Tauri, + // ); + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::TauriApp, + // ); + // } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs index 8c16db4379..2562bd84f7 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs @@ -26,9 +26,6 @@ pub struct DatabasePB { #[pb(index = 4)] pub layout_type: DatabaseLayoutPB, - - #[pb(index = 5)] - pub is_linked: bool, } #[derive(ProtoBuf, Default)] @@ -208,7 +205,7 @@ pub struct DatabaseMetaPB { pub database_id: String, #[pb(index = 2)] - pub inline_view_id: String, + pub view_id: String, } #[derive(Debug, Default, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index ed766885c7..63d6fdf2c3 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -865,17 +865,25 @@ pub(crate) async fn delete_group_handler( } #[tracing::instrument(level = "debug", skip(manager), err)] -pub(crate) async fn get_database_meta_handler( +pub(crate) async fn get_default_database_view_id_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let database_id = data.into_inner().value; - let inline_view_id = manager.get_database_inline_view_id(&database_id).await?; + let database_view_id = manager + .get_database_meta(&database_id) + .await? + .and_then(|mut d| d.linked_views.pop()) + .ok_or_else(|| { + FlowyError::internal().with_context(format!( + "Can't find any database view for given database id: {}", + database_id + )) + })?; - let data = DatabaseMetaPB { - database_id, - inline_view_id, + let data = DatabaseViewIdPB { + value: database_view_id, }; data_result_ok(data) } @@ -892,7 +900,7 @@ pub(crate) async fn get_databases_handler( if let Some(link_view) = meta.linked_views.first() { items.push(DatabaseMetaPB { database_id: meta.database_id, - inline_view_id: link_view.clone(), + view_id: link_view.clone(), }) } } @@ -1261,7 +1269,7 @@ pub(crate) async fn summarize_row_handler( let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let result = manager - .summarize_row(data.view_id, row_id, data.field_id) + .summarize_row(&data.view_id, row_id, data.field_id) .await; let _ = tx.send(result); }); @@ -1280,7 +1288,7 @@ pub(crate) async fn translate_row_handler( let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let result = manager - .translate_row(data.view_id, row_id, data.field_id) + .translate_row(&data.view_id, row_id, data.field_id) .await; let _ = tx.send(result); }); diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 6281cde745..824565e5b8 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -64,7 +64,7 @@ pub fn init(database_manager: Weak) -> AFPlugin { .event(DatabaseEvent::CreateGroup, create_group_handler) .event(DatabaseEvent::DeleteGroup, delete_group_handler) // Database - .event(DatabaseEvent::GetDatabaseMeta, get_database_meta_handler) + .event(DatabaseEvent::GetDefaultDatabaseViewId, get_default_database_view_id_handler) .event(DatabaseEvent::GetDatabases, get_databases_handler) // Calendar .event(DatabaseEvent::GetAllCalendarEvents, get_calendar_events_handler) @@ -305,8 +305,8 @@ pub enum DatabaseEvent { #[event(input = "DeleteGroupPayloadPB")] DeleteGroup = 115, - #[event(input = "DatabaseIdPB", output = "DatabaseMetaPB")] - GetDatabaseMeta = 119, + #[event(input = "DatabaseIdPB", output = "DatabaseViewIdPB")] + GetDefaultDatabaseViewId = 119, /// Returns all the databases #[event(output = "RepeatedDatabaseDescriptionPB")] diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index fca8db4f97..666d2f8eaf 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -20,6 +20,7 @@ use collab_entity::{CollabObject, CollabType, EncodedCollab}; use collab_plugins::local_storage::kv::KVTransactionDB; use rayon::prelude::*; use std::collections::HashMap; +use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::Mutex; @@ -42,12 +43,13 @@ use crate::services::database_view::DatabaseLayoutDepsResolver; use crate::services::field_settings::default_field_settings_by_layout_map; use crate::services::share::csv::{CSVFormat, CSVImporter, ImportResult}; use tokio::sync::RwLock as TokioRwLock; +use uuid::Uuid; pub trait DatabaseUser: Send + Sync { fn user_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; - fn workspace_id(&self) -> Result; - fn workspace_database_object_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn workspace_database_object_id(&self) -> Result; } pub(crate) type DatabaseEditorMap = HashMap>; @@ -110,7 +112,7 @@ impl DatabaseManager { let workspace_database_object_id = self.user.workspace_database_object_id()?; let workspace_database_collab = collab_service .build_collab( - workspace_database_object_id.as_str(), + workspace_database_object_id.to_string().as_str(), CollabType::WorkspaceDatabase, None, ) @@ -132,12 +134,12 @@ impl DatabaseManager { } #[instrument( - name = "database_initialize_with_new_user", + name = "database_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user( + pub async fn initialize_after_sign_up( &self, user_id: i64, is_local_user: bool, @@ -146,13 +148,22 @@ impl DatabaseManager { Ok(()) } - pub async fn get_database_inline_view_id(&self, database_id: &str) -> FlowyResult { - let lock = self.workspace_database()?; - let wdb = lock.read().await; - let database_collab = wdb.get_or_init_database(database_id).await?; - drop(wdb); - let lock_guard = database_collab.read().await; - Ok(lock_guard.get_inline_view_id()) + pub async fn initialize_after_open_workspace( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) + } + + pub async fn initialize_after_sign_in( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) } pub async fn get_all_databases_meta(&self) -> Vec { @@ -164,6 +175,15 @@ impl DatabaseManager { items } + pub async fn get_database_meta(&self, database_id: &str) -> FlowyResult> { + let mut database_meta = None; + if let Some(lock) = self.workspace_database_manager.load_full() { + let wdb = lock.read().await; + database_meta = wdb.get_database_meta(database_id); + } + Ok(database_meta) + } + #[instrument(level = "trace", skip_all, err)] pub async fn update_database_indexing( &self, @@ -189,8 +209,10 @@ impl DatabaseManager { }) } - pub async fn encode_database(&self, view_id: &str) -> FlowyResult { - let editor = self.get_database_editor_with_view_id(view_id).await?; + pub async fn encode_database(&self, view_id: &Uuid) -> FlowyResult { + let editor = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; let collabs = editor .database .read() @@ -207,10 +229,12 @@ impl DatabaseManager { pub async fn get_database_row_metas_with_view_id( &self, - view_id: &str, + view_id: &Uuid, row_ids: Vec, ) -> FlowyResult> { - let database = self.get_database_editor_with_view_id(view_id).await?; + let database = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; let view_id = view_id.to_string(); let mut row_metas: Vec = vec![]; for row_id in row_ids { @@ -275,11 +299,11 @@ impl DatabaseManager { /// Open the database view #[instrument(level = "trace", skip_all, err)] - pub async fn open_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); + pub async fn open_database_view(&self, view_id: &Uuid) -> FlowyResult<()> { + let view_id = view_id.to_string(); let lock = self.workspace_database()?; let workspace_database = lock.read().await; - let result = workspace_database.get_database_id_with_view_id(view_id); + let result = workspace_database.get_database_id_with_view_id(&view_id); drop(workspace_database); if let Some(database_id) = result { @@ -292,8 +316,7 @@ impl DatabaseManager { } #[instrument(level = "trace", skip_all, err)] - pub async fn close_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); + pub async fn close_database_view(&self, view_id: &str) -> FlowyResult<()> { let lock = self.workspace_database()?; let workspace_database = lock.read().await; let database_id = workspace_database.get_database_id_with_view_id(view_id); @@ -518,7 +541,9 @@ impl DatabaseManager { layout: DatabaseLayoutPB, ) -> FlowyResult<()> { let database = self.get_database_editor_with_view_id(view_id).await?; - database.update_view_layout(view_id, layout.into()).await + database + .update_view_layout(view_id.to_string().as_str(), layout.into()) + .await } pub async fn get_database_snapshots( @@ -526,7 +551,7 @@ impl DatabaseManager { view_id: &str, limit: usize, ) -> FlowyResult> { - let database_id = self.get_database_id_with_view_id(view_id).await?; + let database_id = Uuid::from_str(&self.get_database_id_with_view_id(view_id).await?)?; let snapshots = self .cloud_service .get_database_collab_object_snapshots(&database_id, limit) @@ -553,14 +578,14 @@ impl DatabaseManager { #[instrument(level = "debug", skip_all)] pub async fn summarize_row( &self, - view_id: String, + view_id: &str, row_id: RowId, field_id: String, ) -> FlowyResult<()> { - let database = self.get_database_editor_with_view_id(&view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; let mut summary_row_content = SummaryRowContent::new(); - if let Some(row) = database.get_row(&view_id, &row_id).await { - let fields = database.get_fields(&view_id, None).await; + if let Some(row) = database.get_row(view_id, &row_id).await { + let fields = database.get_fields(view_id, None).await; for field in fields { // When summarizing a row, skip the content in the "AI summary" cell; it does not need to // be summarized. @@ -583,13 +608,17 @@ impl DatabaseManager { ); let response = self .ai_service - .summary_database_row(&self.user.workspace_id()?, &row_id, summary_row_content) + .summary_database_row( + &self.user.workspace_id()?, + &Uuid::from_str(&row_id)?, + summary_row_content, + ) .await?; trace!("[AI]:summarize row response: {}", response); // Update the cell with the response from the cloud service. database - .update_cell_with_changeset(&view_id, &row_id, &field_id, BoxAny::new(response)) + .update_cell_with_changeset(view_id, &row_id, &field_id, BoxAny::new(response)) .await?; Ok(()) } @@ -597,11 +626,12 @@ impl DatabaseManager { #[instrument(level = "debug", skip_all)] pub async fn translate_row( &self, - view_id: String, + view_id: &str, row_id: RowId, field_id: String, ) -> FlowyResult<()> { - let database = self.get_database_editor_with_view_id(&view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; + let view_id = view_id.to_string(); let mut translate_row_content = TranslateRowContent::new(); let mut language = "english".to_string(); @@ -703,10 +733,13 @@ impl WorkspaceDatabaseCollabServiceImpl { async fn get_encode_collab( &self, - object_id: &str, + object_id: &Uuid, object_ty: CollabType, ) -> Result, DatabaseError> { - let workspace_id = self.user.workspace_id().unwrap(); + let workspace_id = self + .user + .workspace_id() + .map_err(|e| DatabaseError::Internal(e.into()))?; trace!("[Database]: fetch {}:{} from remote", object_id, object_ty); let encode_collab = self .cloud_service @@ -718,7 +751,7 @@ impl WorkspaceDatabaseCollabServiceImpl { async fn batch_get_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, ) -> Result { let workspace_id = self @@ -730,7 +763,13 @@ impl WorkspaceDatabaseCollabServiceImpl { .batch_get_database_encode_collab(object_ids, object_ty, &workspace_id) .await .map_err(|err| DatabaseError::Internal(err.into()))?; - Ok(updates) + + Ok( + updates + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) } fn collab_db(&self) -> Result, DatabaseError> { @@ -746,7 +785,7 @@ impl WorkspaceDatabaseCollabServiceImpl { fn build_collab_object( &self, - object_id: &str, + object_id: &Uuid, object_type: CollabType, ) -> Result { let uid = self @@ -776,8 +815,12 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { collab_type: CollabType, encoded_collab: Option<(EncodedCollab, bool)>, ) -> Result { - let object = self.build_collab_object(object_id, collab_type.clone())?; - let data_source = if self.persistence.is_collab_exist(object_id) { + let object_id = Uuid::parse_str(object_id)?; + let object = self.build_collab_object(&object_id, collab_type)?; + let data_source = if self + .persistence + .is_collab_exist(object_id.to_string().as_str()) + { trace!( "build collab: {}:{} from local encode collab", collab_type, @@ -796,7 +839,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { object_id, encoded_collab.is_none(), ); - match self.get_encode_collab(object_id, collab_type.clone()).await { + match self.get_encode_collab(&object_id, collab_type).await { Ok(Some(encode_collab)) => { info!( "build collab: {}:{} with remote encode collab, {} bytes", @@ -837,12 +880,11 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { ); self .persistence - .save_collab(object_id, encoded_collab.clone())?; + .save_collab(object_id.to_string().as_str(), encoded_collab.clone())?; // TODO(nathan): cover database rows and other database collab type if matches!(collab_type, CollabType::Database) { if let Ok(workspace_id) = self.user.workspace_id() { - let object_id = object_id.to_string(); let cloned_encoded_collab = encoded_collab.clone(); let cloud_service = self.cloud_service.clone(); tokio::spawn(async move { @@ -878,6 +920,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { if object_ids.is_empty() { return Ok(EncodeCollabByOid::new()); } + let mut encoded_collab_by_id = EncodeCollabByOid::new(); // 1. Collect local disk collabs into a HashMap let local_disk_encoded_collab: HashMap = object_ids @@ -885,7 +928,7 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { .filter_map(|object_id| { self .persistence - .get_encoded_collab(object_id.as_str(), collab_type.clone()) + .get_encoded_collab(object_id.as_str(), collab_type) .map(|encoded_collab| (object_id.clone(), encoded_collab)) }) .collect(); @@ -900,6 +943,10 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { } if !object_ids.is_empty() { + let object_ids = object_ids + .into_iter() + .flat_map(|v| Uuid::from_str(&v).ok()) + .collect::>(); // 2. Fetch remaining collabs from remote let remote_collabs = self .batch_get_encode_collab(object_ids, collab_type) @@ -927,7 +974,7 @@ pub struct DatabasePersistenceImpl { } impl DatabasePersistenceImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { let workspace_id = self .user .workspace_id() @@ -947,7 +994,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { if let Ok((uid, Ok(Some(collab_db)))) = result { let object_id = collab.object_id().to_string(); let db_read = collab_db.read_txn(); - if !db_read.is_exist(uid, &workspace_id, &object_id) { + if !db_read.is_exist(uid, workspace_id.to_string().as_str(), &object_id) { trace!( "[Database]: collab:{} not exist in local storage", object_id @@ -957,7 +1004,12 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { trace!("[Database]: start loading collab:{} from disk", object_id); let mut txn = collab.transact_mut(); - match db_read.load_doc_with_txn(uid, &workspace_id, &object_id, &mut txn) { + match db_read.load_doc_with_txn( + uid, + workspace_id.to_string().as_str(), + &object_id, + &mut txn, + ) { Ok(update_count) => { trace!( "[Database]: did load collab:{}, update_count:{}", @@ -976,7 +1028,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { } fn get_encoded_collab(&self, object_id: &str, collab_type: CollabType) -> Option { - let workspace_id = self.user.workspace_id().ok()?; + let workspace_id = self.user.workspace_id().ok()?.to_string(); let uid = self.user.user_id().ok()?; let db = self.user.collab_db(uid).ok()?.upgrade()?; let read_txn = db.read_txn(); @@ -995,7 +1047,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { } fn delete_collab(&self, object_id: &str) -> Result<(), DatabaseError> { - let workspace_id = self.workspace_id()?; + let workspace_id = self.workspace_id()?.to_string(); let uid = self .user .user_id() @@ -1017,7 +1069,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { object_id: &str, encoded_collab: EncodedCollab, ) -> Result<(), DatabaseError> { - let workspace_id = self.workspace_id()?; + let workspace_id = self.workspace_id()?.to_string(); let uid = self .user .user_id() @@ -1051,7 +1103,7 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { Ok(uid) => { if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { let read_txn = collab_db.read_txn(); - return read_txn.is_exist(uid, workspace_id.as_str(), object_id); + return read_txn.is_exist(uid, workspace_id.to_string().as_str(), object_id); } false }, @@ -1073,7 +1125,8 @@ impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { let workspace_id = self .user .workspace_id() - .map_err(|err| DatabaseError::Internal(err.into()))?; + .map_err(|err| DatabaseError::Internal(err.into()))? + .to_string(); if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { let write_txn = collab_db.write_txn(); diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index e284466054..227b96df4f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -44,6 +44,7 @@ use lib_infra::box_any::BoxAny; use lib_infra::priority_task::TaskDispatcher; use lib_infra::util::timestamp; use std::collections::HashMap; +use std::str::FromStr; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::select; @@ -53,11 +54,12 @@ use tokio::sync::{broadcast, oneshot}; use tokio::task::yield_now; use tokio_util::sync::CancellationToken; use tracing::{debug, error, event, info, instrument, trace, warn}; +use uuid::Uuid; type OpenDatabaseResult = oneshot::Sender>; pub struct DatabaseEditor { - database_id: String, + database_id: Uuid, pub(crate) database: Arc>, pub cell_cache: CellCache, pub(crate) database_views: Arc, @@ -117,6 +119,7 @@ impl DatabaseEditor { .await?, ); + let database_id = Uuid::from_str(&database_id)?; let collab_object = collab_builder.collab_object( &user.workspace_id()?, user.user_id()?, @@ -130,7 +133,7 @@ impl DatabaseEditor { database.clone(), )?; let this = Arc::new(Self { - database_id: database_id.clone(), + database_id, user, database, cell_cache, @@ -806,10 +809,11 @@ impl DatabaseEditor { let is_finalized = self.finalized_rows.get(row_id.as_str()).await.is_some(); if !is_finalized { trace!("[Database]: finalize database row: {}", row_id); + let row_id = Uuid::from_str(row_id.as_str())?; let collab_object = self.collab_builder.collab_object( &self.user.workspace_id()?, self.user.user_id()?, - row_id, + &row_id, CollabType::DatabaseRow, )?; @@ -1501,7 +1505,7 @@ impl DatabaseEditor { view_editor.set_row_orders(row_orders.clone()).await; // Collect database details in a single block holding the `read` lock - let (database_id, fields, is_linked) = { + let (database_id, fields) = { let database = self.database.read().await; ( database.get_database_id(), @@ -1510,7 +1514,6 @@ impl DatabaseEditor { .into_iter() .map(FieldIdPB::from) .collect::>(), - database.is_inline_view(view_id), ) }; @@ -1553,7 +1556,6 @@ impl DatabaseEditor { fields, rows: order_rows, layout_type: view_layout.into(), - is_linked, }); // Mark that the opening process is complete if let Some(tx) = self.is_loading_rows.load_full() { diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs index 081d23f1b3..1c965995ec 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs @@ -16,6 +16,7 @@ use futures::StreamExt; use std::sync::Arc; use tracing::{error, trace, warn}; +use uuid::Uuid; pub(crate) async fn observe_sync_state(database_id: &str, database: &Arc>) { let weak_database = Arc::downgrade(database); @@ -112,7 +113,7 @@ pub(crate) async fn observe_field_change(database_id: &str, database: &Arc) { +pub(crate) async fn observe_view_change(database_id: &Uuid, database_editor: &Arc) { let database_id = database_id.to_string(); let weak_database_editor = Arc::downgrade(database_editor); let view_change = database_editor @@ -289,7 +290,7 @@ async fn handle_did_update_row_orders( } } -pub(crate) async fn observe_block_event(database_id: &str, database_editor: &Arc) { +pub(crate) async fn observe_block_event(database_id: &Uuid, database_editor: &Arc) { let database_id = database_id.to_string(); let mut block_event_rx = database_editor .database diff --git a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs index 8535f46024..d40ab58d72 100644 --- a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs @@ -94,7 +94,10 @@ pub struct BoardLayoutSetting { impl BoardLayoutSetting { pub fn new() -> Self { - Self::default() + Self { + hide_ungrouped_column: false, + collapse_hidden_groups: true, + } } } diff --git a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs index dd704f43d5..3eab243fd7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs +++ b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs @@ -28,8 +28,10 @@ impl CSVExport { style: CSVFormat, ) -> FlowyResult { let mut wtr = csv::Writer::from_writer(vec![]); - let inline_view_id = database.get_inline_view_id(); - let fields = database.get_fields_in_view(&inline_view_id, None); + let view_id = database + .get_first_database_view_id() + .ok_or_else(|| FlowyError::internal().with_context("failed to get first database view"))?; + let fields = database.get_fields_in_view(&view_id, None); // Write fields let field_records = fields @@ -49,7 +51,7 @@ impl CSVExport { field_by_field_id.insert(field.id.clone(), field); }); let rows = database - .get_rows_for_view(&inline_view_id, 20, None) + .get_rows_for_view(&view_id, 20, None) .await .filter_map(|result| async { result.ok() }) .collect::>() diff --git a/frontend/rust-lib/flowy-date/Cargo.toml b/frontend/rust-lib/flowy-date/Cargo.toml index 40015cad77..d04dfd8416 100644 --- a/frontend/rust-lib/flowy-date/Cargo.toml +++ b/frontend/rust-lib/flowy-date/Cargo.toml @@ -24,4 +24,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-date/build.rs b/frontend/rust-lib/flowy-date/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-date/build.rs +++ b/frontend/rust-lib/flowy-date/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-document-pub/Cargo.toml b/frontend/rust-lib/flowy-document-pub/Cargo.toml index 93a282f5cc..cbb74de5c4 100644 --- a/frontend/rust-lib/flowy-document-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-document-pub/Cargo.toml @@ -9,5 +9,5 @@ edition = "2021" lib-infra = { workspace = true } flowy-error = { workspace = true } collab-document = { workspace = true } -anyhow.workspace = true -collab = { workspace = true } \ No newline at end of file +collab = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document-pub/src/cloud.rs b/frontend/rust-lib/flowy-document-pub/src/cloud.rs index f34a91bfd4..d5c25053a8 100644 --- a/frontend/rust-lib/flowy-document-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-document-pub/src/cloud.rs @@ -1,8 +1,8 @@ use collab::entity::EncodedCollab; pub use collab_document::blocks::DocumentData; - use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use uuid::Uuid; /// A trait for document cloud service. /// Each kind of server should implement this trait. Check out the [AppFlowyServerProvider] of @@ -11,27 +11,27 @@ use lib_infra::async_trait::async_trait; pub trait DocumentCloudService: Send + Sync + 'static { async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn get_document_snapshots( &self, - document_id: &str, + document_id: &Uuid, limit: usize, workspace_id: &str, ) -> Result, FlowyError>; async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError>; } diff --git a/frontend/rust-lib/flowy-document/Cargo.toml b/frontend/rust-lib/flowy-document/Cargo.toml index 77aa321d3c..aaaef4938e 100644 --- a/frontend/rust-lib/flowy-document/Cargo.toml +++ b/frontend/rust-lib/flowy-document/Cargo.toml @@ -50,10 +50,5 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = [ - "flowy-codegen/ts", -] - # search "Enable/Disable AppFlowy Verbose Log" to find the place that can enable verbose log verbose_log = ["collab-document/verbose_log"] diff --git a/frontend/rust-lib/flowy-document/build.rs b/frontend/rust-lib/flowy-document/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-document/build.rs +++ b/frontend/rust-lib/flowy-document/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-document/src/document.rs b/frontend/rust-lib/flowy-document/src/document.rs index ffdd0c900e..aa871cf4bc 100644 --- a/frontend/rust-lib/flowy-document/src/document.rs +++ b/frontend/rust-lib/flowy-document/src/document.rs @@ -6,9 +6,10 @@ use collab::preclude::Collab; use collab_document::document::Document; use futures::StreamExt; use lib_infra::sync_trace; +use uuid::Uuid; -pub fn subscribe_document_changed(doc_id: &str, document: &mut Document) { - let doc_id_clone_for_block_changed = doc_id.to_owned(); +pub fn subscribe_document_changed(doc_id: &Uuid, document: &mut Document) { + let doc_id_clone_for_block_changed = doc_id.to_string(); document.subscribe_block_changed("key", move |events, is_remote| { sync_trace!( "[Document] block changed in doc_id: {}, is_remote: {}, events: {:?}", @@ -35,7 +36,7 @@ pub fn subscribe_document_changed(doc_id: &str, document: &mut Document) { ); document_notification_builder( - &doc_id_clone_for_awareness_state, + &doc_id_clone_for_awareness_state.to_string(), DocumentNotification::DidUpdateDocumentAwarenessState, ) .payload::(events.into()) diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 24611a8140..c8a6765fd6 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use collab::core::collab_state::SyncState; use collab_document::{ blocks::{json_str_to_hashmap, Block, BlockAction, DocumentData}, @@ -8,10 +6,12 @@ use collab_document::{ DocumentAwarenessUser, }, }; - use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use lib_infra::validator_fn::{required_not_empty_str, required_valid_path}; +use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; use validator::Validate; use crate::parse::{NotEmptyStr, NotEmptyVec}; @@ -31,7 +31,7 @@ pub struct OpenDocumentPayloadPB { } pub struct OpenDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for OpenDocumentPayloadPB { @@ -39,9 +39,9 @@ impl TryInto for OpenDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(OpenDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + + Ok(OpenDocumentParams { document_id }) } } @@ -52,7 +52,7 @@ pub struct DocumentRedoUndoPayloadPB { } pub struct DocumentRedoUndoParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for DocumentRedoUndoPayloadPB { @@ -60,9 +60,8 @@ impl TryInto for DocumentRedoUndoPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(DocumentRedoUndoParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(DocumentRedoUndoParams { document_id }) } } @@ -115,6 +114,13 @@ pub struct DownloadFilePB { pub local_file_path: String, } +#[derive(Default, ProtoBuf, Validate)] +pub struct DeleteFilePB { + #[pb(index = 1)] + #[validate(url)] + pub url: String, +} + #[derive(Default, ProtoBuf)] pub struct CreateDocumentPayloadPB { #[pb(index = 1)] @@ -125,7 +131,7 @@ pub struct CreateDocumentPayloadPB { } pub struct CreateDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub initial_data: Option, } @@ -134,9 +140,10 @@ impl TryInto for CreateDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let initial_data = self.initial_data.map(|data| data.into()); Ok(CreateDocumentParams { - document_id: document_id.0, + document_id, initial_data, }) } @@ -149,7 +156,7 @@ pub struct CloseDocumentPayloadPB { } pub struct CloseDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for CloseDocumentPayloadPB { @@ -157,9 +164,8 @@ impl TryInto for CloseDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(CloseDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(CloseDocumentParams { document_id }) } } @@ -173,7 +179,7 @@ pub struct ApplyActionPayloadPB { } pub struct ApplyActionParams { - pub document_id: String, + pub document_id: Uuid, pub actions: Vec, } @@ -182,10 +188,11 @@ impl TryInto for ApplyActionPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let actions = NotEmptyVec::parse(self.actions).map_err(|_| ErrorCode::ApplyActionsIsEmpty)?; let actions = actions.0.into_iter().map(BlockAction::from).collect(); Ok(ApplyActionParams { - document_id: document_id.0, + document_id, actions, }) } @@ -518,7 +525,7 @@ pub struct TextDeltaPayloadPB { } pub struct TextDeltaParams { - pub document_id: String, + pub document_id: Uuid, pub text_id: String, pub delta: String, } @@ -528,10 +535,11 @@ impl TryInto for TextDeltaPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let text_id = NotEmptyStr::parse(self.text_id).map_err(|_| ErrorCode::TextIdIsEmpty)?; let delta = self.delta.map_or_else(|| "".to_string(), |delta| delta); Ok(TextDeltaParams { - document_id: document_id.0, + document_id, text_id: text_id.0, delta, }) diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index 774561cc4e..acf45777eb 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -3,7 +3,7 @@ * as well as performing actions on documents. These functions make use of a DocumentManager, * which you can think of as a higher-level interface to interact with documents. */ - +use std::str::FromStr; use std::sync::{Arc, Weak}; use collab_document::blocks::{ @@ -11,10 +11,6 @@ use collab_document::blocks::{ DocumentData, }; -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use tracing::instrument; - use crate::entities::*; use crate::parser::document_data_parser::DocumentDataParser; use crate::parser::external::parser::ExternalDataToNestedJSONParser; @@ -23,6 +19,11 @@ use crate::parser::parser_entities::{ ConvertDocumentParams, ConvertDocumentPayloadPB, ConvertDocumentResponsePB, }; use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser}; +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_infra::sync_trace; +use tracing::instrument; +use uuid::Uuid; fn upgrade_document( document_manager: AFPluginState>, @@ -124,9 +125,7 @@ pub(crate) async fn apply_action_handler( let doc_id = params.document_id; let document = manager.editable_document(&doc_id).await?; let actions = params.actions; - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying actions: {:?}", doc_id, actions); - } + sync_trace!("{} applying action: {:?}", doc_id, actions); document.write().await.apply_action(actions)?; Ok(()) } @@ -141,6 +140,7 @@ pub(crate) async fn create_text_handler( let doc_id = params.document_id; let document = manager.editable_document(&doc_id).await?; let mut document = document.write().await; + sync_trace!("{} creating text: {:?}", doc_id, params.delta); document.apply_text_delta(¶ms.text_id, params.delta); Ok(()) } @@ -157,9 +157,7 @@ pub(crate) async fn apply_text_delta_handler( let text_id = params.text_id; let delta = params.delta; let mut document = document.write().await; - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying delta: {:?}", doc_id, delta); - } + sync_trace!("{} applying delta: {:?}", doc_id, delta); document.apply_text_delta(&text_id, delta); Ok(()) } @@ -485,13 +483,10 @@ pub(crate) async fn download_file_handler( // Handler for deleting file pub(crate) async fn delete_file_handler( - params: AFPluginData, + params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { - let DownloadFilePB { - url, - local_file_path: _, - } = params.try_into_inner()?; + let DeleteFilePB { url } = params.try_into_inner()?; let manager = upgrade_document(manager)?; manager.delete_file(url).await } @@ -502,7 +497,7 @@ pub(crate) async fn set_awareness_local_state_handler( ) -> FlowyResult<()> { let manager = upgrade_document(manager)?; let data = data.into_inner(); - let doc_id = data.document_id.clone(); + let doc_id = Uuid::from_str(&data.document_id)?; manager .set_document_awareness_local_state(&doc_id, data) .await?; diff --git a/frontend/rust-lib/flowy-document/src/event_map.rs b/frontend/rust-lib/flowy-document/src/event_map.rs index e05519d81e..1931d32161 100644 --- a/frontend/rust-lib/flowy-document/src/event_map.rs +++ b/frontend/rust-lib/flowy-document/src/event_map.rs @@ -126,7 +126,7 @@ pub enum DocumentEvent { UploadFile = 15, #[event(input = "DownloadFilePB")] DownloadFile = 16, - #[event(input = "DownloadFilePB")] + #[event(input = "DeleteFilePB")] DeleteFile = 17, #[event(input = "UpdateDocumentAwarenessStatePB")] diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index b84469872b..9c6a383bae 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -14,21 +14,21 @@ use collab_document::document_awareness::DocumentAwarenessUser; use collab_document::document_data::default_document_data; use collab_entity::CollabType; -use collab_plugins::CollabKVDB; -use dashmap::DashMap; -use lib_infra::util::timestamp; -use tracing::{event, instrument}; -use tracing::{info, trace}; - use crate::document::{ subscribe_document_changed, subscribe_document_snapshot_state, subscribe_document_sync_state, }; use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, }; +use collab_plugins::CollabKVDB; +use dashmap::DashMap; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::storage::{CreatedUpload, StorageService}; +use lib_infra::util::timestamp; +use tracing::{event, instrument}; +use tracing::{info, trace}; +use uuid::Uuid; use crate::entities::UpdateDocumentAwarenessStatePB; use crate::entities::{ @@ -39,7 +39,7 @@ use crate::reminder::DocumentReminderAction; pub trait DocumentUserService: Send + Sync { fn user_id(&self) -> Result; fn device_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; } @@ -54,8 +54,8 @@ pub trait DocumentSnapshotService: Send + Sync { pub struct DocumentManager { pub user_service: Arc, collab_builder: Arc, - documents: Arc>>>, - removing_documents: Arc>>>, + documents: Arc>>>, + removing_documents: Arc>>>, cloud_service: Arc, storage_service: Weak, snapshot_service: Arc, @@ -81,7 +81,7 @@ impl DocumentManager { } /// Get the encoded collab of the document. - pub async fn get_encoded_collab_with_view_id(&self, doc_id: &str) -> FlowyResult { + pub async fn get_encoded_collab_with_view_id(&self, doc_id: &Uuid) -> FlowyResult { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; let doc_state = @@ -106,12 +106,23 @@ impl DocumentManager { } #[instrument( - name = "document_initialize_with_new_user", + name = "document_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user(&self, uid: i64) -> FlowyResult<()> { + pub async fn initialize_after_sign_up(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + pub async fn initialize_after_open_workspace(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn initialize_after_sign_in(&self, uid: i64) -> FlowyResult<()> { self.initialize(uid).await?; Ok(()) } @@ -139,7 +150,7 @@ impl DocumentManager { pub async fn create_document( &self, _uid: i64, - doc_id: &str, + doc_id: &Uuid, data: Option, ) -> FlowyResult { if self.is_doc_exist(doc_id).await.unwrap_or(false) { @@ -151,17 +162,17 @@ impl DocumentManager { let encoded_collab = doc_state_from_document_data(doc_id, data).await?; self .persistence()? - .save_collab_to_disk(doc_id, encoded_collab.clone()) + .save_collab_to_disk(doc_id.to_string().as_str(), encoded_collab.clone()) .map_err(internal_error)?; // Send the collab data to server with a background task. let cloud_service = self.cloud_service.clone(); let cloned_encoded_collab = encoded_collab.clone(); - let document_id = doc_id.to_string(); let workspace_id = self.user_service.workspace_id()?; + let doc_id = *doc_id; tokio::spawn(async move { let _ = cloud_service - .create_document_collab(&workspace_id, &document_id, cloned_encoded_collab) + .create_document_collab(&workspace_id, &doc_id, cloned_encoded_collab) .await; }); Ok(encoded_collab) @@ -171,7 +182,7 @@ impl DocumentManager { async fn collab_for_document( &self, uid: i64, - doc_id: &str, + doc_id: &Uuid, data_source: DataSource, sync_enable: bool, ) -> FlowyResult>> { @@ -195,7 +206,7 @@ impl DocumentManager { } /// Return a document instance if the document is already opened. - pub async fn editable_document(&self, doc_id: &str) -> FlowyResult>> { + pub async fn editable_document(&self, doc_id: &Uuid) -> FlowyResult>> { if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { return Ok(doc); } @@ -213,7 +224,7 @@ impl DocumentManager { #[tracing::instrument(level = "info", skip(self), err)] async fn create_document_instance( &self, - doc_id: &str, + doc_id: &Uuid, enable_sync: bool, ) -> FlowyResult>> { let uid = self.user_service.user_id()?; @@ -260,7 +271,7 @@ impl DocumentManager { subscribe_document_snapshot_state(&lock); subscribe_document_sync_state(&lock); } - self.documents.insert(doc_id.to_string(), document.clone()); + self.documents.insert(*doc_id, document.clone()); } Ok(document) }, @@ -273,21 +284,21 @@ impl DocumentManager { } } - pub async fn get_document_data(&self, doc_id: &str) -> FlowyResult { + pub async fn get_document_data(&self, doc_id: &Uuid) -> FlowyResult { let document = self.get_document(doc_id).await?; let document = document.read().await; document.get_document_data().map_err(internal_error) } - pub async fn get_document_text(&self, doc_id: &str) -> FlowyResult { + pub async fn get_document_text(&self, doc_id: &Uuid) -> FlowyResult { let document = self.get_document(doc_id).await?; let document = document.read().await; - let text = document.to_plain_text(true, false)?; + let text = document.paragraphs().join("\n"); Ok(text) } /// Return a document instance. /// The returned document might or might not be able to sync with the cloud. - async fn get_document(&self, doc_id: &str) -> FlowyResult>> { + async fn get_document(&self, doc_id: &Uuid) -> FlowyResult>> { if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { return Ok(doc); } @@ -300,7 +311,7 @@ impl DocumentManager { Ok(document) } - pub async fn open_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn open_document(&self, doc_id: &Uuid) -> FlowyResult<()> { if let Some(mutex_document) = self.restore_document_from_removing(doc_id) { let lock = mutex_document.read().await; lock.start_init_sync(); @@ -314,7 +325,7 @@ impl DocumentManager { Ok(()) } - pub async fn close_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn close_document(&self, doc_id: &Uuid) -> FlowyResult<()> { if let Some((doc_id, document)) = self.documents.remove(doc_id) { { // clear the awareness state when close the document @@ -322,7 +333,7 @@ impl DocumentManager { lock.clean_awareness_local_state(); } - let clone_doc_id = doc_id.clone(); + let clone_doc_id = doc_id; trace!("move document to removing_documents: {}", doc_id); self.removing_documents.insert(doc_id, document); @@ -340,11 +351,12 @@ impl DocumentManager { Ok(()) } - pub async fn delete_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn delete_document(&self, doc_id: &Uuid) -> FlowyResult<()> { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; if let Some(db) = self.user_service.collab_db(uid)?.upgrade() { - db.delete_doc(uid, &workspace_id, doc_id).await?; + db.delete_doc(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; // When deleting a document, we need to remove it from the cache. self.documents.remove(doc_id); } @@ -354,7 +366,7 @@ impl DocumentManager { #[instrument(level = "debug", skip_all, err)] pub async fn set_document_awareness_local_state( &self, - doc_id: &str, + doc_id: &Uuid, state: UpdateDocumentAwarenessStatePB, ) -> FlowyResult { let uid = self.user_service.user_id()?; @@ -379,12 +391,12 @@ impl DocumentManager { /// Return the list of snapshots of the document. pub async fn get_document_snapshot_meta( &self, - document_id: &str, + document_id: &Uuid, _limit: usize, ) -> FlowyResult> { let metas = self .snapshot_service - .get_document_snapshot_metas(document_id)? + .get_document_snapshot_metas(document_id.to_string().as_str())? .into_iter() .map(|meta| DocumentSnapshotMetaPB { snapshot_id: meta.snapshot_id, @@ -434,11 +446,13 @@ impl DocumentManager { Ok(()) } - async fn is_doc_exist(&self, doc_id: &str) -> FlowyResult { + async fn is_doc_exist(&self, doc_id: &Uuid) -> FlowyResult { let uid = self.user_service.user_id()?; let workspace_id = self.user_service.workspace_id()?; if let Some(collab_db) = self.user_service.collab_db(uid)?.upgrade() { - let is_exist = collab_db.is_exist(uid, &workspace_id, doc_id).await?; + let is_exist = collab_db + .is_exist(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; Ok(is_exist) } else { Ok(false) @@ -463,7 +477,7 @@ impl DocumentManager { &self.storage_service } - fn restore_document_from_removing(&self, doc_id: &str) -> Option>> { + fn restore_document_from_removing(&self, doc_id: &Uuid) -> Option>> { let (doc_id, doc) = self.removing_documents.remove(doc_id)?; trace!( "move document {} from removing_documents to documents", @@ -475,11 +489,17 @@ impl DocumentManager { } async fn doc_state_from_document_data( - doc_id: &str, + doc_id: &Uuid, data: Option, ) -> Result { let doc_id = doc_id.to_string(); - let data = data.unwrap_or_else(|| default_document_data(&doc_id)); + let data = data.unwrap_or_else(|| { + trace!( + "{} document data is None, use default document data", + doc_id.to_string() + ); + default_document_data(&doc_id) + }); // spawn_blocking is used to avoid blocking the tokio thread pool if the document is large. let encoded_collab = tokio::task::spawn_blocking(move || { let collab = Collab::new_with_origin(CollabOrigin::Empty, doc_id, vec![], false); diff --git a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs index 8acdecae36..94680b32d3 100644 --- a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs +++ b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; +use uuid::Uuid; use validator::Validate; #[derive(Default, ProtoBuf)] @@ -96,7 +97,7 @@ pub struct ParseType { } pub struct ConvertDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub range: Option, pub parse_types: ParseType, } @@ -140,10 +141,11 @@ impl TryInto for ConvertDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::parse_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let range = self.range.map(|data| data.into()); Ok(ConvertDocumentParams { - document_id: document_id.0, + document_id, range, parse_types: self.parse_types.into(), }) diff --git a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs index b11cd2ecde..2a47ec93c4 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs @@ -9,8 +9,8 @@ use crate::document::util::{gen_document_id, gen_id, DocumentTest}; async fn undo_redo_test() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test diff --git a/frontend/rust-lib/flowy-document/tests/document/document_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_test.rs index d7906bc114..8323a645c7 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_test.rs @@ -11,8 +11,8 @@ async fn restore_document() { let test = DocumentTest::new(); // create a document - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); test .create_document(uid, &doc_id, Some(data.clone())) @@ -55,8 +55,8 @@ async fn restore_document() { async fn document_apply_insert_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; @@ -111,9 +111,9 @@ async fn document_apply_insert_action() { #[tokio::test] async fn document_apply_update_page_action() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); + let doc_id = gen_document_id(); let uid = test.user_service.user_id().unwrap(); - let data = default_document_data(&doc_id); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; @@ -158,8 +158,8 @@ async fn document_apply_update_page_action() { async fn document_apply_update_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index 20e6b5d79d..231bb3852e 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -1,17 +1,11 @@ use std::ops::Deref; use std::sync::{Arc, OnceLock}; -use anyhow::Error; use collab::entity::EncodedCollab; use collab::preclude::CollabPlugin; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_document::document_data::default_document_data; -use nanoid::nanoid; -use tempfile::TempDir; -use tokio::sync::RwLock; -use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; - use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, WorkspaceCollabIntegrate, @@ -24,6 +18,11 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::storage::{CreatedUpload, FileProgressReceiver, StorageService}; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; +use nanoid::nanoid; +use tempfile::TempDir; +use tokio::sync::RwLock; +use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; +use uuid::Uuid; pub struct DocumentTest { inner: DocumentManager, @@ -39,7 +38,7 @@ impl DocumentTest { let builder = Arc::new(AppFlowyCollabBuilder::new( DefaultCollabStorageProvider(), WorkspaceCollabIntegrateImpl { - workspace_id: user.workspace_id.clone(), + workspace_id: user.workspace_id, }, )); @@ -63,7 +62,7 @@ impl Deref for DocumentTest { } pub struct FakeUser { - workspace_id: String, + workspace_id: Uuid, collab_db: Arc, } @@ -74,7 +73,7 @@ impl FakeUser { let tempdir = TempDir::new().unwrap(); let path = tempdir.into_path(); let collab_db = Arc::new(CollabKVDB::open(path).unwrap()); - let workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_id = uuid::Uuid::new_v4(); Self { collab_db, @@ -88,8 +87,8 @@ impl DocumentUserService for FakeUser { Ok(1) } - fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + fn workspace_id(&self) -> Result { + Ok(self.workspace_id) } fn collab_db(&self, _uid: i64) -> Result, FlowyError> { @@ -115,8 +114,8 @@ pub fn setup_log() { pub async fn create_and_open_empty_document() -> (DocumentTest, Arc>, String) { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(&doc_id); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); // create a document test @@ -130,9 +129,8 @@ pub async fn create_and_open_empty_document() -> (DocumentTest, Arc String { - let uuid = uuid::Uuid::new_v4(); - uuid.to_string() +pub fn gen_document_id() -> Uuid { + uuid::Uuid::new_v4() } pub fn gen_id() -> String { @@ -145,8 +143,8 @@ pub struct LocalTestDocumentCloudServiceImpl(); impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + _workspace_id: &Uuid, ) -> Result, FlowyError> { let document_id = document_id.to_string(); Err(FlowyError::new( @@ -157,7 +155,7 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_snapshots( &self, - _document_id: &str, + _document_id: &Uuid, _limit: usize, _workspace_id: &str, ) -> Result, FlowyError> { @@ -166,16 +164,16 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, + _document_id: &Uuid, + _workspace_id: &Uuid, ) -> Result, FlowyError> { Ok(None) } async fn create_document_collab( &self, - _workspace_id: &str, - _document_id: &str, + _workspace_id: &Uuid, + _document_id: &Uuid, _encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) @@ -257,14 +255,14 @@ impl DocumentSnapshotService for DocumentTestSnapshot { } struct WorkspaceCollabIntegrateImpl { - workspace_id: String, + workspace_id: Uuid, } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + fn workspace_id(&self) -> Result { + Ok(self.workspace_id) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok("fake_device_id".to_string()) } } diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index d521e26f4d..61a7422f17 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -33,7 +33,8 @@ collab-document = { workspace = true, optional = true } collab-plugins = { workspace = true, optional = true } collab-folder = { workspace = true, optional = true } client-api = { workspace = true, optional = true } -tantivy = { version = "0.22.0", optional = true } +tantivy = { workspace = true, optional = true } +uuid.workspace = true [features] default = ["impl_from_dispatch_error", "impl_from_serde", "impl_from_reqwest", "impl_from_sqlite"] @@ -54,8 +55,6 @@ impl_from_tantivy = ["tantivy"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] impl_from_appflowy_cloud = ["client-api"] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] [build-dependencies] flowy-codegen = { workspace = true, features = ["proto_gen"] } diff --git a/frontend/rust-lib/flowy-error/build.rs b/frontend/rust-lib/flowy-error/build.rs index 81f0556ae3..8dfda67156 100644 --- a/frontend/rust-lib/flowy-error/build.rs +++ b/frontend/rust-lib/flowy-error/build.rs @@ -1,18 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index 36c7b06e6d..4112883e61 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -164,8 +164,8 @@ pub enum ErrorCode { #[error("Sql error")] SqlError = 58, - #[error("Http error")] - HttpError = 59, + #[error("Network error")] + NetworkError = 59, #[error("The content should not be empty")] UnexpectedEmpty = 60, @@ -302,8 +302,8 @@ pub enum ErrorCode { #[error("Unsupported file format")] UnsupportedFileFormat = 104, - #[error("AI offline not started")] - AIOfflineNotInstalled = 105, + #[error("AppFlowy LAI not ready")] + AppFlowyLAINotReady = 105, #[error("Invalid Request")] InvalidRequest = 106, @@ -357,11 +357,32 @@ pub enum ErrorCode { #[error("Requested namespace has one or more invalid characters")] CustomNamespaceInvalidCharacter = 122, - #[error("Requested namespace has one or more invalid characters")] + #[error("AI Service is unavailable")] AIServiceUnavailable = 123, #[error("AI Image Response limit exceeded")] AIImageResponseLimitExceeded = 124, + + #[error("AI Max Required")] + AIMaxRequired = 125, + + #[error("View is locked")] + ViewIsLocked = 126, + + #[error("Request timeout")] + RequestTimeout = 127, + + #[error("Local AI is not ready")] + LocalAINotReady = 128, + + #[error("MCP error")] + MCPError = 129, + + #[error("Local AI disabled")] + LocalAIDisabled = 130, + + #[error("User not login")] + UserNotLogin = 131, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index d7b085a803..a9a2b6fa2b 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -13,7 +13,7 @@ use crate::code::ErrorCode; pub type FlowyResult = anyhow::Result; #[derive(Debug, Default, Clone, ProtoBuf, Error)] -#[error("{msg}")] +#[error("code:{code}, message:{msg}")] pub struct FlowyError { #[pb(index = 1)] pub code: ErrorCode, @@ -95,6 +95,18 @@ impl FlowyError { self.code == ErrorCode::AIImageResponseLimitExceeded } + pub fn is_local_ai_not_ready(&self) -> bool { + self.code == ErrorCode::LocalAINotReady + } + + pub fn is_local_ai_disabled(&self) -> bool { + self.code == ErrorCode::LocalAIDisabled + } + + pub fn is_ai_max_required(&self) -> bool { + self.code == ErrorCode::AIMaxRequired + } + static_flowy_error!(internal, ErrorCode::Internal); static_flowy_error!(record_not_found, ErrorCode::RecordNotFound); static_flowy_error!(workspace_initialize, ErrorCode::WorkspaceInitializeError); @@ -127,7 +139,7 @@ impl FlowyError { static_flowy_error!(serde, ErrorCode::Serde); static_flowy_error!(field_record_not_found, ErrorCode::FieldRecordNotFound); static_flowy_error!(payload_none, ErrorCode::UnexpectedEmpty); - static_flowy_error!(http, ErrorCode::HttpError); + static_flowy_error!(http, ErrorCode::NetworkError); static_flowy_error!( unexpect_calendar_field_type, ErrorCode::UnexpectedCalendarFieldType @@ -145,6 +157,11 @@ impl FlowyError { static_flowy_error!(local_ai_unavailable, ErrorCode::LocalAIUnavailable); static_flowy_error!(response_timeout, ErrorCode::ResponseTimeout); static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded); + + static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked); + static_flowy_error!(local_ai_not_ready, ErrorCode::LocalAINotReady); + static_flowy_error!(local_ai_disabled, ErrorCode::LocalAIDisabled); + static_flowy_error!(user_not_login, ErrorCode::UserNotLogin); } impl std::convert::From for FlowyError { @@ -240,3 +257,9 @@ impl From for FlowyError { } } } + +impl From for FlowyError { + fn from(value: uuid::Error) -> Self { + FlowyError::internal().with_context(value) + } +} diff --git a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs index 76c0185f34..53617c8c36 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -18,13 +18,15 @@ impl From for FlowyError { AppErrorCode::InvalidOAuthProvider => ErrorCode::InvalidAuthConfig, AppErrorCode::NotLoggedIn => ErrorCode::UserUnauthorized, AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, - AppErrorCode::NetworkError => ErrorCode::HttpError, + AppErrorCode::NetworkError => ErrorCode::NetworkError, + AppErrorCode::RequestTimeout => ErrorCode::RequestTimeout, AppErrorCode::PayloadTooLarge => ErrorCode::PayloadTooLarge, AppErrorCode::UserUnAuthorized => ErrorCode::UserUnauthorized, AppErrorCode::WorkspaceLimitExceeded => ErrorCode::WorkspaceLimitExceeded, AppErrorCode::WorkspaceMemberLimitExceeded => ErrorCode::WorkspaceMemberLimitExceeded, AppErrorCode::AIResponseLimitExceeded => ErrorCode::AIResponseLimitExceeded, AppErrorCode::AIImageResponseLimitExceeded => ErrorCode::AIImageResponseLimitExceeded, + AppErrorCode::AIMaxRequired => ErrorCode::AIMaxRequired, AppErrorCode::FileStorageLimitExceeded => ErrorCode::FileStorageLimitExceeded, AppErrorCode::SingleUploadLimitExceeded => ErrorCode::SingleUploadLimitExceeded, AppErrorCode::CustomNamespaceDisabled => ErrorCode::CustomNamespaceRequirePlanUpgrade, diff --git a/frontend/rust-lib/flowy-error/src/impl_from/database.rs b/frontend/rust-lib/flowy-error/src/impl_from/database.rs index 3a72a7cdf3..077ff2b708 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/database.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/database.rs @@ -1,8 +1,12 @@ use crate::FlowyError; +use flowy_sqlite::Error; impl std::convert::From for FlowyError { fn from(error: flowy_sqlite::Error) -> Self { - FlowyError::internal().with_context(error) + match error { + Error::NotFound => FlowyError::record_not_found(), + _ => FlowyError::internal().with_context(error), + } } } diff --git a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs index 3e7776d0bf..52ed4b7314 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs @@ -15,7 +15,7 @@ pub trait FolderCloudService: Send + Sync + 'static { /// Returns error if the cloud service doesn't support multiple workspaces async fn create_workspace(&self, uid: i64, name: &str) -> Result; - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; /// Returns all workspaces of the user. /// Returns vec![] if the cloud service doesn't support multiple workspaces @@ -23,7 +23,7 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError>; @@ -35,21 +35,21 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError>; async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError>; async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError>; @@ -57,64 +57,64 @@ pub trait FolderCloudService: Send + Sync + 'static { async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError>; async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError>; - async fn get_publish_info(&self, view_id: &str) -> Result; + async fn get_publish_info(&self, view_id: &Uuid) -> Result; async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError>; async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError>; async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError>; async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result; async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError>; - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; - async fn get_publish_namespace(&self, workspace_id: &str) -> Result; + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result; async fn import_zip(&self, file_path: &str) -> Result<(), FlowyError>; } #[derive(Debug)] pub struct FolderCollabParams { - pub object_id: String, + pub object_id: Uuid, pub encoded_collab_v1: Vec, pub collab_type: CollabType, } #[derive(Debug)] pub struct FullSyncCollabParams { - pub object_id: String, + pub object_id: Uuid, pub encoded_collab: EncodedCollab, pub collab_type: CollabType, } diff --git a/frontend/rust-lib/flowy-folder-pub/src/query.rs b/frontend/rust-lib/flowy-folder-pub/src/query.rs index 7b4682885d..74761e44db 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/query.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/query.rs @@ -3,6 +3,7 @@ use collab_entity::CollabType; use collab_folder::ViewLayout; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct QueryCollab { pub collab_type: CollabType, @@ -17,14 +18,14 @@ pub trait FolderQueryService: Send + Sync + 'static { /// the provided view layout, given that the parent view is not a space async fn get_surrounding_view_ids_with_view_layout( &self, - parent_view_id: &str, + parent_view_id: &Uuid, view_layout: ViewLayout, - ) -> Vec; + ) -> Vec; - async fn get_collab(&self, object_id: &str, collab_type: CollabType) -> Option; + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option; } #[async_trait] pub trait FolderViewEdit: Send + Sync + 'static { - async fn set_view_title_if_empty(&self, view_id: &str, title: &str) -> FlowyResult<()>; + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-folder/Cargo.toml b/frontend/rust-lib/flowy-folder/Cargo.toml index 30d9850e55..13b19e48b8 100644 --- a/frontend/rust-lib/flowy-folder/Cargo.toml +++ b/frontend/rust-lib/flowy-folder/Cargo.toml @@ -14,6 +14,7 @@ collab-plugins = { workspace = true } collab-integrate = { workspace = true } flowy-folder-pub = { workspace = true } flowy-search-pub = { workspace = true } +flowy-user-pub = { workspace = true } flowy-sqlite = { workspace = true } flowy-derive.workspace = true flowy-notification = { workspace = true } @@ -41,7 +42,7 @@ validator.workspace = true async-trait.workspace = true client-api = { workspace = true } regex = "1.9.5" -futures = "0.3.30" +futures = "0.3.31" dashmap.workspace = true @@ -50,6 +51,4 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] -web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] test_helper = [] diff --git a/frontend/rust-lib/flowy-folder/build.rs b/frontend/rust-lib/flowy-folder/build.rs index e9230d3d6d..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-folder/build.rs +++ b/frontend/rust-lib/flowy-folder/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/import.rs b/frontend/rust-lib/flowy-folder/src/entities/import.rs index 4189dfaa6d..83e8bdf874 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/import.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/import.rs @@ -4,6 +4,8 @@ use crate::share::{ImportData, ImportItem, ImportParams, ImportType}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::FlowyError; use lib_infra::validator_fn::required_not_empty_str; +use std::str::FromStr; +use uuid::Uuid; use validator::Validate; #[derive(Clone, Debug, ProtoBuf_Enum)] @@ -76,6 +78,8 @@ impl TryInto for ImportPayloadPB { .map_err(|_| FlowyError::invalid_view_id())? .0; + let parent_view_id = Uuid::from_str(&parent_view_id)?; + let items = self .items .into_iter() diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 60df8158de..4f2304846b 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -1,12 +1,13 @@ use collab_folder::{View, ViewIcon, ViewLayout}; -use std::collections::HashMap; -use std::convert::TryInto; -use std::ops::{Deref, DerefMut}; -use std::sync::Arc; - use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_folder_pub::cloud::gen_view_id; +use std::collections::HashMap; +use std::convert::TryInto; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; +use std::sync::Arc; +use uuid::Uuid; use crate::entities::icon::ViewIconPB; use crate::entities::parser::view::{ViewIdentify, ViewName, ViewThumbnail}; @@ -72,6 +73,11 @@ pub struct ViewPB { // user_id #[pb(index = 12, one_of)] pub last_edited_by: Option, + + // is_locked + // If true, the view is locked and cannot be edited. + #[pb(index = 13, one_of)] + pub is_locked: Option, } pub fn view_pb_without_child_views(view: View) -> ViewPB { @@ -88,6 +94,7 @@ pub fn view_pb_without_child_views(view: View) -> ViewPB { created_by: view.created_by, last_edited: view.last_edited_time, last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -105,6 +112,7 @@ pub fn view_pb_without_child_views_from_arc(view: Arc) -> ViewPB { created_by: view.created_by, last_edited: view.last_edited_time, last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -126,6 +134,7 @@ pub fn view_pb_with_child_views(view: Arc, child_views: Vec>) -> created_by: view.created_by, last_edited: view.last_edited_time, last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -314,10 +323,10 @@ pub struct CreateOrphanViewPayloadPB { #[derive(Debug, Clone)] pub struct CreateViewParams { - pub parent_view_id: String, + pub parent_view_id: Uuid, pub name: String, pub layout: ViewLayoutPB, - pub view_id: String, + pub view_id: Uuid, pub initial_data: ViewData, pub meta: HashMap, // Mark the view as current view after creation. @@ -338,9 +347,13 @@ impl TryInto for CreateViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.parent_view_id)?.0; + let parent_view_id = ViewIdentify::parse(self.parent_view_id) + .and_then(|id| Uuid::from_str(&id.0).map_err(|_| ErrorCode::InvalidParams))?; // if view_id is not provided, generate a new view_id - let view_id = self.view_id.unwrap_or_else(|| gen_view_id().to_string()); + let view_id = self + .view_id + .and_then(|v| Uuid::parse_str(&v).ok()) + .unwrap_or_else(gen_view_id); Ok(CreateViewParams { parent_view_id, @@ -363,13 +376,13 @@ impl TryInto for CreateOrphanViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.view_id.clone())?.0; + let view_id = Uuid::parse_str(&self.view_id).map_err(|_| ErrorCode::InvalidParams)?; Ok(CreateViewParams { - parent_view_id, + parent_view_id: view_id, name, layout: self.layout, - view_id: self.view_id, + view_id, initial_data: ViewData::Data(self.initial_data.into()), meta: Default::default(), set_as_current: false, @@ -556,9 +569,9 @@ impl TryInto for MoveViewPayloadPB { #[derive(Debug)] pub struct MoveNestedViewParams { - pub view_id: String, - pub new_parent_id: String, - pub prev_view_id: Option, + pub view_id: Uuid, + pub new_parent_id: Uuid, + pub prev_view_id: Option, pub from_section: Option, pub to_section: Option, } @@ -567,9 +580,20 @@ impl TryInto for MoveNestedViewPayloadPB { type Error = ErrorCode; fn try_into(self) -> Result { - let view_id = ViewIdentify::parse(self.view_id)?.0; + let view_id = Uuid::from_str(&ViewIdentify::parse(self.view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?; + let new_parent_id = ViewIdentify::parse(self.new_parent_id)?.0; - let prev_view_id = self.prev_view_id; + let new_parent_id = Uuid::from_str(&new_parent_id).map_err(|_| ErrorCode::InvalidParams)?; + + let prev_view_id = match self.prev_view_id { + Some(prev_view_id) => Some( + Uuid::from_str(&ViewIdentify::parse(prev_view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?, + ), + None => None, + }; + Ok(MoveNestedViewParams { view_id, new_parent_id, diff --git a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs index 21ff046226..72e50562f3 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs @@ -134,7 +134,7 @@ impl TryInto for GetWorkspaceViewPB { } #[derive(Default, ProtoBuf, Debug, Clone)] -pub struct WorkspaceSettingPB { +pub struct WorkspaceLatestPB { #[pb(index = 1)] pub workspace_id: String, diff --git a/frontend/rust-lib/flowy-folder/src/event_handler.rs b/frontend/rust-lib/flowy-folder/src/event_handler.rs index 7e13274ca3..6889b7ebe6 100644 --- a/frontend/rust-lib/flowy-folder/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/event_handler.rs @@ -1,8 +1,9 @@ -use std::sync::{Arc, Weak}; -use tracing::instrument; - use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use tracing::instrument; +use uuid::Uuid; use crate::entities::*; use crate::manager::FolderManager; @@ -83,7 +84,7 @@ pub(crate) async fn read_private_views_handler( #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_current_workspace_setting_handler( folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let setting = folder.get_workspace_setting_pb().await?; data_result_ok(setting) @@ -443,7 +444,12 @@ pub(crate) async fn unpublish_views_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let params = data.into_inner(); - folder.unpublish_views(params.view_ids).await?; + let view_ids = params + .view_ids + .into_iter() + .flat_map(|id| Uuid::from_str(&id).ok()) + .collect::>(); + folder.unpublish_views(view_ids).await?; Ok(()) } @@ -454,6 +460,7 @@ pub(crate) async fn get_publish_info_handler( ) -> DataResult { let folder = upgrade_folder(folder)?; let view_id = data.into_inner().value; + let view_id = Uuid::from_str(&view_id)?; let info = folder.get_publish_info(&view_id).await?; data_result_ok(PublishInfoResponsePB::from(info)) } @@ -465,6 +472,7 @@ pub(crate) async fn set_publish_name_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let SetPublishNamePB { view_id, new_name } = data.into_inner(); + let view_id = Uuid::from_str(&view_id)?; folder.set_publish_name(view_id, new_name).await?; Ok(()) } @@ -533,3 +541,25 @@ pub(crate) async fn remove_default_publish_view_handler( folder.remove_default_published_view().await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn lock_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let view_id = data.into_inner().value; + folder.lock_view(&view_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn unlock_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let view_id = data.into_inner().value; + folder.unlock_view(&view_id).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-folder/src/event_map.rs b/frontend/rust-lib/flowy-folder/src/event_map.rs index 8078cdf896..19953aad1b 100644 --- a/frontend/rust-lib/flowy-folder/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder/src/event_map.rs @@ -53,6 +53,8 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::GetDefaultPublishInfo, get_default_publish_info_handler) .event(FolderEvent::SetDefaultPublishView, set_default_publish_view_handler) .event(FolderEvent::RemoveDefaultPublishView, remove_default_publish_view_handler) + .event(FolderEvent::LockView, lock_view_handler) + .event(FolderEvent::UnlockView, unlock_view_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -63,7 +65,7 @@ pub enum FolderEvent { CreateFolderWorkspace = 0, /// Read the current opening workspace. Currently, we only support one workspace - #[event(output = "WorkspaceSettingPB")] + #[event(output = "WorkspaceLatestPB")] GetCurrentWorkspaceSetting = 1, /// Return a list of workspaces that the current user can access. @@ -220,4 +222,10 @@ pub enum FolderEvent { #[event()] RemoveDefaultPublishView = 53, + + #[event(input = "ViewIdPB")] + LockView = 54, + + #[event(input = "ViewIdPB")] + UnlockView = 55, } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 44509fbd2c..8e228191c4 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -3,7 +3,7 @@ use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, view_pb_without_child_views_from_arc, CreateViewParams, CreateWorkspaceParams, DeletedViewPB, DuplicateViewParams, FolderSnapshotPB, MoveNestedViewParams, RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, UpdateViewParams, - ViewLayoutPB, ViewPB, ViewSectionPB, WorkspacePB, WorkspaceSettingPB, + ViewLayoutPB, ViewPB, ViewSectionPB, WorkspaceLatestPB, WorkspacePB, }; use crate::manager_observer::{ notify_child_views_changed, notify_did_update_workspace, notify_parent_view_did_change, @@ -44,16 +44,18 @@ use flowy_sqlite::kv::KVStorePreferences; use futures::future; use std::collections::HashMap; use std::fmt::{Display, Formatter}; +use std::str::FromStr; use std::sync::{Arc, Weak}; use tokio::sync::RwLockWriteGuard; use tracing::{error, info, instrument}; +use uuid::Uuid; pub trait FolderUser: Send + Sync { fn user_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; - fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &str) -> FlowyResult; + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult; } pub struct FolderManager { @@ -111,7 +113,7 @@ impl FolderManager { Ok::(workspace) }; - match folder.get_workspace_info(&workspace_id) { + match folder.get_workspace_info(&workspace_id.to_string()) { None => Err(FlowyError::record_not_found().with_context("Can not find the workspace")), Some(workspace) => workspace_pb_from_workspace(workspace, &folder), } @@ -127,14 +129,14 @@ impl FolderManager { .ok_or_else(|| internal_error("The folder is not initialized"))? .read() .await - .get_folder_data(&workspace_id) + .get_folder_data(&workspace_id.to_string()) .ok_or_else(|| internal_error("Workspace id not match the id in current folder"))?; Ok(data) } pub async fn gather_publish_encode_collab( &self, - view_id: &str, + view_id: &Uuid, layout: &ViewLayout, ) -> FlowyResult { let handler = self.get_handler(layout)?; @@ -177,7 +179,7 @@ impl FolderManager { pub(crate) async fn make_folder>>( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, data_source: Option, folder_notifier: T, @@ -187,8 +189,7 @@ impl FolderManager { let config = CollabBuilderConfig::default().sync_enable(true); let data_source = data_source.unwrap_or_else(|| { - CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.to_string()) - .into_data_source() + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source() }); let object_id = workspace_id; @@ -218,8 +219,11 @@ impl FolderManager { "Clear the folder data and try to open the folder again due to: {}", err ); + if let Some(db) = self.user.collab_db(uid).ok().and_then(|a| a.upgrade()) { - let _ = db.delete_doc(uid, workspace_id, workspace_id).await; + let _ = db + .delete_doc(uid, &workspace_id.to_string(), &object_id.to_string()) + .await; } Err(err.into()) }, @@ -229,7 +233,7 @@ impl FolderManager { pub(crate) async fn create_folder_with_data( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, notifier: Option, folder_data: Option, @@ -240,8 +244,8 @@ impl FolderManager { .collab_builder .collab_object(workspace_id, uid, object_id, CollabType::Folder)?; - let doc_state = CollabPersistenceImpl::new(collab_db.clone(), uid, workspace_id.to_string()) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source(); let folder = self .collab_builder .create_folder( @@ -258,16 +262,20 @@ impl FolderManager { /// Initialize the folder with the given workspace id. /// Fetch the folder updates from the cloud service and initialize the folder. - #[tracing::instrument(skip(self, user_id), err)] - pub async fn initialize_with_workspace_id(&self, user_id: i64) -> FlowyResult<()> { + #[tracing::instrument(skip_all, err)] + pub async fn initialize_after_sign_in( + &self, + user_id: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; - let object_id = &workspace_id; - - let is_exist = self - .user - .is_folder_exist_on_disk(user_id, &workspace_id) - .unwrap_or(false); - if is_exist { + if let Err(err) = self.initialize(user_id, &workspace_id, data_source).await { + // If failed to open folder with remote data, open from local disk. After open from the local + // disk. the data will be synced to the remote server. + error!( + "initialize folder for user {} with workspace {} encountered error: {:?}, fallback local", + user_id, workspace_id, err + ); self .initialize( user_id, @@ -277,47 +285,29 @@ impl FolderManager { }, ) .await?; - } else { - let folder_doc_state = self - .cloud_service - .get_folder_doc_state(&workspace_id, user_id, CollabType::Folder, object_id) - .await?; - if let Err(err) = self - .initialize( - user_id, - &workspace_id, - FolderInitDataSource::Cloud(folder_doc_state), - ) - .await - { - // If failed to open folder with remote data, open from local disk. After open from the local - // disk. the data will be synced to the remote server. - error!("initialize folder with error {:?}, fallback local", err); - self - .initialize( - user_id, - &workspace_id, - FolderInitDataSource::LocalDisk { - create_if_not_exist: false, - }, - ) - .await?; - } } Ok(()) } + pub async fn initialize_after_open_workspace( + &self, + uid: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { + self.initialize_after_sign_in(uid, data_source).await + } + /// Initialize the folder for the new user. /// Using the [DefaultFolderBuilder] to create the default workspace for the new user. #[instrument(level = "info", skip_all, err)] - pub async fn initialize_with_new_user( + pub async fn initialize_after_sign_up( &self, user_id: i64, _token: &str, is_new: bool, data_source: FolderInitDataSource, - workspace_id: &str, + workspace_id: &Uuid, ) -> FlowyResult<()> { // Create the default workspace if the user is new info!("initialize_when_sign_up: is_new: {}", is_new); @@ -373,11 +363,11 @@ impl FolderManager { Ok(new_workspace) } - pub async fn get_workspace_setting_pb(&self) -> FlowyResult { + pub async fn get_workspace_setting_pb(&self) -> FlowyResult { let workspace_id = self.user.workspace_id()?; let latest_view = self.get_current_view().await; - Ok(WorkspaceSettingPB { - workspace_id, + Ok(WorkspaceLatestPB { + workspace_id: workspace_id.to_string(), latest_view, }) } @@ -495,7 +485,7 @@ impl FolderManager { .ok_or_else(|| FlowyError::internal().with_context("folder is not initialized"))?; let folder = lock.read().await; let workspace = folder - .get_workspace_info(&workspace_id) + .get_workspace_info(&workspace_id.to_string()) .ok_or_else(|| FlowyError::record_not_found().with_context("Can not find the workspace"))?; let views = folder @@ -606,8 +596,9 @@ impl FolderManager { // Drop the folder lock explicitly to avoid deadlock when following calls contains 'self' drop(folder); + let view_id = Uuid::from_str(view_id)?; let handler = self.get_handler(&view.layout)?; - handler.close_view(view_id).await?; + handler.close_view(&view_id).await?; } } Ok(()) @@ -767,6 +758,11 @@ impl FolderManager { } if let Some(view) = folder.get_view(view_id) { + // if the view is locked, the view can't be moved to trash + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + Self::unfavorite_view_and_decendants(view.clone(), &mut folder); folder.add_trash_view_ids(vec![view_id.to_string()]); drop(folder); @@ -839,19 +835,28 @@ impl FolderManager { let prev_view_id = params.prev_view_id; let from_section = params.from_section; let to_section = params.to_section; - let view = self.get_view_pb(&view_id).await?; - let old_parent_id = view.parent_view_id; + let view = self.get_view_pb(&view_id.to_string()).await?; + // if the view is locked, the view can't be moved + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + + let old_parent_id = Uuid::from_str(&view.parent_view_id)?; if let Some(lock) = self.mutex_folder.load_full() { let mut folder = lock.write().await; - folder.move_nested_view(&view_id, &new_parent_id, prev_view_id); + folder.move_nested_view( + &view_id.to_string(), + &new_parent_id.to_string(), + prev_view_id.map(|s| s.to_string()), + ); if from_section != to_section { if to_section == Some(ViewSectionPB::Private) { - folder.add_private_view_ids(vec![view_id.clone()]); + folder.add_private_view_ids(vec![view_id.to_string()]); } else { - folder.delete_private_view_ids(vec![view_id.clone()]); + folder.delete_private_view_ids(vec![view_id.to_string()]); } } - notify_parent_view_did_change(&workspace_id, &folder, vec![new_parent_id, old_parent_id]); + notify_parent_view_did_change(workspace_id, &folder, vec![new_parent_id, old_parent_id]); } Ok(()) } @@ -863,6 +868,12 @@ impl FolderManager { #[tracing::instrument(level = "trace", skip(self), err)] pub async fn move_view(&self, view_id: &str, from: usize, to: usize) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; + let view = self.get_view_pb(view_id).await?; + // if the view is locked, the view can't be moved + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + if let Some((is_workspace, parent_view_id, child_views)) = self.get_view_relation(view_id).await { // The display parent view is the view that is displayed in the UI @@ -896,7 +907,8 @@ impl FolderManager { if let Some(lock) = self.mutex_folder.load_full() { let mut folder = lock.write().await; folder.move_view(view_id, actual_from_index as u32, actual_to_index as u32); - notify_parent_view_did_change(&workspace_id, &folder, vec![parent_view_id]); + let parent_view_id = Uuid::from_str(&parent_view_id)?; + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); } } } @@ -952,7 +964,7 @@ impl FolderManager { #[tracing::instrument(level = "trace", skip(self), err)] pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> { self - .update_view(¶ms.view_id, |update| { + .update_view(¶ms.view_id, true, |update| { update .set_name_if_not_none(params.name) .set_desc_if_not_none(params.desc) @@ -971,12 +983,34 @@ impl FolderManager { params: UpdateViewIconParams, ) -> FlowyResult<()> { self - .update_view(¶ms.view_id, |update| { + .update_view(¶ms.view_id, true, |update| { update.set_icon(params.icon).done() }) .await } + /// Lock the view with the given view id. + /// + /// If the view is locked, it cannot be edited. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn lock_view(&self, view_id: &str) -> FlowyResult<()> { + self + .update_view(view_id, false, |update| { + update.set_page_lock_status(true).done() + }) + .await + } + + /// Unlock the view with the given view id. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn unlock_view(&self, view_id: &str) -> FlowyResult<()> { + self + .update_view(view_id, false, |update| { + update.set_page_lock_status(false).done() + }) + .await + } + /// Duplicate the view with the given view id. /// /// Including the view data (icon, cover, extra) and the child views. @@ -1077,7 +1111,8 @@ impl FolderManager { view.name, view.layout ); - let view_data = handler.duplicate_view(&view.id).await?; + let view_id = Uuid::from_str(&view.id)?; + let view_data = handler.duplicate_view(&view_id).await?; let index = self .get_view_relation(¤t_parent_id) @@ -1113,12 +1148,13 @@ impl FolderManager { view.name.clone() }; + let parent_view_id = Uuid::from_str(¤t_parent_id)?; let duplicate_params = CreateViewParams { - parent_view_id: current_parent_id.clone(), + parent_view_id, name, layout: view.layout.clone().into(), initial_data: ViewData::DuplicateData(view_data), - view_id: gen_view_id().to_string(), + view_id: gen_view_id(), meta: Default::default(), set_as_current: is_source_view && open_after_duplicated, index, @@ -1138,7 +1174,7 @@ impl FolderManager { if sync_after_create { if let Some(encoded_collab) = encoded_collab { - let object_id = duplicated_view.id.clone(); + let object_id = Uuid::from_str(&duplicated_view.id)?; let collab_type = match duplicated_view.layout { ViewLayout::Document => CollabType::Document, ViewLayout::Board | ViewLayout::Grid | ViewLayout::Calendar => CollabType::Database, @@ -1170,20 +1206,20 @@ impl FolderManager { is_source_view = false } - let workspace_id = &self.user.workspace_id()?; + let workspace_id = self.user.workspace_id()?; + let parent_view_id = Uuid::from_str(parent_view_id)?; // Sync the view to the cloud if sync_after_create { self .cloud_service - .batch_create_folder_collab_objects(workspace_id, objects) + .batch_create_folder_collab_objects(&workspace_id, objects) .await?; } // notify the update here let folder = lock.read().await; - notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id.to_string()]); - + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); let duplicated_view = self.get_view_pb(&new_view_id).await?; Ok(duplicated_view) @@ -1204,6 +1240,7 @@ impl FolderManager { let view_layout: ViewLayout = view.layout.clone().into(); if let Some(handle) = self.operation_handlers.get(&view_layout) { info!("Open view: {}-{}", view.name, view.id); + let view_id = Uuid::from_str(&view.id)?; if let Err(err) = handle.open_view(&view_id).await { error!("Open view error: {:?}", err); } @@ -1211,8 +1248,8 @@ impl FolderManager { } let workspace_id = self.user.workspace_id()?; - let setting = WorkspaceSettingPB { - workspace_id, + let setting = WorkspaceLatestPB { + workspace_id: workspace_id.to_string(), latest_view: view, }; send_current_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); @@ -1329,18 +1366,18 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; self .cloud_service - .publish_view(workspace_id.as_str(), payload) + .publish_view(&workspace_id, payload) .await?; Ok(()) } /// Unpublish the view with the given view id. #[tracing::instrument(level = "debug", skip(self), err)] - pub async fn unpublish_views(&self, view_ids: Vec) -> FlowyResult<()> { + pub async fn unpublish_views(&self, view_ids: Vec) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; self .cloud_service - .unpublish_views(workspace_id.as_str(), view_ids) + .unpublish_views(&workspace_id, view_ids) .await?; Ok(()) } @@ -1348,14 +1385,14 @@ impl FolderManager { /// Get the publish info of the view with the given view id. /// The publish info contains the namespace and publish_name of the view. #[tracing::instrument(level = "debug", skip(self))] - pub async fn get_publish_info(&self, view_id: &str) -> FlowyResult { + pub async fn get_publish_info(&self, view_id: &Uuid) -> FlowyResult { let publish_info = self.cloud_service.get_publish_info(view_id).await?; Ok(publish_info) } /// Sets the publish name of the view with the given view id. #[tracing::instrument(level = "debug", skip(self))] - pub async fn set_publish_name(&self, view_id: String, new_name: String) -> FlowyResult<()> { + pub async fn set_publish_name(&self, view_id: Uuid, new_name: String) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; self .cloud_service @@ -1371,7 +1408,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; self .cloud_service - .set_publish_namespace(workspace_id.as_str(), new_namespace) + .set_publish_namespace(&workspace_id, new_namespace) .await?; Ok(()) } @@ -1382,7 +1419,7 @@ impl FolderManager { let workspace_id = self.user.workspace_id()?; let namespace = self .cloud_service - .get_publish_namespace(workspace_id.as_str()) + .get_publish_namespace(&workspace_id) .await?; Ok(namespace) } @@ -1464,7 +1501,7 @@ impl FolderManager { }; if let Ok(payload) = self - .get_publish_payload(¤t_view_id, publish_name, layout) + .get_publish_payload(&Uuid::from_str(¤t_view_id)?, publish_name, layout) .await { payloads.push(payload); @@ -1513,7 +1550,7 @@ impl FolderManager { async fn get_publish_payload( &self, - view_id: &str, + view_id: &Uuid, publish_name: Option, layout: ViewLayout, ) -> FlowyResult { @@ -1521,18 +1558,20 @@ impl FolderManager { let encoded_collab_wrapper: GatherEncodedCollab = handler .gather_publish_encode_collab(&self.user, view_id) .await?; - let view = self.get_view_pb(view_id).await?; + + let view_str_id = view_id.to_string(); + let view = self.get_view_pb(&view_str_id).await?; let publish_name = publish_name.unwrap_or_else(|| generate_publish_name(&view.id, &view.name)); let child_views = self - .build_publish_views(view_id) + .build_publish_views(&view_str_id) .await .and_then(|v| v.child_views) .unwrap_or_default(); let ancestor_views = self - .get_view_ancestors_pb(view_id) + .get_view_ancestors_pb(&view_str_id) .await? .iter() .map(view_pb_to_publish_view) @@ -1682,8 +1721,9 @@ impl FolderManager { }; if let Some(view) = view { + let view_id = Uuid::from_str(view_id)?; if let Ok(handler) = self.get_handler(&view.layout) { - handler.delete_view(view_id).await?; + handler.delete_view(&view_id).await?; } } } @@ -1695,11 +1735,11 @@ impl FolderManager { #[instrument(level = "debug", skip_all, err)] pub(crate) async fn import_single_file( &self, - parent_view_id: String, + parent_view_id: Uuid, import_data: ImportItem, ) -> FlowyResult<(View, Vec<(String, CollabType, EncodedCollab)>)> { let handler = self.get_handler(&import_data.view_layout)?; - let view_id = gen_view_id().to_string(); + let view_id = gen_view_id(); let uid = self.user.user_id()?; let mut encoded_collab = vec![]; @@ -1707,7 +1747,7 @@ impl FolderManager { match import_data.data { ImportData::FilePath { file_path } => { handler - .import_from_file_path(&view_id, &import_data.name, file_path) + .import_from_file_path(&view_id.to_string(), &import_data.name, file_path) .await?; }, ImportData::Bytes { bytes } => { @@ -1761,16 +1801,18 @@ impl FolderManager { for data in import_data.items { // Import a single file and get the view and encoded collab data let (view, encoded_collabs) = self - .import_single_file(import_data.parent_view_id.clone(), data) + .import_single_file(import_data.parent_view_id, data) .await?; views.push(view_pb_without_child_views(view)); for (object_id, collab_type, encode_collab) in encoded_collabs { - match self.get_folder_collab_params(object_id, collab_type, encode_collab) { - Ok(params) => objects.push(params), - Err(e) => { - error!("import error {}", e); - }, + if let Ok(object_id) = Uuid::from_str(&object_id) { + match self.get_folder_collab_params(object_id, collab_type, encode_collab) { + Ok(params) => objects.push(params), + Err(e) => { + error!("import error {}", e); + }, + } } } } @@ -1784,14 +1826,17 @@ impl FolderManager { // Notify that the parent view has changed if let Some(lock) = self.mutex_folder.load_full() { let folder = lock.read().await; - notify_parent_view_did_change(&workspace_id, &folder, vec![import_data.parent_view_id]); + notify_parent_view_did_change(workspace_id, &folder, vec![import_data.parent_view_id]); } Ok(RepeatedViewPB { items: views }) } /// Update the view with the provided view_id using the specified function. - async fn update_view(&self, view_id: &str, f: F) -> FlowyResult<()> + /// + /// If the check_locked is true, it will check the lock status of the view. If the view is locked, + /// it will return an error. + async fn update_view(&self, view_id: &str, check_locked: bool, f: F) -> FlowyResult<()> where F: FnOnce(ViewUpdate) -> Option, { @@ -1801,6 +1846,12 @@ impl FolderManager { Some(lock) => { let mut folder = lock.write().await; let old_view = folder.get_view(view_id); + + // Check if the view is locked + if check_locked && old_view.as_ref().and_then(|v| v.is_locked).unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + let new_view = folder.update_view(view_id, f); Some((old_view, new_view)) @@ -1840,7 +1891,7 @@ impl FolderManager { fn get_folder_collab_params( &self, - object_id: String, + object_id: Uuid, collab_type: CollabType, encoded_collab: EncodedCollab, ) -> FlowyResult { @@ -1864,18 +1915,20 @@ impl FolderManager { let folder = lock.read().await; let view = folder.get_view(view_id)?; match folder.get_view(&view.parent_view_id) { - None => folder.get_workspace_info(&workspace_id).map(|workspace| { - ( - true, - workspace.id, - workspace - .child_views - .items - .into_iter() - .map(|view| view.id) - .collect::>(), - ) - }), + None => folder + .get_workspace_info(&workspace_id.to_string()) + .map(|workspace| { + ( + true, + workspace.id, + workspace + .child_views + .items + .into_iter() + .map(|view| view.id) + .collect::>(), + ) + }), Some(parent_view) => Some(( false, parent_view.id.clone(), @@ -1984,17 +2037,18 @@ impl FolderManager { .collect() } - pub fn remove_indices_for_workspace(&self, workspace_id: String) -> FlowyResult<()> { + pub async fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { self .folder_indexer - .remove_indices_for_workspace(workspace_id)?; + .remove_indices_for_workspace(*workspace_id) + .await?; Ok(()) } } /// Return the views that belong to the workspace. The views are filtered by the trash and all the private views. -pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_public_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -2009,7 +2063,7 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) .map(|view| view.id) .collect::>(); - let mut views = folder.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and all the private views views.retain(|view| !trash_ids.contains(&view.id) && !private_view_ids.contains(&view.id)); @@ -2027,20 +2081,15 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) /// Get all the child views belong to the view id, including the child views of the child views. fn get_all_child_view_ids(folder: &Folder, view_id: &str) -> Vec { - let child_view_ids = folder - .get_views_belong_to(view_id) - .into_iter() + folder + .get_view_recursively(view_id) + .iter() .map(|view| view.id.clone()) - .collect::>(); - let mut all_child_view_ids = child_view_ids.clone(); - for child_view_id in child_view_ids { - all_child_view_ids.extend(get_all_child_view_ids(folder, &child_view_id)); - } - all_child_view_ids + .collect() } /// Get the current private views of the user. -pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_private_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -2055,7 +2104,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder .map(|view| view.id) .collect::>(); - let mut views = folder.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and not in the private view ids views.retain(|view| !trash_ids.contains(&view.id) && private_view_ids.contains(&view.id)); @@ -2072,6 +2121,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder } #[allow(clippy::large_enum_variant)] +#[derive(Debug)] pub enum FolderInitDataSource { /// It means using the data stored on local disk to initialize the folder LocalDisk { create_if_not_exist: bool }, diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index 4393bfbb29..62cce7c394 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -10,6 +10,7 @@ use flowy_error::{FlowyError, FlowyResult}; use std::sync::{Arc, Weak}; use tokio::task::spawn_blocking; use tracing::{event, info, Level}; +use uuid::Uuid; impl FolderManager { /// Called immediately after the application launched if the user already sign in/sign up. @@ -17,7 +18,7 @@ impl FolderManager { pub async fn initialize( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, initial_data: FolderInitDataSource, ) -> FlowyResult<()> { // Update the workspace id @@ -37,7 +38,6 @@ impl FolderManager { ); } - let workspace_id = workspace_id.to_string(); // Get the collab db for the user with given user id. let collab_db = self.user.collab_db(uid)?; @@ -54,33 +54,33 @@ impl FolderManager { } => { let is_exist = self .user - .is_folder_exist_on_disk(uid, &workspace_id) + .is_folder_exist_on_disk(uid, workspace_id) .unwrap_or(false); // 1. if the folder exists, open it from local disk if is_exist { event!(Level::INFO, "Init folder from local disk"); self - .make_folder(uid, &workspace_id, collab_db, None, folder_notifier) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else if create_if_not_exist { // 2. if the folder doesn't exist and create_if_not_exist is true, create a default folder // Currently, this branch is only used when the server type is supabase. For appflowy cloud, // the default workspace is already created when the user sign up. self - .create_default_folder(uid, &workspace_id, collab_db, folder_notifier) + .create_default_folder(uid, workspace_id, collab_db, folder_notifier) .await? } else { // 3. If the folder doesn't exist and create_if_not_exist is false, try to fetch the folder data from cloud/ // This will happen user can't fetch the folder data when the user sign in. let doc_state = self .cloud_service - .get_folder_doc_state(&workspace_id, uid, CollabType::Folder, &workspace_id) + .get_folder_doc_state(workspace_id, uid, CollabType::Folder, workspace_id) .await?; self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), @@ -92,14 +92,14 @@ impl FolderManager { if doc_state.is_empty() { event!(Level::ERROR, "remote folder data is empty, open from local"); self - .make_folder(uid, &workspace_id, collab_db, None, folder_notifier) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else { event!(Level::INFO, "Restore folder from remote data"); self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), @@ -115,39 +115,43 @@ impl FolderManager { let index_content_rx = folder.subscribe_index_content(); self .folder_indexer - .set_index_content_receiver(index_content_rx, workspace_id.clone()); - self.handle_index_folder(workspace_id.clone(), &folder); + .set_index_content_receiver(index_content_rx, *workspace_id) + .await; + self.handle_index_folder(*workspace_id, &folder).await; folder_state_rx }; self.mutex_folder.store(Some(folder.clone())); let weak_mutex_folder = Arc::downgrade(&folder); - subscribe_folder_sync_state_changed( - workspace_id.clone(), - folder_state_rx, - Arc::downgrade(&self.user), - ); + subscribe_folder_sync_state_changed(*workspace_id, folder_state_rx, Arc::downgrade(&self.user)); subscribe_folder_trash_changed( - workspace_id.clone(), + *workspace_id, section_change_rx, weak_mutex_folder.clone(), Arc::downgrade(&self.user), ); subscribe_folder_view_changed( - workspace_id.clone(), + *workspace_id, view_rx, weak_mutex_folder.clone(), Arc::downgrade(&self.user), ); + let weak_folder_indexer = Arc::downgrade(&self.folder_indexer); + tokio::spawn(async move { + if let Some(folder_indexer) = weak_folder_indexer.upgrade() { + folder_indexer.initialize().await; + } + }); + Ok(()) } async fn create_default_folder( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, folder_notifier: FolderNotify, ) -> Result>, FlowyError> { @@ -170,24 +174,22 @@ impl FolderManager { Ok(folder) } - fn handle_index_folder(&self, workspace_id: String, folder: &Folder) { + async fn handle_index_folder(&self, workspace_id: Uuid, folder: &Folder) { let mut index_all = true; let encoded_collab = self .store_preferences - .get_object::(&workspace_id); + .get_object::(workspace_id.to_string().as_str()); if let Some(encoded_collab) = encoded_collab { if let Ok(changes) = folder.calculate_view_changes(encoded_collab) { let folder_indexer = self.folder_indexer.clone(); let views = folder.get_all_views(); - let wid = workspace_id.clone(); - if !changes.is_empty() && !views.is_empty() { spawn_blocking(move || { // We index the changes - folder_indexer.index_view_changes(views, changes, wid); + folder_indexer.index_view_changes(views, changes, workspace_id); }); index_all = false; } @@ -197,15 +199,12 @@ impl FolderManager { if index_all { let views = folder.get_all_views(); let folder_indexer = self.folder_indexer.clone(); - let wid = workspace_id.clone(); - + let _ = folder_indexer + .remove_indices_for_workspace(workspace_id) + .await; // We spawn a blocking task to index all views in the folder spawn_blocking(move || { - // We remove old indexes just in case - let _ = folder_indexer.remove_indices_for_workspace(wid.clone()); - - // We index all views from the workspace - folder_indexer.index_all_views(views, wid); + folder_indexer.index_all_views(views, workspace_id); }); } diff --git a/frontend/rust-lib/flowy-folder/src/manager_observer.rs b/frontend/rust-lib/flowy-folder/src/manager_observer.rs index dec4ff062d..5d3034b5aa 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_observer.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_observer.rs @@ -13,14 +13,16 @@ use collab_folder::{ use lib_infra::sync_trace; use std::collections::HashSet; +use std::str::FromStr; use std::sync::Weak; use tokio_stream::wrappers::WatchStream; use tokio_stream::StreamExt; use tracing::{event, trace, Level}; +use uuid::Uuid; /// Listen on the [ViewChange] after create/delete/update events happened pub(crate) fn subscribe_folder_view_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: ViewChangeReceiver, weak_mutex_folder: Weak>, user: Weak, @@ -46,9 +48,10 @@ pub(crate) fn subscribe_folder_view_changed( ChildViewChangeReason::Create, ); let folder = lock.read().await; - let parent_view_id = view.parent_view_id.clone(); - notify_parent_view_did_change(&workspace_id, &folder, vec![parent_view_id]); - sync_trace!("[Folder] create view: {:?}", view); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + sync_trace!("[Folder] create view: {:?}", view); + } }, ViewChange::DidDeleteView { views } => { for view in views { @@ -69,7 +72,9 @@ pub(crate) fn subscribe_folder_view_changed( ChildViewChangeReason::Update, ); let folder = lock.read().await; - notify_parent_view_did_change(&workspace_id, &folder, vec![view.parent_view_id]); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + } }, }; } @@ -78,7 +83,7 @@ pub(crate) fn subscribe_folder_view_changed( } pub(crate) fn subscribe_folder_sync_state_changed( - workspace_id: String, + workspace_id: Uuid, mut folder_sync_state_rx: WatchStream, user: Weak, ) { @@ -93,16 +98,19 @@ pub(crate) fn subscribe_folder_sync_state_changed( } } - folder_notification_builder(&workspace_id, FolderNotification::DidUpdateFolderSyncUpdate) - .payload(FolderSyncStatePB::from(state)) - .send(); + folder_notification_builder( + workspace_id.to_string(), + FolderNotification::DidUpdateFolderSyncUpdate, + ) + .payload(FolderSyncStatePB::from(state)) + .send(); } }); } /// Listen on the [TrashChange]s and notify the frontend some views were changed. pub(crate) fn subscribe_folder_trash_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: SectionChangeReceiver, weak_mutex_folder: Weak>, user: Weak, @@ -131,7 +139,9 @@ pub(crate) fn subscribe_folder_trash_changed( let folder = lock.read().await; let views = folder.get_views(&ids); for view in views { - unique_ids.insert(view.parent_view_id.clone()); + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + unique_ids.insert(parent_view_id); + } } let repeated_trash: RepeatedTrashPB = folder.get_my_trash_info().into(); @@ -140,7 +150,7 @@ pub(crate) fn subscribe_folder_trash_changed( .send(); let parent_view_ids = unique_ids.into_iter().collect(); - notify_parent_view_did_change(&workspace_id, &folder, parent_view_ids); + notify_parent_view_did_change(workspace_id, &folder, parent_view_ids); }, } } @@ -150,10 +160,10 @@ pub(crate) fn subscribe_folder_trash_changed( /// Notify the list of parent view ids that its child views were changed. #[tracing::instrument(level = "debug", skip(folder, parent_view_ids))] -pub(crate) fn notify_parent_view_did_change>( - workspace_id: &str, +pub(crate) fn notify_parent_view_did_change( + workspace_id: Uuid, folder: &Folder, - parent_view_ids: Vec, + parent_view_ids: Vec, ) -> Option<()> { let trash_ids = folder .get_all_trash_sections() @@ -162,24 +172,23 @@ pub(crate) fn notify_parent_view_did_change>( .collect::>(); for parent_view_id in parent_view_ids { - let parent_view_id = parent_view_id.as_ref(); - // if the view's parent id equal to workspace id. Then it will fetch the current // workspace views. Because the workspace is not a view stored in the views map. if parent_view_id == workspace_id { - notify_did_update_workspace(workspace_id, folder); - notify_did_update_section_views(workspace_id, folder); + notify_did_update_workspace(&workspace_id, folder); + notify_did_update_section_views(&workspace_id, folder); } else { // Parent view can contain a list of child views. Currently, only get the first level // child views. - let parent_view = folder.get_view(parent_view_id)?; - let mut child_views = folder.get_views_belong_to(parent_view_id); + let parent_view_id = parent_view_id.to_string(); + let parent_view = folder.get_view(&parent_view_id)?; + let mut child_views = folder.get_views_belong_to(&parent_view_id); child_views.retain(|view| !trash_ids.contains(&view.id)); event!(Level::DEBUG, child_views_count = child_views.len()); // Post the notification let parent_view_pb = view_pb_with_child_views(parent_view, child_views); - folder_notification_builder(parent_view_id, FolderNotification::DidUpdateView) + folder_notification_builder(&parent_view_id, FolderNotification::DidUpdateView) .payload(parent_view_pb) .send(); } @@ -188,7 +197,7 @@ pub(crate) fn notify_parent_view_did_change>( None } -pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_section_views(workspace_id: &Uuid, folder: &Folder) { let public_views = get_workspace_public_view_pbs(workspace_id, folder); let private_views = get_workspace_private_view_pbs(workspace_id, folder); trace!( @@ -214,7 +223,7 @@ pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folde .send(); } -pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_workspace(workspace_id: &Uuid, folder: &Folder) { let repeated_view: RepeatedViewPB = get_workspace_public_view_pbs(workspace_id, folder).into(); folder_notification_builder(workspace_id, FolderNotification::DidUpdateWorkspaceViews) .payload(repeated_view) diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index 8d9bfd5dea..5629ef4133 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -1,6 +1,7 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; use lib_dispatch::prelude::ToBytes; +use tracing::trace; const FOLDER_OBSERVABLE_SOURCE: &str = "Workspace"; @@ -68,9 +69,14 @@ impl std::convert::From for FolderNotification { } } -#[tracing::instrument(level = "trace")] -pub(crate) fn folder_notification_builder(id: &str, ty: FolderNotification) -> NotificationBuilder { - NotificationBuilder::new(id, ty, FOLDER_OBSERVABLE_SOURCE) +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn folder_notification_builder( + id: T, + ty: FolderNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("folder_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, FOLDER_OBSERVABLE_SOURCE) } /// The [CURRENT_WORKSPACE] represents as the current workspace that opened by the diff --git a/frontend/rust-lib/flowy-folder/src/share/import.rs b/frontend/rust-lib/flowy-folder/src/share/import.rs index 2abac3540d..6fd8d8feab 100644 --- a/frontend/rust-lib/flowy-folder/src/share/import.rs +++ b/frontend/rust-lib/flowy-folder/src/share/import.rs @@ -1,5 +1,6 @@ use collab_folder::ViewLayout; use std::fmt::{Display, Formatter}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum ImportType { @@ -35,6 +36,6 @@ impl Display for ImportData { #[derive(Clone, Debug)] pub struct ImportParams { - pub parent_view_id: String, + pub parent_view_id: Uuid, pub items: Vec, } diff --git a/frontend/rust-lib/flowy-folder/src/util.rs b/frontend/rust-lib/flowy-folder/src/util.rs index 89d49f8a23..98b87be52d 100644 --- a/frontend/rust-lib/flowy-folder/src/util.rs +++ b/frontend/rust-lib/flowy-folder/src/util.rs @@ -1,11 +1,12 @@ use crate::entities::UserFolderPB; use flowy_error::{ErrorCode, FlowyError}; +use uuid::Uuid; pub(crate) fn folder_not_init_error() -> FlowyError { FlowyError::internal().with_context("Folder not initialized") } -pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &str) -> FlowyError { +pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &Uuid) -> FlowyError { FlowyError::from(ErrorCode::WorkspaceDataNotSync).with_payload(UserFolderPB { uid, workspace_id: workspace_id.to_string(), diff --git a/frontend/rust-lib/flowy-folder/src/view_operation.rs b/frontend/rust-lib/flowy-folder/src/view_operation.rs index a4f2792afc..17919e07b1 100644 --- a/frontend/rust-lib/flowy-folder/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder/src/view_operation.rs @@ -6,11 +6,11 @@ use collab_folder::hierarchy_builder::NestedViewBuilder; pub use collab_folder::View; use collab_folder::ViewLayout; use dashmap::DashMap; +use flowy_error::FlowyError; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; - -use flowy_error::FlowyError; +use uuid::Uuid; use lib_infra::util::timestamp; @@ -51,23 +51,23 @@ pub trait FolderOperationHandler: Send + Sync { Ok(()) } - async fn open_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Closes the view and releases the resources that this view has in /// the backend - async fn close_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Called when the view is deleted. /// This will called after the view is deleted from the trash. - async fn delete_view(&self, view_id: &str) -> Result<(), FlowyError>; + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Returns the [ViewData] that can be used to create the same view. - async fn duplicate_view(&self, view_id: &str) -> Result; + async fn duplicate_view(&self, view_id: &Uuid) -> Result; /// get the encoded collab data from the disk. async fn gather_publish_encode_collab( &self, _user: &Arc, - _view_id: &str, + _view_id: &Uuid, ) -> Result { Err(FlowyError::not_support()) } @@ -102,8 +102,8 @@ pub trait FolderOperationHandler: Send + Sync { async fn create_default_view( &self, user_id: i64, - parent_view_id: &str, - view_id: &str, + parent_view_id: &Uuid, + view_id: &Uuid, name: &str, layout: ViewLayout, ) -> Result<(), FlowyError>; @@ -114,7 +114,7 @@ pub trait FolderOperationHandler: Send + Sync { async fn import_from_bytes( &self, uid: i64, - view_id: &str, + view_id: &Uuid, name: &str, import_type: ImportType, bytes: Vec, @@ -152,8 +152,8 @@ impl From for ViewLayout { pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout) -> View { let time = timestamp(); View { - id: params.view_id, - parent_view_id: params.parent_view_id, + id: params.view_id.to_string(), + parent_view_id: params.parent_view_id.to_string(), name: params.name, created_at: time, is_favorite: false, @@ -164,6 +164,7 @@ pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout last_edited_by: Some(uid), extra: params.extra, children: Default::default(), + is_locked: None, } } diff --git a/frontend/rust-lib/flowy-notification/Cargo.toml b/frontend/rust-lib/flowy-notification/Cargo.toml index b7a96898ff..3851546541 100644 --- a/frontend/rust-lib/flowy-notification/Cargo.toml +++ b/frontend/rust-lib/flowy-notification/Cargo.toml @@ -25,5 +25,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-notification/build.rs b/frontend/rust-lib/flowy-notification/build.rs index 81f0556ae3..8dfda67156 100644 --- a/frontend/rust-lib/flowy-notification/build.rs +++ b/frontend/rust-lib/flowy-notification/build.rs @@ -1,18 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-search-pub/Cargo.toml b/frontend/rust-lib/flowy-search-pub/Cargo.toml index 631f2d2c83..907942303d 100644 --- a/frontend/rust-lib/flowy-search-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-search-pub/Cargo.toml @@ -11,4 +11,4 @@ collab = { workspace = true } collab-folder = { workspace = true } flowy-error = { workspace = true } client-api = { workspace = true } -futures = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-search-pub/src/cloud.rs b/frontend/rust-lib/flowy-search-pub/src/cloud.rs index f2ffb3c439..8108cbed9a 100644 --- a/frontend/rust-lib/flowy-search-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-search-pub/src/cloud.rs @@ -1,12 +1,22 @@ -use client_api::entity::search_dto::SearchDocumentResponseItem; +pub use client_api::entity::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use uuid::Uuid; #[async_trait] pub trait SearchCloudService: Send + Sync + 'static { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError>; + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result; } diff --git a/frontend/rust-lib/flowy-search-pub/src/entities.rs b/frontend/rust-lib/flowy-search-pub/src/entities.rs index 65e23a9ddb..fc4c19359c 100644 --- a/frontend/rust-lib/flowy-search-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-search-pub/src/entities.rs @@ -1,47 +1,51 @@ -use std::any::Any; use std::sync::Arc; use collab::core::collab::IndexContentReceiver; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewLayout}; use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct IndexableData { pub id: String, pub data: String, pub icon: Option, pub layout: ViewLayout, - pub workspace_id: String, + pub workspace_id: Uuid, } impl IndexableData { - pub fn from_view(view: Arc, workspace_id: String) -> Self { + pub fn from_view(view: Arc, workspace_id: Uuid) -> Self { IndexableData { id: view.id.clone(), data: view.name.clone(), icon: view.icon.clone(), layout: view.layout.clone(), - workspace_id: workspace_id.clone(), + workspace_id, } } } +#[async_trait] pub trait IndexManager: Send + Sync { - fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: String); - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; - fn remove_indices_for_workspace(&self, workspace_id: String) -> Result<(), FlowyError>; - fn is_indexed(&self) -> bool; - - fn as_any(&self) -> &dyn Any; + async fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: Uuid); + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError>; + async fn is_indexed(&self) -> bool; } +#[async_trait] pub trait FolderIndexManager: IndexManager { - fn index_all_views(&self, views: Vec>, workspace_id: String); + async fn initialize(&self); + + fn index_all_views(&self, views: Vec>, workspace_id: Uuid); + fn index_view_changes( &self, views: Vec>, changes: Vec, - workspace_id: String, + workspace_id: Uuid, ); } diff --git a/frontend/rust-lib/flowy-search/Cargo.toml b/frontend/rust-lib/flowy-search/Cargo.toml index 2769f55479..a803ad894f 100644 --- a/frontend/rust-lib/flowy-search/Cargo.toml +++ b/frontend/rust-lib/flowy-search/Cargo.toml @@ -11,20 +11,16 @@ collab-folder = { workspace = true } flowy-derive.workspace = true flowy-error = { workspace = true, features = [ - "impl_from_sqlite", - "impl_from_dispatch_error", - "impl_from_collab_document", - "impl_from_tantivy", - "impl_from_serde", + "impl_from_sqlite", + "impl_from_dispatch_error", + "impl_from_collab_document", + "impl_from_tantivy", + "impl_from_serde", ] } -flowy-notification.workspace = true -flowy-sqlite.workspace = true flowy-user.workspace = true flowy-search-pub.workspace = true flowy-folder = { workspace = true } - bytes.workspace = true -futures.workspace = true lib-dispatch.workspace = true lib-infra = { workspace = true } protobuf.workspace = true @@ -32,24 +28,18 @@ serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["full", "rt-multi-thread", "tracing"] } tracing.workspace = true - -async-stream = "0.3.4" +derive_builder.workspace = true strsim = "0.11.0" strum_macros = "0.26.1" -tantivy = { version = "0.22.0" } -tempfile = "3.9.0" -validator = { workspace = true, features = ["derive"] } - -diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -diesel_migrations = { version = "2.1.0", features = ["sqlite"] } +tantivy.workspace = true +uuid.workspace = true +allo-isolate = { version = "^0.1", features = ["catch-unwind"] } +futures.workspace = true +tokio-stream.workspace = true +async-stream = "0.3.6" [build-dependencies] flowy-codegen.workspace = true -[dev-dependencies] -tempfile = "3.10.0" - [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-search/build.rs b/frontend/rust-lib/flowy-search/build.rs index 2600d32fb7..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-search/build.rs +++ b/frontend/rust-lib/flowy-search/build.rs @@ -1,19 +1,7 @@ -#[cfg(feature = "tauri_ts")] -use flowy_codegen::Project; - fn main() { - #[cfg(any(feature = "dart", feature = "tauri_ts"))] - let crate_name = env!("CARGO_PKG_NAME"); - #[cfg(feature = "dart")] { - flowy_codegen::protobuf_file::dart_gen(crate_name); - flowy_codegen::dart_event::gen(crate_name); - } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen(crate_name, crate_name, Project::Tauri); - flowy_codegen::ts_event::gen(crate_name, Project::Tauri); + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } } diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs index 4f963033e0..2127ef0d98 100644 --- a/frontend/rust-lib/flowy-search/src/document/handler.rs +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -1,15 +1,23 @@ -use std::sync::Arc; -use tracing::{trace, warn}; - -use flowy_error::FlowyResult; -use flowy_folder::{manager::FolderManager, ViewLayout}; -use flowy_search_pub::cloud::SearchCloudService; -use lib_infra::async_trait::async_trait; - +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, + SearchResponsePB, SearchSourcePB, SearchSummaryPB, +}; use crate::{ - entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResultPB}, + entities::{ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, services::manager::{SearchHandler, SearchType}, }; +use async_stream::stream; +use flowy_error::FlowyResult; +use flowy_folder::entities::ViewPB; +use flowy_folder::{manager::FolderManager, ViewLayout}; +use flowy_search_pub::cloud::{SearchCloudService, SearchResult}; +use lib_infra::async_trait::async_trait; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +use tokio_stream::{self, Stream}; +use tracing::{trace, warn}; +use uuid::Uuid; pub struct DocumentSearchHandler { pub cloud_service: Arc, @@ -27,7 +35,6 @@ impl DocumentSearchHandler { } } } - #[async_trait] impl SearchHandler for DocumentSearchHandler { fn search_type(&self) -> SearchType { @@ -38,64 +45,148 @@ impl SearchHandler for DocumentSearchHandler { &self, query: String, filter: Option, - ) -> FlowyResult> { - let filter = match filter { - Some(filter) => filter, - None => return Ok(vec![]), - }; + ) -> Pin> + Send + 'static>> { + let cloud_service = self.cloud_service.clone(); + let folder_manager = self.folder_manager.clone(); - let workspace_id = match filter.workspace_id { - Some(workspace_id) => workspace_id, - None => return Ok(vec![]), - }; - - let results = self - .cloud_service - .document_search(&workspace_id, query) - .await?; - trace!("[Search] remote search results: {:?}", results); - - // Grab all views from folder cache - // Notice that `get_all_view_pb` returns Views that don't include trashed and private views - let views = self.folder_manager.get_all_views_pb().await?; - let mut search_results: Vec = vec![]; - - for result in results { - if let Some(view) = views.iter().find(|v| v.id == result.object_id) { - // If there is no View for the result, we don't add it to the results - // If possible we will extract the icon to display for the result - let icon: Option = match view.icon.clone() { - Some(view_icon) => Some(ResultIconPB::from(view_icon)), - None => { - let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); - Some(ResultIconPB { - ty: ResultIconTypePB::Icon, - value: view_layout_ty.to_string(), - }) - }, - }; - - search_results.push(SearchResultPB { - index_type: IndexTypePB::Document, - view_id: result.object_id.clone(), - id: result.object_id.clone(), - data: view.name.clone(), - icon, - score: result.score, - workspace_id: result.workspace_id, - preview: result.preview, - }); + Box::pin(stream! { + // Exit early if there is no filter. + let filter = if let Some(f) = filter { + f } else { - warn!("No view found for search result: {:?}", result); + yield Ok(CreateSearchResultPBArgs::default().build().unwrap()); + return; + }; + + // Parse workspace id. + let workspace_id = match Uuid::from_str(&filter.workspace_id) { + Ok(id) => id, + Err(e) => { + yield Err(e.into()); + return; + } + }; + + // Retrieve all available views. + let views = match folder_manager.get_all_views_pb().await { + Ok(views) => views, + Err(e) => { + yield Err(e); + return; + } + }; + + // Execute document search. + yield Ok( + CreateSearchResultPBArgs::default().searching(true) + .build() + .unwrap(), + ); + + let result_items = match cloud_service.document_search(&workspace_id, query.clone()).await { + Ok(items) => items, + Err(e) => { + yield Err(e); + return; + } + }; + trace!("[Search] search result: {:?}", result_items); + + // Prepare input for search summary generation. + let summary_input: Vec = result_items + .iter() + .map(|v| SearchResult { + object_id: v.object_id, + content: v.content.clone(), + }) + .collect(); + + // Build search response items. + let mut items: Vec = Vec::new(); + for item in &result_items { + if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { + items.push(SearchResponseItemPB { + id: item.object_id.to_string(), + display_name: view.name.clone(), + icon: extract_icon(view), + workspace_id: item.workspace_id.to_string(), + content: item.content.clone()} + ); + } else { + warn!("No view found for search result: {:?}", item); + } } - } - trace!("[Search] showing results: {:?}", search_results); - Ok(search_results) - } + // Yield primary search result. + let search_result = RepeatedSearchResponseItemPB { items }; + yield Ok( + CreateSearchResultPBArgs::default() + .searching(false) + .search_result(Some(search_result)) + .generating_ai_summary(!result_items.is_empty()) + .build() + .unwrap(), + ); - /// Ignore for [DocumentSearchHandler] - fn index_count(&self) -> u64 { - 0 + if result_items.is_empty() { + return; + } + + // Generate and yield search summary. + match cloud_service.generate_search_summary(&workspace_id, query.clone(), summary_input).await { + Ok(summary_result) => { + trace!("[Search] search summary: {:?}", summary_result); + let summaries: Vec = summary_result + .summaries + .into_iter() + .map(|v| { + let sources: Vec = v.sources + .iter() + .flat_map(|id| { + views.iter().find(|v| v.id == id.to_string()).map(|view| SearchSourcePB { + id: id.to_string(), + display_name: view.name.clone(), + icon: extract_icon(view), + }) + }) + .collect(); + + SearchSummaryPB { content: v.content, sources, highlights: v.highlights } + }) + .collect(); + + let summary_result = RepeatedSearchSummaryPB { items: summaries }; + yield Ok( + CreateSearchResultPBArgs::default() + .search_summary(Some(summary_result)) + .generating_ai_summary(false) + .build() + .unwrap(), + ); + } + Err(e) => { + warn!("Failed to generate search summary: {:?}", e); + yield Ok( + CreateSearchResultPBArgs::default() + .generating_ai_summary(false) + .build() + .unwrap(), + ); + } + } + }) + } +} + +fn extract_icon(view: &ViewPB) -> Option { + match view.icon.clone() { + Some(view_icon) => Some(ResultIconPB::from(view_icon)), + None => { + let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); + Some(ResultIconPB { + ty: ResultIconTypePB::Icon, + value: view_layout_ty.to_string(), + }) + }, } } diff --git a/frontend/rust-lib/flowy-search/src/entities/index_type.rs b/frontend/rust-lib/flowy-search/src/entities/index_type.rs deleted file mode 100644 index 77adc76a97..0000000000 --- a/frontend/rust-lib/flowy-search/src/entities/index_type.rs +++ /dev/null @@ -1,31 +0,0 @@ -use flowy_derive::ProtoBuf_Enum; - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum IndexTypePB { - View = 0, - Document = 1, - DocumentBlock = 2, - DatabaseRow = 3, -} - -impl Default for IndexTypePB { - fn default() -> Self { - Self::View - } -} - -impl std::convert::From for i32 { - fn from(notification: IndexTypePB) -> Self { - notification as i32 - } -} - -impl std::convert::From for IndexTypePB { - fn from(notification: i32) -> Self { - match notification { - 1 => IndexTypePB::View, - 2 => IndexTypePB::DocumentBlock, - _ => IndexTypePB::DatabaseRow, - } - } -} diff --git a/frontend/rust-lib/flowy-search/src/entities/mod.rs b/frontend/rust-lib/flowy-search/src/entities/mod.rs index b4d7c682b9..dc6aaace08 100644 --- a/frontend/rust-lib/flowy-search/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-search/src/entities/mod.rs @@ -1,10 +1,8 @@ -mod index_type; mod notification; mod query; mod result; mod search_filter; -pub use index_type::*; pub use notification::*; pub use query::*; pub use result::*; diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index a28ed2b5d8..4f12305d9a 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -1,20 +1,13 @@ +use super::SearchResponsePB; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use super::SearchResultPB; - #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultNotificationPB { - #[pb(index = 1)] - pub items: Vec, +pub struct SearchStatePB { + #[pb(index = 1, one_of)] + pub response: Option, #[pb(index = 2)] - pub sends: u64, - - #[pb(index = 3, one_of)] - pub channel: Option, - - #[pb(index = 4)] - pub query: String, + pub search_id: String, } #[derive(ProtoBuf_Enum, Debug, Default)] diff --git a/frontend/rust-lib/flowy-search/src/entities/query.rs b/frontend/rust-lib/flowy-search/src/entities/query.rs index 8ffbcf3d46..65c92ebed0 100644 --- a/frontend/rust-lib/flowy-search/src/entities/query.rs +++ b/frontend/rust-lib/flowy-search/src/entities/query.rs @@ -13,13 +13,9 @@ pub struct SearchQueryPB { #[pb(index = 3, one_of)] pub filter: Option, - /// Used to identify the channel of the search - /// - /// This can be used to have multiple search notification listeners in place. - /// It is up to the client to decide how to handle this. - /// - /// If not set, then no channel is used. - /// - #[pb(index = 4, one_of)] - pub channel: Option, + #[pb(index = 4)] + pub search_id: String, + + #[pb(index = 5)] + pub stream_port: i64, } diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 0f5ea4dc23..a01f01b074 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -1,55 +1,106 @@ use collab_folder::{IconType, ViewIcon}; +use derive_builder::Builder; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_folder::entities::ViewIconPB; -use super::IndexTypePB; +#[derive(Debug, Default, ProtoBuf, Builder, Clone)] +#[builder(name = "CreateSearchResultPBArgs")] +#[builder(pattern = "mutable")] +pub struct SearchResponsePB { + #[pb(index = 1, one_of)] + #[builder(default)] + pub search_result: Option, -#[derive(Debug, Default, ProtoBuf, Clone)] -pub struct RepeatedSearchResultPB { - #[pb(index = 1)] - pub items: Vec, + #[pb(index = 2, one_of)] + #[builder(default)] + pub search_summary: Option, + + #[pb(index = 3, one_of)] + #[builder(default)] + pub local_search_result: Option, + + #[pb(index = 4)] + #[builder(default)] + pub searching: bool, + + #[pb(index = 5)] + #[builder(default)] + pub generating_ai_summary: bool, } #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultPB { +pub struct RepeatedSearchSummaryPB { #[pb(index = 1)] - pub index_type: IndexTypePB, - - #[pb(index = 2)] - pub view_id: String, - - #[pb(index = 3)] - pub id: String, - - #[pb(index = 4)] - pub data: String, - - #[pb(index = 5, one_of)] - pub icon: Option, - - #[pb(index = 6)] - pub score: f64, - - #[pb(index = 7)] - pub workspace_id: String, - - #[pb(index = 8, one_of)] - pub preview: Option, + pub items: Vec, } -impl SearchResultPB { - pub fn with_score(&self, score: f64) -> Self { - SearchResultPB { - index_type: self.index_type.clone(), - view_id: self.view_id.clone(), - id: self.id.clone(), - data: self.data.clone(), - icon: self.icon.clone(), - score, - workspace_id: self.workspace_id.clone(), - preview: self.preview.clone(), - } - } +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSummaryPB { + #[pb(index = 1)] + pub content: String, + + #[pb(index = 2)] + pub sources: Vec, + + #[pb(index = 3)] + pub highlights: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSourcePB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, + + #[pb(index = 5)] + pub content: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedLocalSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct LocalSearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, } #[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)] diff --git a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs index 33031b3b2c..2059971a0d 100644 --- a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs +++ b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs @@ -2,6 +2,6 @@ use flowy_derive::ProtoBuf; #[derive(Eq, PartialEq, ProtoBuf, Default, Debug, Clone)] pub struct SearchFilterPB { - #[pb(index = 1, one_of)] - pub workspace_id: Option, + #[pb(index = 1)] + pub workspace_id: String, } diff --git a/frontend/rust-lib/flowy-search/src/event_handler.rs b/frontend/rust-lib/flowy-search/src/event_handler.rs index de611a078f..d79a719f6f 100644 --- a/frontend/rust-lib/flowy-search/src/event_handler.rs +++ b/frontend/rust-lib/flowy-search/src/event_handler.rs @@ -21,7 +21,14 @@ pub(crate) async fn search_handler( ) -> Result<(), FlowyError> { let query = data.into_inner(); let manager = upgrade_manager(manager)?; - manager.perform_search(query.search, query.filter, query.channel); + manager + .perform_search( + query.search, + query.stream_port, + query.filter, + query.search_id, + ) + .await; Ok(()) } diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index b3837668b8..1bb763b4a6 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::entities::{IndexTypePB, ResultIconPB, SearchResultPB}; +use crate::entities::{LocalSearchResponseItemPB, ResultIconPB}; #[derive(Debug, Serialize, Deserialize)] pub struct FolderIndexData { @@ -11,7 +11,7 @@ pub struct FolderIndexData { pub workspace_id: String, } -impl From for SearchResultPB { +impl From for LocalSearchResponseItemPB { fn from(data: FolderIndexData) -> Self { let icon = if data.icon.is_empty() { None @@ -23,14 +23,10 @@ impl From for SearchResultPB { }; Self { - index_type: IndexTypePB::View, - view_id: data.id.clone(), id: data.id, - data: data.title, - score: 0.0, + display_name: data.title, icon, workspace_id: data.workspace_id, - preview: None, } } } diff --git a/frontend/rust-lib/flowy-search/src/folder/handler.rs b/frontend/rust-lib/flowy-search/src/folder/handler.rs index f92e17cda1..e21ce1c98c 100644 --- a/frontend/rust-lib/flowy-search/src/folder/handler.rs +++ b/frontend/rust-lib/flowy-search/src/folder/handler.rs @@ -1,12 +1,14 @@ -use crate::{ - entities::{SearchFilterPB, SearchResultPB}, - services::manager::{SearchHandler, SearchType}, +use super::indexer::FolderIndexManagerImpl; +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedLocalSearchResponseItemPB, SearchFilterPB, SearchResponsePB, }; +use crate::services::manager::{SearchHandler, SearchType}; +use async_stream::stream; use flowy_error::FlowyResult; use lib_infra::async_trait::async_trait; +use std::pin::Pin; use std::sync::Arc; - -use super::indexer::FolderIndexManagerImpl; +use tokio_stream::{self, Stream}; pub struct FolderSearchHandler { pub index_manager: Arc, @@ -28,19 +30,26 @@ impl SearchHandler for FolderSearchHandler { &self, query: String, filter: Option, - ) -> FlowyResult> { - let mut results = self.index_manager.search(query, filter.clone())?; - if let Some(filter) = filter { - if let Some(workspace_id) = filter.workspace_id { - // Filter results by workspace ID - results.retain(|result| result.workspace_id == workspace_id); - } - } + ) -> Pin> + Send + 'static>> { + let index_manager = self.index_manager.clone(); - Ok(results) - } + Box::pin(stream! { + // Perform search (if search() returns a Result) + let mut items = match index_manager.search(query).await { + Ok(items) => items, + Err(err) => { + yield Err(err); + return; + } + }; - fn index_count(&self) -> u64 { - self.index_manager.num_docs() + if let Some(filter) = filter { + items.retain(|result| result.workspace_id == filter.workspace_id); + } + + // Build the search result. + let search_result = RepeatedLocalSearchResponseItemPB {items}; + yield Ok(CreateSearchResultPBArgs::default().local_search_result(Some(search_result)).build().unwrap()) + }) } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 8c1d5633ac..71ac5d5e60 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,190 +1,126 @@ -use std::{ - any::Any, - collections::HashMap, - fs, - ops::Deref, - path::Path, - sync::{Arc, Mutex, MutexGuard, Weak}, -}; - -use crate::{ - entities::{ResultIconTypePB, SearchFilterPB, SearchResultPB}, - folder::schema::{ - FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, - FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, - }, +use super::entities::FolderIndexData; +use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; +use crate::folder::schema::{ + FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, + FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, }; use collab::core::collab::{IndexContent, IndexContentReceiver}; use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexContent, ViewLayout}; use flowy_error::{FlowyError, FlowyResult}; use flowy_search_pub::entities::{FolderIndexManager, IndexManager, IndexableData}; use flowy_user::services::authenticate_user::AuthenticateUser; - -use strsim::levenshtein; +use lib_infra::async_trait::async_trait; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use std::{collections::HashMap, fs}; use tantivy::{ collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, - Index, IndexReader, IndexWriter, TantivyDocument, Term, + Index, IndexReader, IndexWriter, TantivyDocument, TantivyError, Term, }; +use tokio::sync::RwLock; +use tracing::{error, info}; +use uuid::Uuid; -use super::entities::FolderIndexData; +pub struct TantivyState { + pub path: PathBuf, + pub index: Index, + pub folder_schema: FolderSchema, + pub index_reader: IndexReader, + pub index_writer: IndexWriter, +} -#[derive(Clone)] -pub struct FolderIndexManagerImpl { - folder_schema: Option, - index: Option, - index_reader: Option, - index_writer: Option>>, +impl Drop for TantivyState { + fn drop(&mut self) { + tracing::trace!("Dropping TantivyState at {:?}", self.path); + } } const FOLDER_INDEX_DIR: &str = "folder_index"; +#[derive(Clone)] +pub struct FolderIndexManagerImpl { + auth_user: Weak, + state: Arc>>, +} + impl FolderIndexManagerImpl { - pub fn new(auth_user: Option>) -> Self { - let auth_user = match auth_user { - Some(auth_user) => auth_user, - None => { - return FolderIndexManagerImpl::empty(); - }, - }; - - // AuthenticateUser is required to get the index path - let authenticate_user = auth_user.upgrade(); - - // Storage path is the users data path with an index directory - // Eg. /usr/flowy-data/indexes - let storage_path = match authenticate_user { - Some(auth_user) => auth_user.get_index_path(), - None => { - tracing::error!("FolderIndexManager: AuthenticateUser is not available"); - return FolderIndexManagerImpl::empty(); - }, - }; - - // We check if the `folder_index` directory exists, if not we create it - let index_path = storage_path.join(Path::new(FOLDER_INDEX_DIR)); - if !index_path.exists() { - let res = fs::create_dir_all(&index_path); - if let Err(e) = res { - tracing::error!( - "FolderIndexManager failed to create index directory: {:?}", - e - ); - return FolderIndexManagerImpl::empty(); - } - } - - // The folder schema is used to define the fields of the index along - // with how they are stored and if the field is indexed - let folder_schema = FolderSchema::new(); - - // We open the existing or newly created folder_index directory - // This is required by the Tantivy Index, as it will use it to store - // and read index data - let index = match MmapDirectory::open(index_path) { - // We open or create an index that takes the directory r/w and the schema. - Ok(dir) => match Index::open_or_create(dir, folder_schema.schema.clone()) { - Ok(index) => index, - Err(e) => { - tracing::error!("FolderIndexManager failed to open index: {:?}", e); - return FolderIndexManagerImpl::empty(); - }, - }, - Err(e) => { - tracing::error!("FolderIndexManager failed to open index directory: {:?}", e); - return FolderIndexManagerImpl::empty(); - }, - }; - - // We only need one IndexReader per index - let index_reader = index.reader(); - let index_writer = index.writer(50_000_000); - - let (index_reader, index_writer) = match (index_reader, index_writer) { - (Ok(reader), Ok(writer)) => (reader, writer), - _ => { - tracing::error!("FolderIndexManager failed to instantiate index writer and/or reader"); - return FolderIndexManagerImpl::empty(); - }, - }; - + pub fn new(auth_user: Weak) -> Self { Self { - folder_schema: Some(folder_schema), - index: Some(index), - index_reader: Some(index_reader), - index_writer: Some(Arc::new(Mutex::new(index_writer))), + auth_user, + state: Arc::new(RwLock::new(None)), } } - fn index_all(&self, indexes: Vec) -> Result<(), FlowyError> { - if indexes.is_empty() { - return Ok(()); + async fn with_writer(&self, f: F) -> FlowyResult + where + F: FnOnce(&mut IndexWriter, &FolderSchema) -> FlowyResult, + { + let mut lock = self.state.write().await; + if let Some(ref mut state) = *lock { + f(&mut state.index_writer, &state.folder_schema) + } else { + Err(FlowyError::internal().with_context("Index not initialized. Call initialize first")) + } + } + + /// Initializes the state using the workspace directory. + async fn initialize(&self) -> FlowyResult<()> { + if let Some(state) = self.state.write().await.take() { + info!("Re-initializing folder indexer"); + drop(state); } - let mut index_writer = self.get_index_writer()?; - let folder_schema = self.get_folder_schema()?; + // Since the directory lock may not be immediately released, + // a workaround is implemented by waiting for 3 seconds before proceeding further. This delay helps + // to avoid errors related to trying to open an index directory while an IndexWriter is still active. + // + // Also, we don't need to initialize the indexer immediately. + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + let auth_user = self + .auth_user + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("AuthenticateUser is not available"))?; - for data in indexes { - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data.clone(), - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), - ]); + let index_path = auth_user.get_index_path()?.join(FOLDER_INDEX_DIR); + if !index_path.exists() { + fs::create_dir_all(&index_path).map_err(|e| { + error!("Failed to create folder index directory: {:?}", e); + FlowyError::internal().with_context("Failed to create folder index") + })?; } - index_writer.commit()?; + info!("Folder indexer initialized at: {:?}", index_path); + let folder_schema = FolderSchema::new(); + let dir = MmapDirectory::open(index_path.clone())?; + let index = Index::open_or_create(dir, folder_schema.schema.clone())?; + let index_reader = index.reader()?; + + let index_writer = match index.writer::<_>(50_000_000) { + Ok(index_writer) => index_writer, + Err(err) => { + if let TantivyError::LockFailure(_, _) = err { + error!( + "Failed to acquire lock for index writer: {:?}, retry later", + err + ); + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + } + index.writer::<_>(50_000_000)? + }, + }; + + *self.state.write().await = Some(TantivyState { + path: index_path, + index, + folder_schema, + index_reader, + index_writer, + }); Ok(()) } - pub fn num_docs(&self) -> u64 { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs()) - .unwrap_or(0) - } - - fn empty() -> Self { - Self { - folder_schema: None, - index: None, - index_reader: None, - index_writer: None, - } - } - - fn get_index_writer(&self) -> FlowyResult> { - match &self.index_writer { - Some(index_writer) => match index_writer.deref().lock() { - Ok(writer) => Ok(writer), - Err(e) => { - tracing::error!("FolderIndexManager failed to lock index writer: {:?}", e); - Err(FlowyError::folder_index_manager_unavailable()) - }, - }, - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - - fn get_folder_schema(&self) -> FlowyResult { - match &self.folder_schema { - Some(folder_schema) => Ok(folder_schema.clone()), - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - fn extract_icon( &self, view_icon: Option, @@ -200,132 +136,99 @@ impl FolderIndexManagerImpl { icon = Some(view_icon.value); } else { icon_ty = ResultIconTypePB::Icon.into(); - let layout_ty: i64 = view_layout.into(); + let layout_ty = view_layout as i64; icon = Some(layout_ty.to_string()); } - (icon, icon_ty) } - pub fn search( - &self, - query: String, - _filter: Option, - ) -> Result, FlowyError> { - let folder_schema = self.get_folder_schema()?; + /// Simple implementation to index all given data by spawning async tasks. + fn index_all(&self, data_vec: Vec) -> Result<(), FlowyError> { + for data in data_vec { + let indexer = self.clone(); + tokio::spawn(async move { + let _ = indexer.add_index(data).await; + }); + } + Ok(()) + } - let (index, index_reader) = self - .index + /// Searches the index using the given query string. + pub async fn search(&self, query: String) -> Result, FlowyError> { + let lock = self.state.read().await; + let state = lock .as_ref() - .zip(self.index_reader.as_ref()) .ok_or_else(FlowyError::folder_index_manager_unavailable)?; + let schema = &state.folder_schema; + let index = &state.index; + let reader = &state.index_reader; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let title_field = schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let mut parser = QueryParser::for_index(index, vec![title_field]); + parser.set_field_fuzzy(title_field, true, 2, true); - let length = query.len(); - let distance: u8 = if length >= 2 { 2 } else { 1 }; - - let mut query_parser = QueryParser::for_index(&index.clone(), vec![title_field]); - query_parser.set_field_fuzzy(title_field, true, distance, true); - let built_query = query_parser.parse_query(&query.clone())?; - - let searcher = index_reader.searcher(); - let mut search_results: Vec = vec![]; + let built_query = parser.parse_query(&query)?; + let searcher = reader.searcher(); let top_docs = searcher.search(&built_query, &TopDocs::with_limit(10))?; - for (_score, doc_address) in top_docs { - let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?; + let mut results = Vec::new(); + for (_score, doc_address) in top_docs { + let doc: TantivyDocument = searcher.doc(doc_address)?; + let named_doc = doc.to_named_doc(&schema.schema); let mut content = HashMap::new(); - let named_doc = retrieved_doc.to_named_doc(&folder_schema.schema); for (k, v) in named_doc.0 { content.insert(k, v[0].clone()); } - - if content.is_empty() { - continue; + if !content.is_empty() { + let s = serde_json::to_string(&content)?; + let result: LocalSearchResponseItemPB = serde_json::from_str::(&s)?.into(); + results.push(result); } - - let s = serde_json::to_string(&content)?; - let result: SearchResultPB = serde_json::from_str::(&s)?.into(); - let score = self.score_result(&query, &result.data); - search_results.push(result.with_score(score)); } - Ok(search_results) - } - - // Score result by distance - fn score_result(&self, query: &str, term: &str) -> f64 { - let distance = levenshtein(query, term) as f64; - 1.0 / (distance + 1.0) - } - - fn get_schema_fields(&self) -> Result<(Field, Field, Field, Field, Field), FlowyError> { - let folder_schema = match self.folder_schema.clone() { - Some(schema) => schema, - _ => return Err(FlowyError::folder_index_manager_unavailable()), - }; - - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - - Ok(( - id_field, - title_field, - icon_field, - icon_ty_field, - workspace_id_field, - )) + Ok(results) } } +#[async_trait] impl IndexManager for FolderIndexManagerImpl { - fn is_indexed(&self) -> bool { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs() > 0) - .unwrap_or(false) - } - - fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: String) { + async fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { let indexer = self.clone(); - let wid = workspace_id.clone(); + let wid = workspace_id; tokio::spawn(async move { while let Ok(msg) = rx.recv().await { match msg { IndexContent::Create(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.add_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid.clone(), - }); + let _ = indexer + .add_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => tracing::error!("FolderIndexManager error deserialize (create): {:?}", err), }, IndexContent::Update(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.update_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid.clone(), - }); + let _ = indexer + .update_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => error!("FolderIndexManager error deserialize (update): {:?}", err), }, IndexContent::Delete(ids) => { - if let Err(e) = indexer.remove_indices(ids) { - tracing::error!("FolderIndexManager error deserialize: {:?}", e); + if let Err(e) = indexer.remove_indices(ids).await { + error!("FolderIndexManager error (delete): {:?}", e); } }, } @@ -333,100 +236,108 @@ impl IndexManager for FolderIndexManagerImpl { }); } - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = - self.get_schema_fields()?; - - let delete_term = Term::from_field_text(id_field, &data.id.clone()); - - // Remove old index - index_writer.delete_term(delete_term); - + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), - ]); - - index_writer.commit()?; - - Ok(()) - } - - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - for id in ids { - let delete_term = Term::from_field_text(id_field, &id); - index_writer.delete_term(delete_term); - } - - index_writer.commit()?; - - Ok(()) - } - - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = - self.get_schema_fields()?; - - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id, - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id, - ]); - - index_writer.commit()?; - - Ok(()) - } - - /// Removes all indexes that are related by workspace id. This is useful - /// for cleaning indexes when eg. removing/leaving a workspace. - /// - fn remove_indices_for_workspace(&self, workspace_id: String) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - let id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - let delete_term = Term::from_field_text(id_field, &workspace_id); - index_writer.delete_term(delete_term); - - index_writer.commit()?; - - Ok(()) - } - - fn as_any(&self) -> &dyn Any { self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let delete_term = Term::from_field_text(id_field, &data.id); + index_writer.delete_term(delete_term); + + let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + for id in ids { + let delete_term = Term::from_field_text(id_field, &id); + index_writer.delete_term(delete_term); + } + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + let delete_term = Term::from_field_text(id_field, &workspace_id.to_string()); + index_writer.delete_term(delete_term); + index_writer.commit()?; + Ok(()) + }) + .await?; + Ok(()) + } + + async fn is_indexed(&self) -> bool { + let lock = self.state.read().await; + if let Some(ref state) = *lock { + state.index_reader.searcher().num_docs() > 0 + } else { + false + } } } +#[async_trait] impl FolderIndexManager for FolderIndexManagerImpl { - fn index_all_views(&self, views: Vec>, workspace_id: String) { + async fn initialize(&self) { + if let Err(e) = self.initialize().await { + error!("Failed to initialize FolderIndexManager: {:?}", e); + } + } + + fn index_all_views(&self, views: Vec>, workspace_id: Uuid) { let indexable_data = views .into_iter() - .map(|view| IndexableData::from_view(view, workspace_id.clone())) + .map(|view| IndexableData::from_view(view, workspace_id)) .collect(); - let _ = self.index_all(indexable_data); } @@ -434,29 +345,56 @@ impl FolderIndexManager for FolderIndexManagerImpl { &self, views: Vec>, changes: Vec, - workspace_id: String, + workspace_id: Uuid, ) { let mut views_iter = views.into_iter(); for change in changes { match change { FolderViewChange::Inserted { view_id } => { - let view = views_iter.find(|view| view.id == view_id); - if let Some(view) = view { - let indexable_data = IndexableData::from_view(view, workspace_id.clone()); - let _ = self.add_index(indexable_data); + if let Some(view) = views_iter.find(|view| view.id == view_id) { + let indexable_data = IndexableData::from_view(view, workspace_id); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.add_index(indexable_data).await; + }); } }, FolderViewChange::Updated { view_id } => { - let view = views_iter.find(|view| view.id == view_id); - if let Some(view) = view { - let indexable_data = IndexableData::from_view(view, workspace_id.clone()); - let _ = self.update_index(indexable_data); + if let Some(view) = views_iter.find(|view| view.id == view_id) { + let indexable_data = IndexableData::from_view(view, workspace_id); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.update_index(indexable_data).await; + }); } }, FolderViewChange::Deleted { view_ids } => { - let _ = self.remove_indices(view_ids); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.remove_indices(view_ids).await; + }); }, - }; + } } } } + +fn get_schema_fields( + folder_schema: &FolderSchema, +) -> Result<(Field, Field, Field, Field, Field), FlowyError> { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; + let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; + let workspace_id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + Ok(( + id_field, + title_field, + icon_field, + icon_ty_field, + workspace_id_field, + )) +} diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 84659b3037..a71449d5d2 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use super::notifier::{SearchNotifier, SearchResultChanged, SearchResultReceiverRunner}; -use crate::entities::{SearchFilterPB, SearchResultNotificationPB, SearchResultPB}; +use crate::entities::{SearchFilterPB, SearchResponsePB, SearchStatePB}; +use allo_isolate::Isolate; use flowy_error::FlowyResult; - use lib_infra::async_trait::async_trait; -use tokio::sync::broadcast; +use lib_infra::isolate_stream::{IsolateSink, SinkExt}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio_stream::{self, Stream, StreamExt}; +use tracing::{error, trace}; #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum SearchType { @@ -19,15 +20,12 @@ pub trait SearchHandler: Send + Sync + 'static { /// returns the type of search this handler is responsible for fn search_type(&self) -> SearchType; - /// performs a search and returns the results + /// performs a search and returns a stream of results async fn perform_search( &self, query: String, filter: Option, - ) -> FlowyResult>; - - /// returns the number of indexed objects - fn index_count(&self) -> u64; + ) -> Pin> + Send + 'static>>; } /// The [SearchManager] is used to inject multiple [SearchHandler]'s @@ -36,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static { /// pub struct SearchManager { pub handlers: HashMap>, - notifier: SearchNotifier, + current_search: Arc>>, } impl SearchManager { @@ -46,45 +44,87 @@ impl SearchManager { .map(|handler| (handler.search_type(), handler)) .collect(); - // Initialize Search Notifier - let (notifier, _) = broadcast::channel(100); - tokio::spawn(SearchResultReceiverRunner(Some(notifier.subscribe())).run()); - - Self { handlers, notifier } + Self { + handlers, + current_search: Arc::new(tokio::sync::Mutex::new(None)), + } } pub fn get_handler(&self, search_type: SearchType) -> Option<&Arc> { self.handlers.get(&search_type) } - pub fn perform_search( + pub async fn perform_search( &self, query: String, + stream_port: i64, filter: Option, - channel: Option, + search_id: String, ) { - let max: usize = self.handlers.len(); + // Cancel previous search by updating current_search + *self.current_search.lock().await = Some(search_id.clone()); + let handlers = self.handlers.clone(); + let sink = IsolateSink::new(Isolate::new(stream_port)); + let mut join_handles = vec![]; + let current_search = self.current_search.clone(); + + tracing::info!("[Search] perform search: {}", query); for (_, handler) in handlers { - let q = query.clone(); - let f = filter.clone(); - let ch = channel.clone(); - let notifier = self.notifier.clone(); + let mut clone_sink = sink.clone(); + let query = query.clone(); + let filter = filter.clone(); + let search_id = search_id.clone(); + let current_search = current_search.clone(); - tokio::spawn(async move { - let res = handler.perform_search(q.clone(), f).await; + let handle = tokio::spawn(async move { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] cancel search: {}", query); + return; + } - let items = res.unwrap_or_default(); + let mut stream = handler.perform_search(query.clone(), filter).await; + while let Some(Ok(search_result)) = stream.next().await { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] discard search stream: {}", query); + return; + } - let notification = SearchResultNotificationPB { - items, - sends: max as u64, - channel: ch, - query: q, + let resp = SearchStatePB { + response: Some(search_result), + search_id: search_id.clone(), + }; + if let Ok::, _>(data) = resp.try_into() { + if let Err(err) = clone_sink.send(data).await { + error!("Failed to send search result: {}", err); + break; + } + } + } + + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] discard search result: {}", query); + return; + } + + let resp = SearchStatePB { + response: None, + search_id: search_id.clone(), }; - - let _ = notifier.send(SearchResultChanged::SearchResultUpdate(notification)); + if let Ok::, _>(data) = resp.try_into() { + let _ = clone_sink.send(data).await; + } }); + join_handles.push(handle); } + futures::future::join_all(join_handles).await; } } + +async fn is_current_search( + current_search: &Arc>>, + search_id: &str, +) -> bool { + let current = current_search.lock().await; + current.as_ref().map_or(false, |id| id == search_id) +} diff --git a/frontend/rust-lib/flowy-search/src/services/mod.rs b/frontend/rust-lib/flowy-search/src/services/mod.rs index 2a417e6c62..ff8de9eb9a 100644 --- a/frontend/rust-lib/flowy-search/src/services/mod.rs +++ b/frontend/rust-lib/flowy-search/src/services/mod.rs @@ -1,2 +1 @@ pub mod manager; -pub mod notifier; diff --git a/frontend/rust-lib/flowy-search/src/services/notifier.rs b/frontend/rust-lib/flowy-search/src/services/notifier.rs deleted file mode 100644 index abbf5d4b0c..0000000000 --- a/frontend/rust-lib/flowy-search/src/services/notifier.rs +++ /dev/null @@ -1,61 +0,0 @@ -use async_stream::stream; -use flowy_notification::NotificationBuilder; -use futures::stream::StreamExt; -use tokio::sync::broadcast; - -use crate::entities::{SearchNotification, SearchResultNotificationPB}; - -const SEARCH_OBSERVABLE_SOURCE: &str = "Search"; -const SEARCH_ID: &str = "SEARCH_IDENTIFIER"; - -#[derive(Clone)] -pub enum SearchResultChanged { - SearchResultUpdate(SearchResultNotificationPB), -} - -pub type SearchNotifier = broadcast::Sender; - -pub(crate) struct SearchResultReceiverRunner( - pub(crate) Option>, -); - -impl SearchResultReceiverRunner { - pub(crate) async fn run(mut self) { - let mut receiver = self.0.take().expect("Only take once"); - let stream = stream! { - while let Ok(changed) = receiver.recv().await { - yield changed; - } - }; - stream - .for_each(|changed| async { - match changed { - SearchResultChanged::SearchResultUpdate(notification) => { - send_notification( - SEARCH_ID, - SearchNotification::DidUpdateResults, - notification.channel.clone(), - ) - .payload(notification) - .send(); - }, - } - }) - .await; - } -} - -#[tracing::instrument(level = "trace")] -pub fn send_notification( - id: &str, - ty: SearchNotification, - channel: Option, -) -> NotificationBuilder { - let observable_source = &format!( - "{}{}", - SEARCH_OBSERVABLE_SOURCE, - channel.unwrap_or_default() - ); - - NotificationBuilder::new(id, ty, observable_source) -} diff --git a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs index 14a72c6ce6..9c74850fcd 100644 --- a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs +++ b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs @@ -60,7 +60,7 @@ impl AFCloudConfiguration { let enable_sync_trace = std::env::var(APPFLOWY_ENABLE_SYNC_TRACE) .map(|v| v == "true" || v == "1") - .unwrap_or(false); + .unwrap_or(true); Ok(Self { base_url, diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index 9e67081eb7..c8710470b0 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -12,20 +12,15 @@ crate-type = ["cdylib", "rlib"] tracing.workspace = true futures.workspace = true futures-util = "0.3.26" -reqwest = { version = "0.11.20", features = ["native-tls-vendored", "multipart", "blocking"] } -hyper = "0.14" serde.workspace = true serde_json.workspace = true thiserror = "1.0" tokio = { workspace = true, features = ["sync"] } lazy_static = "1.4.0" bytes = { workspace = true, features = ["serde"] } -tokio-retry = "0.3" anyhow.workspace = true arc-swap.workspace = true -dashmap.workspace = true uuid.workspace = true -chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } collab = { workspace = true } collab-plugins = { workspace = true } collab-document = { workspace = true } @@ -33,8 +28,6 @@ collab-entity = { workspace = true } collab-folder = { workspace = true } collab-database = { workspace = true } collab-user = { workspace = true } -hex = "0.4.3" -postgrest = "1.0" lib-infra = { workspace = true } flowy-user-pub = { workspace = true } flowy-folder-pub = { workspace = true } @@ -46,14 +39,13 @@ flowy-search-pub = { workspace = true } flowy-storage = { workspace = true } flowy-storage-pub = { workspace = true } flowy-ai-pub = { workspace = true } -mime_guess = "2.0" -url = "2.4" tokio-util = "0.7" tokio-stream = { workspace = true, features = ["sync"] } -lib-dispatch = { workspace = true } -yrs.workspace = true rand = "0.8.5" semver = "1.0.23" +flowy-sqlite = { workspace = true } +flowy-ai = { workspace = true } +chrono.workspace = true [dependencies.client-api] workspace = true diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index b0f09b1530..65808e5b6b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,4 +1,11 @@ -use flowy_error::FlowyResult; +use collab_plugins::CollabKVDB; +use flowy_ai::ai_manager::AIUserService; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; pub const USER_UUID: &str = "uuid"; @@ -6,7 +13,51 @@ pub const USER_EMAIL: &str = "email"; pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. -pub trait ServerUser: Send + Sync { +#[async_trait] +pub trait LoggedUser: Send + Sync { /// different user might return different workspace id. - fn workspace_id(&self) -> FlowyResult; + fn workspace_id(&self) -> FlowyResult; + + fn user_id(&self) -> FlowyResult; + async fn is_local_mode(&self) -> FlowyResult; + + fn get_sqlite_db(&self, uid: i64) -> Result; + + fn get_collab_db(&self, uid: i64) -> Result, FlowyError>; + + fn application_root_dir(&self) -> Result; +} + +pub struct AIUserServiceImpl(pub Weak); + +impl AIUserServiceImpl { + fn logged_user(&self) -> FlowyResult> { + self + .0 + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("User is not logged in")) + } +} + +#[async_trait] +impl AIUserService for AIUserServiceImpl { + fn user_id(&self) -> Result { + self.logged_user()?.user_id() + } + + async fn is_local_model(&self) -> FlowyResult { + self.logged_user()?.is_local_mode().await + } + + fn workspace_id(&self) -> Result { + self.logged_user()?.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.logged_user()?.get_sqlite_db(uid) + } + + fn application_root_dir(&self) -> Result { + self.logged_user()?.application_root_dir() + } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs index 7512b9e48c..6086f7084b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ ChatQuestionQuery, CompleteTextParams, RepeatedRelatedQuestion, ResponseFormat, @@ -7,39 +8,41 @@ use client_api::entity::chat_dto::{ RepeatedChatMessage, }; use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, LocalAIConfig, - StreamAnswer, StreamComplete, SubscriptionPlan, UpdateChatParams, + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, ModelList, StreamAnswer, + StreamComplete, UpdateChatParams, }; use flowy_error::FlowyError; use futures_util::{StreamExt, TryStreamExt}; use lib_infra::async_trait::async_trait; -use lib_infra::util::{get_operating_system, OperatingSystem}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; use tracing::trace; +use uuid::Uuid; -pub(crate) struct AFCloudChatCloudServiceImpl { +pub(crate) struct CloudChatServiceImpl { pub inner: T, } #[async_trait] -impl ChatCloudService for AFCloudChatCloudServiceImpl +impl ChatCloudService for CloudChatServiceImpl where T: AFServer, { async fn create_chat( &self, - _uid: &i64, - workspace_id: &str, - chat_id: &str, - rag_ids: Vec, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, ) -> Result<(), FlowyError> { let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = CreateChatParams { chat_id, - name: "".to_string(), + name: name.to_string(), rag_ids, }; try_get_client? @@ -52,23 +55,20 @@ where async fn create_question( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, message_type: ChatMessageType, - metadata: &[ChatMessageMetadata], ) -> Result { - let workspace_id = workspace_id.to_string(); let chat_id = chat_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = CreateChatMessageParams { content: message.to_string(), message_type, - metadata: metadata.to_vec(), }; let message = try_get_client? - .create_question(&workspace_id, &chat_id, params) + .create_question(workspace_id, &chat_id, params) .await .map_err(FlowyError::from)?; Ok(message) @@ -76,8 +76,8 @@ where async fn create_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message: &str, question_id: i64, metadata: Option, @@ -89,7 +89,7 @@ where question_message_id: question_id, }; let message = try_get_client? - .save_answer(workspace_id, chat_id, params) + .save_answer(workspace_id, chat_id.to_string().as_str(), params) .await .map_err(FlowyError::from)?; Ok(message) @@ -97,16 +97,18 @@ where async fn stream_answer( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, format: ResponseFormat, + ai_model: Option, ) -> Result { trace!( - "stream_answer: workspace_id={}, chat_id={}, format={:?}", + "stream_answer: workspace_id={}, chat_id={}, format={:?}, model: {:?}", workspace_id, chat_id, - format + format, + ai_model, ); let try_get_client = self.inner.try_get_client(); let result = try_get_client? @@ -117,6 +119,7 @@ where question_id: message_id, format, }, + ai_model.map(|v| v.name), ) .await; @@ -126,13 +129,13 @@ where async fn get_answer( &self, - workspace_id: &str, - chat_id: &str, - question_message_id: i64, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_answer(workspace_id, chat_id, question_message_id) + .get_answer(workspace_id, chat_id.to_string().as_str(), question_id) .await .map_err(FlowyError::from)?; Ok(resp) @@ -140,14 +143,14 @@ where async fn get_chat_messages( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, offset: MessageCursor, limit: u64, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_chat_messages(workspace_id, chat_id, offset, limit) + .get_chat_messages(workspace_id, chat_id.to_string().as_str(), offset, limit) .await .map_err(FlowyError::from)?; @@ -156,13 +159,17 @@ where async fn get_question_from_answer_id( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, answer_message_id: i64, ) -> Result { let try_get_client = self.inner.try_get_client()?; let resp = try_get_client - .get_question_message_from_answer_id(workspace_id, chat_id, answer_message_id) + .get_question_message_from_answer_id( + workspace_id, + chat_id.to_string().as_str(), + answer_message_id, + ) .await .map_err(FlowyError::from)? .ok_or_else(FlowyError::record_not_found)?; @@ -172,13 +179,14 @@ where async fn get_related_message( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, message_id: i64, + ai_model: Option, ) -> Result { let try_get_client = self.inner.try_get_client(); let resp = try_get_client? - .get_chat_related_question(workspace_id, chat_id, message_id) + .get_chat_related_question(workspace_id, chat_id.to_string().as_str(), message_id) .await .map_err(FlowyError::from)?; @@ -187,87 +195,76 @@ where async fn stream_complete( &self, - workspace_id: &str, + workspace_id: &Uuid, params: CompleteTextParams, + ai_model: Option, ) -> Result { let stream = self .inner .try_get_client()? - .stream_completion_text(workspace_id, params) + .stream_completion_v2(workspace_id, params, ai_model.map(|v| v.name)) .await .map_err(FlowyError::from)? .map_err(FlowyError::from); + Ok(stream.boxed()) } - async fn index_file( + async fn embed_file( &self, - _workspace_id: &str, - _file_path: &Path, - _chat_id: &str, - _metadata: Option>, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, ) -> Result<(), FlowyError> { - return Err( + Err( FlowyError::not_support() .with_context("indexing file with appflowy cloud is not suppotred yet"), - ); - } - - async fn get_local_ai_config(&self, workspace_id: &str) -> Result { - let system = get_operating_system(); - let platform = match system { - OperatingSystem::MacOS => "macos", - _ => { - return Err( - FlowyError::not_support() - .with_context("local ai is not supported on this operating system"), - ); - }, - }; - let config = self - .inner - .try_get_client()? - .get_local_ai_config(workspace_id, platform) - .await?; - Ok(config) - } - - async fn get_workspace_plan( - &self, - workspace_id: &str, - ) -> Result, FlowyError> { - let plans = self - .inner - .try_get_client()? - .get_active_workspace_subscriptions(workspace_id) - .await?; - Ok(plans) + ) } async fn get_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, ) -> Result { let settings = self .inner .try_get_client()? - .get_chat_settings(workspace_id, chat_id) + .get_chat_settings(workspace_id, chat_id.to_string().as_str()) .await?; Ok(settings) } async fn update_chat_settings( &self, - workspace_id: &str, - chat_id: &str, + workspace_id: &Uuid, + chat_id: &Uuid, params: UpdateChatParams, ) -> Result<(), FlowyError> { self .inner .try_get_client()? - .update_chat_settings(workspace_id, chat_id, params) + .update_chat_settings(workspace_id, chat_id.to_string().as_str(), params) .await?; Ok(()) } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + let list = self + .inner + .try_get_client()? + .get_model_list(workspace_id) + .await?; + Ok(list) + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + let setting = self + .inner + .try_get_client()? + .get_workspace_settings(workspace_id.to_string().as_str()) + .await?; + Ok(setting.ai_model) + } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index 4d264365ec..f29a7f89ad 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,3 +1,7 @@ +#![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; +use crate::af_cloud::impls::util::check_request_workspace_id_is_match; +use crate::af_cloud::AFServer; use client_api::entity::ai_dto::{ SummarizeRowData, SummarizeRowParams, TranslateRowData, TranslateRowParams, }; @@ -6,24 +10,20 @@ use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; use client_api::error::ErrorCode::RecordNotFound; use collab::entity::EncodedCollab; use collab_entity::CollabType; -use serde_json::{Map, Value}; -use std::sync::Arc; -use tracing::{error, instrument}; - use flowy_database_pub::cloud::{ DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, TranslateRowContent, TranslateRowResponse, }; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; - -use crate::af_cloud::define::ServerUser; -use crate::af_cloud::impls::util::check_request_workspace_id_is_match; -use crate::af_cloud::AFServer; +use serde_json::{Map, Value}; +use std::sync::Weak; +use tracing::{error, instrument}; +use uuid::Uuid; pub(crate) struct AFCloudDatabaseCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } #[async_trait] @@ -35,24 +35,21 @@ where #[allow(clippy::blocks_in_conditions)] async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id.clone(), collab_type.clone()), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), }; let result = try_get_client?.get_collab(params).await; match result { Ok(data) => { check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, + workspace_id, + &self.logged_user, format!("get database object: {}:{}", object_id, collab_type), )?; Ok(Some(data.encode_collab)) @@ -71,17 +68,17 @@ where #[allow(clippy::blocks_in_conditions)] async fn create_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, + workspace_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let encoded_collab_v1 = encoded_collab .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?; let params = CreateCollabParams { - workspace_id: workspace_id.to_string(), - object_id: object_id.to_string(), + workspace_id: *workspace_id, + object_id: *object_id, encoded_collab_v1, collab_type, }; @@ -92,20 +89,22 @@ where #[instrument(level = "debug", skip_all)] async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); let client = try_get_client?; let params = object_ids .into_iter() - .map(|object_id| QueryCollab::new(object_id, object_ty.clone())) + .map(|object_id| QueryCollab::new(object_id, object_ty)) .collect(); - let results = client.batch_get_collab(&workspace_id, params).await?; - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "batch get database object")?; + let results = client.batch_get_collab(workspace_id, params).await?; + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + "batch get database object", + )?; Ok( results .0 @@ -131,8 +130,8 @@ where async fn get_database_collab_object_snapshots( &self, - _object_id: &str, - _limit: usize, + object_id: &Uuid, + limit: usize, ) -> Result, FlowyError> { Ok(vec![]) } @@ -145,17 +144,17 @@ where { async fn summary_database_row( &self, - workspace_id: &str, - _object_id: &str, - summary_row: SummaryRowContent, + workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, ) -> Result { let try_get_client = self.inner.try_get_client(); - let map: Map = summary_row + let map: Map = _summary_row .into_iter() .map(|(key, value)| (key, Value::String(value))) .collect(); let params = SummarizeRowParams { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, data: SummarizeRowData::Content(map), }; let data = try_get_client?.summarize_row(params).await?; @@ -164,19 +163,21 @@ where async fn translate_database_row( &self, - workspace_id: &str, - translate_row: TranslateRowContent, - language: &str, + workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, ) -> Result { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let data = TranslateRowData { - cells: translate_row, - language: language.to_string(), + cells: _translate_row, + language: _language.to_string(), include_header: false, }; - let params = TranslateRowParams { workspace_id, data }; + let params = TranslateRowParams { + workspace_id: workspace_id.to_string(), + data, + }; let data = try_get_client?.translate_row(params).await?; Ok(data) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index d73bbe4c75..1e000d5971 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -1,3 +1,4 @@ +#![allow(unused_variables)] use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; @@ -5,20 +6,20 @@ use collab::entity::EncodedCollab; use collab::preclude::Collab; use collab_document::document::Document; use collab_entity::CollabType; -use std::sync::Arc; -use tracing::instrument; - use flowy_document_pub::cloud::*; use flowy_error::FlowyError; use lib_infra::async_trait::async_trait; +use std::sync::Weak; +use tracing::instrument; +use uuid::Uuid; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudDocumentCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } #[async_trait] @@ -29,12 +30,12 @@ where #[instrument(level = "debug", skip_all, fields(document_id = %document_id))] async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.to_string(), - inner: QueryCollab::new(document_id.to_string(), CollabType::Document), + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), }; let doc_state = self .inner @@ -48,7 +49,7 @@ where check_request_workspace_id_is_match( workspace_id, - &self.user, + &self.logged_user, format!("get document doc state:{}", document_id), )?; @@ -57,9 +58,9 @@ where async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, + document_id: &Uuid, + limit: usize, + workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } @@ -67,12 +68,12 @@ where #[instrument(level = "debug", skip_all)] async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let params = QueryCollabParams { - workspace_id: workspace_id.to_string(), - inner: QueryCollab::new(document_id.to_string(), CollabType::Document), + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), }; let doc_state = self .inner @@ -84,12 +85,12 @@ where .to_vec(); check_request_workspace_id_is_match( workspace_id, - &self.user, + &self.logged_user, format!("Get {} document", document_id), )?; let collab = Collab::new_with_source( CollabOrigin::Empty, - document_id, + document_id.to_string().as_str(), DataSource::DocStateV1(doc_state), vec![], false, @@ -100,13 +101,13 @@ where async fn create_document_collab( &self, - workspace_id: &str, - document_id: &str, + workspace_id: &Uuid, + document_id: &Uuid, encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { let params = CreateCollabParams { - workspace_id: workspace_id.to_string(), - object_id: document_id.to_string(), + workspace_id: *workspace_id, + object_id: *document_id, encoded_collab_v1: encoded_collab .encode_to_bytes() .map_err(|err| FlowyError::internal().with_context(err))?, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs index 9f4e15f430..8db806a0da 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs @@ -1,9 +1,10 @@ use crate::af_cloud::AFServer; use client_api::entity::{CompleteUploadRequest, CreateUploadRequest}; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct AFCloudFileStorageServiceImpl { pub client: T, @@ -56,10 +57,10 @@ where async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, - ) -> Result { + ) -> FlowyResult { let url = self .client .try_get_client()? @@ -67,14 +68,14 @@ where Ok(url) } - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)> { + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { let value = self.client.try_get_client().ok()?.parse_blob_url_v1(url)?; Some(value) } async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -109,7 +110,7 @@ where async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -134,7 +135,7 @@ where async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index 5457164a87..e6408bc24c 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -10,11 +10,11 @@ use collab_entity::CollabType; use collab_folder::RepeatedViewIdentifier; use serde_json::to_vec; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::Weak; use tracing::{instrument, trace}; use uuid::Uuid; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, FullSyncCollabParams, Workspace, WorkspaceRecord, @@ -22,13 +22,13 @@ use flowy_folder_pub::cloud::{ use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudFolderCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } #[async_trait] @@ -58,12 +58,10 @@ where }) } - async fn open_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client(); - let client = try_get_client?; - let _ = client.open_workspace(&workspace_id).await?; + let _ = client.open_workspace(workspace_id).await?; Ok(()) } @@ -88,16 +86,14 @@ where #[instrument(level = "debug", skip_all)] async fn get_folder_data( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: &i64, ) -> Result, FlowyError> { let uid = *uid; - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(workspace_id.clone(), CollabType::Folder), + workspace_id: *workspace_id, + inner: QueryCollab::new(*workspace_id, CollabType::Folder), }; let doc_state = try_get_client? .get_collab(params) @@ -106,15 +102,15 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder data")?; + check_request_workspace_id_is_match(workspace_id, &self.logged_user, "get folder data")?; let folder = Folder::from_collab_doc_state( uid, CollabOrigin::Empty, DataSource::DocStateV1(doc_state), - &workspace_id, + &workspace_id.to_string(), vec![], )?; - Ok(folder.get_folder_data(&workspace_id)) + Ok(folder.get_folder_data(&workspace_id.to_string())) } async fn get_folder_snapshots( @@ -128,18 +124,15 @@ where #[instrument(level = "debug", skip_all)] async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, _uid: i64, collab_type: CollabType, - object_id: &str, + object_id: &Uuid, ) -> Result, FlowyError> { - let object_id = object_id.to_string(); - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id, collab_type), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), }; let doc_state = try_get_client? .get_collab(params) @@ -148,20 +141,19 @@ where .encode_collab .doc_state .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder doc state")?; + check_request_workspace_id_is_match(workspace_id, &self.logged_user, "get folder doc state")?; Ok(doc_state) } async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, params: FullSyncCollabParams, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); try_get_client? .collab_full_sync( - &workspace_id, + workspace_id, ¶ms.object_id, params.collab_type, params.encoded_collab.doc_state.to_vec(), @@ -173,10 +165,9 @@ where async fn batch_create_folder_collab_objects( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = objects .into_iter() @@ -189,7 +180,7 @@ where }) .collect::>(); try_get_client? - .create_collab_list(&workspace_id, params) + .create_collab_list(workspace_id, params) .await?; Ok(()) } @@ -200,10 +191,9 @@ where async fn publish_view( &self, - workspace_id: &str, + workspace_id: &Uuid, payload: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); let params = payload .into_iter() @@ -228,36 +218,27 @@ where }) .collect::>(); try_get_client? - .publish_collabs(&workspace_id, params) + .publish_collabs(workspace_id, params) .await?; Ok(()) } async fn unpublish_views( &self, - workspace_id: &str, - view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); - let view_uuids = view_ids - .iter() - .map(|id| Uuid::parse_str(id).unwrap_or(Uuid::nil())) - .collect::>(); try_get_client? - .unpublish_collabs(&workspace_id, &view_uuids) + .unpublish_collabs(workspace_id, &view_ids) .await?; Ok(()) } - async fn get_publish_info(&self, view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { let try_get_client = self.inner.try_get_client(); - let view_id = Uuid::parse_str(view_id) - .map_err(|_| FlowyError::new(ErrorCode::InvalidParams, "Invalid view id")); - - let view_id = view_id?; let info = try_get_client? - .get_published_collab_info(&view_id) + .get_published_collab_info(view_id) .await .map_err(FlowyError::from)?; Ok(info) @@ -265,14 +246,11 @@ where async fn set_publish_name( &self, - workspace_id: &str, - view_id: String, + workspace_id: &Uuid, + view_id: Uuid, new_name: String, ) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client()?; - let view_id = Uuid::parse_str(&view_id) - .map_err(|_| FlowyError::new(ErrorCode::InvalidParams, "Invalid view id"))?; - try_get_client .patch_published_collabs( workspace_id, @@ -290,36 +268,33 @@ where async fn set_publish_namespace( &self, - workspace_id: &str, + workspace_id: &Uuid, new_namespace: String, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.inner.try_get_client(); try_get_client? - .set_workspace_publish_namespace(&workspace_id, new_namespace) + .set_workspace_publish_namespace(workspace_id, new_namespace) .await?; Ok(()) } - async fn get_publish_namespace(&self, workspace_id: &str) -> Result { - let workspace_id = workspace_id.to_string(); + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { let namespace = self .inner .try_get_client()? - .get_workspace_publish_namespace(&workspace_id) + .get_workspace_publish_namespace(workspace_id) .await?; Ok(namespace) } async fn list_published_views( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); let published_views = self .inner .try_get_client()? - .list_published_views(&workspace_id) + .list_published_views(workspace_id) .await .map_err(FlowyError::from)?; Ok(published_views) @@ -327,7 +302,7 @@ where async fn get_default_published_view_info( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let default_published_view_info = self .inner @@ -340,7 +315,7 @@ where async fn set_default_published_view( &self, - workspace_id: &str, + workspace_id: &Uuid, view_id: uuid::Uuid, ) -> Result<(), FlowyError> { self @@ -352,7 +327,7 @@ where Ok(()) } - async fn remove_default_published_view(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { self .inner .try_get_client()? diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs index 552a94068a..1ce0995144 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs @@ -1,18 +1,16 @@ -use client_api::entity::search_dto::SearchDocumentResponseItem; +use crate::af_cloud::AFServer; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; use flowy_error::FlowyError; use flowy_search_pub::cloud::SearchCloudService; use lib_infra::async_trait::async_trait; - -use crate::af_cloud::AFServer; +use uuid::Uuid; pub(crate) struct AFCloudSearchCloudServiceImpl { pub inner: T, } -// The limit of what the score should be for results, used to -// filter out irrelevant results. -// https://community.openai.com/t/rule-of-thumb-cosine-similarity-thresholds/693670/5 -const SCORE_LIMIT: f64 = 0.3; const DEFAULT_PREVIEW: u32 = 80; #[async_trait] @@ -22,19 +20,27 @@ where { async fn document_search( &self, - workspace_id: &str, + workspace_id: &Uuid, query: String, ) -> Result, FlowyError> { let client = self.inner.try_get_client()?; let result = client - .search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW) + .search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW, None) .await?; - // Filter out irrelevant results - let result = result - .into_iter() - .filter(|r| r.score > SCORE_LIMIT) - .collect(); + Ok(result) + } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let client = self.inner.try_get_client()?; + let result = client + .generate_search_summary(workspace_id, &query, search_results) + .await?; Ok(result) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 4f7ce7a3e9..d6260d9e09 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::str::FromStr; +use std::sync::{Arc, Weak}; use anyhow::anyhow; use arc_swap::ArcSwapOption; @@ -12,25 +13,25 @@ use client_api::entity::workspace_dto::{ WorkspaceMemberInvitation, }; use client_api::entity::{ - AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, - AuthProvider, CollabParams, CreateCollabParams, QueryWorkspaceMember, + AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, AuthProvider, + CollabParams, CreateCollabParams, GotrueTokenResponse, QueryWorkspaceMember, }; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api::{Client, ClientConfiguration}; use collab_entity::{CollabObject, CollabType}; -use tracing::instrument; +use tracing::{instrument, trace}; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, UserUpdateReceiver}; use flowy_user_pub::entities::{ - AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserProfile, UserWorkspace, + WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; use uuid::Uuid; -use crate::af_cloud::define::{ServerUser, USER_SIGN_IN_URL}; +use crate::af_cloud::define::{LoggedUser, USER_SIGN_IN_URL}; use crate::af_cloud::impls::user::dto::{ af_update_from_update_params, from_af_workspace_member, to_af_role, user_profile_from_af_profile, }; @@ -43,19 +44,19 @@ use super::dto::{from_af_workspace_invitation_status, to_workspace_invitation_st pub(crate) struct AFCloudUserAuthServiceImpl { server: T, user_change_recv: ArcSwapOption>, - user: Arc, + logged_user: Weak, } impl AFCloudUserAuthServiceImpl { pub(crate) fn new( server: T, user_change_recv: tokio::sync::mpsc::Receiver, - user: Arc, + logged_user: Weak, ) -> Self { Self { server, user_change_recv: ArcSwapOption::new(Some(Arc::new(user_change_recv))), - user, + logged_user, } } } @@ -120,16 +121,13 @@ where &self, email: &str, password: &str, - ) -> Result { + ) -> Result { let password = password.to_string(); let email = email.to_string(); let try_get_client = self.server.try_get_client(); let client = try_get_client?; - client.sign_in_password(&email, &password).await?; - let profile = client.get_profile().await?; - let token = client.get_token()?; - let profile = user_profile_from_af_profile(token, profile)?; - Ok(profile) + let response = client.sign_in_password(&email, &password).await?; + Ok(response.gotrue_response) } async fn sign_in_with_magic_link( @@ -147,6 +145,19 @@ where Ok(()) } + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + let email = email.to_owned(); + let passcode = passcode.to_owned(); + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let response = client.sign_in_with_passcode(&email, &passcode).await?; + Ok(response) + } + async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result { let provider = AuthProvider::from(provider); let try_get_client = self.server.try_get_client(); @@ -157,11 +168,7 @@ where Ok(url) } - async fn update_user( - &self, - _credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; client @@ -171,13 +178,13 @@ where } #[instrument(level = "debug", skip_all)] - async fn get_user_profile( - &self, - _credential: UserCredentials, - ) -> Result { + async fn get_user_profile(&self, _uid: i64) -> Result { let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); - let expected_workspace_id = cloned_user.workspace_id()?; + let expected_workspace_id = self + .logged_user + .upgrade() + .ok_or_else(FlowyError::user_not_login)? + .workspace_id()?; let client = try_get_client?; let profile = client.get_profile().await?; let token = client.get_token()?; @@ -185,15 +192,18 @@ where // Discard the response if the user has switched to a new workspace. This avoids updating the // user profile with potentially outdated information when the workspace ID no longer matches. - check_request_workspace_id_is_match(&expected_workspace_id, &cloned_user, "get user profile")?; + check_request_workspace_id_is_match( + &expected_workspace_id, + &self.logged_user, + "get user profile", + )?; Ok(profile) } - async fn open_workspace(&self, workspace_id: &str) -> Result { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; - let af_workspace = client.open_workspace(&workspace_id).await?; + let af_workspace = client.open_workspace(workspace_id).await?; Ok(to_user_workspace(af_workspace)) } @@ -222,40 +232,34 @@ where async fn patch_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let owned_workspace_id = workspace_id.to_owned(); - let owned_workspace_name = new_workspace_name.map(|s| s.to_owned()); - let owned_workspace_icon = new_workspace_icon.map(|s| s.to_owned()); - let workspace_id: Uuid = owned_workspace_id - .parse() - .map_err(|_| ErrorCode::InvalidParams)?; + let workspace_id = workspace_id.to_owned(); let client = try_get_client?; client .patch_workspace(PatchWorkspaceParam { workspace_id, - workspace_name: owned_workspace_name, - workspace_icon: owned_workspace_icon, + workspace_name: new_workspace_name, + workspace_icon: new_workspace_icon, }) .await?; Ok(()) } - async fn delete_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let workspace_id_owned = workspace_id.to_owned(); let client = try_get_client?; - client.delete_workspace(&workspace_id_owned).await?; + client.delete_workspace(workspace_id).await?; Ok(()) } async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); @@ -300,11 +304,11 @@ where async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); try_get_client? - .remove_workspace_members(workspace_id, vec![user_email]) + .remove_workspace_members(&workspace_id, vec![user_email]) .await?; Ok(()) } @@ -312,20 +316,20 @@ where async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let changeset = WorkspaceMemberChangeset::new(user_email).with_role(to_af_role(role)); try_get_client? - .update_workspace_member(workspace_id, changeset) + .update_workspace_member(&workspace_id, changeset) .await?; Ok(()) } async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let members = try_get_client? @@ -337,38 +341,21 @@ where Ok(members) } - async fn get_workspace_member( - &self, - workspace_id: String, - uid: i64, - ) -> Result { - let try_get_client = self.server.try_get_client(); - let client = try_get_client?; - let query = QueryWorkspaceMember { - workspace_id: workspace_id.clone(), - uid, - }; - let member = client.get_workspace_member(query).await?; - Ok(from_af_workspace_member(member)) - } - #[instrument(level = "debug", skip_all)] async fn get_user_awareness_doc_state( &self, _uid: i64, - workspace_id: &str, - object_id: &str, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); + let cloned_user = self.logged_user.clone(); let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab::new(object_id, CollabType::UserAwareness), + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, CollabType::UserAwareness), }; let resp = try_get_client?.get_collab(params).await?; - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get user awareness object")?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "get user awareness object")?; Ok(resp.encode_collab.doc_state.to_vec()) } @@ -377,10 +364,6 @@ where Arc::into_inner(rx) } - async fn reset_workspace(&self, _collab_object: CollabObject) -> Result<(), FlowyError> { - Ok(()) - } - async fn create_collab_object( &self, collab_object: &CollabObject, @@ -389,9 +372,12 @@ where let try_get_client = self.server.try_get_client(); let collab_object = collab_object.clone(); let client = try_get_client?; + let workspace_id = Uuid::from_str(&collab_object.workspace_id)?; + let object_id = Uuid::from_str(&collab_object.object_id)?; + let params = CreateCollabParams { - workspace_id: collab_object.workspace_id, - object_id: collab_object.object_id, + workspace_id, + object_id, collab_type: collab_object.collab_type, encoded_collab_v1: data, }; @@ -401,41 +387,43 @@ where async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError> { - let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); let params = objects .into_iter() - .map(|object| { - CollabParams::new( - object.object_id, - u8::from(object.collab_type).into(), - object.encoded_collab, - ) + .flat_map(|object| { + Uuid::from_str(&object.object_id) + .map(|object_id| { + CollabParams::new( + object_id, + u8::from(object.collab_type).into(), + object.encoded_collab, + ) + }) + .ok() }) .collect::>(); try_get_client? - .create_collab_list(&workspace_id, params) + .create_collab_list(workspace_id, params) .await .map_err(FlowyError::from)?; Ok(()) } - async fn leave_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; - client.leave_workspace(&workspace_id).await?; + client.leave_workspace(workspace_id).await?; Ok(()) } async fn subscribe_workspace( &self, - workspace_id: String, + workspace_id: Uuid, recurring_interval: RecurringInterval, - subscription_plan: SubscriptionPlan, + workspace_subscription_plan: SubscriptionPlan, success_url: String, ) -> Result { let try_get_client = self.server.try_get_client(); @@ -445,37 +433,27 @@ where .create_subscription( &workspace_id, recurring_interval, - subscription_plan, + workspace_subscription_plan, &success_url, ) .await?; Ok(payment_link) } - async fn get_workspace_member_info( + async fn get_workspace_member( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, ) -> Result { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); let client = try_get_client?; let params = QueryWorkspaceMember { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, uid, }; let member = client.get_workspace_member(params).await?; - let role = match member.role { - AFRole::Owner => Role::Owner, - AFRole::Member => Role::Member, - AFRole::Guest => Role::Guest, - }; - Ok(WorkspaceMember { - email: member.email, - role, - name: member.name, - avatar_url: member.avatar_url, - }) + + Ok(from_af_workspace_member(member)) } async fn get_workspace_subscriptions( @@ -489,11 +467,13 @@ where async fn get_workspace_subscription_one( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let workspace_subscriptions = client.get_workspace_subscriptions(&workspace_id).await?; + let workspace_subscriptions = client + .get_workspace_subscriptions(&workspace_id.to_string()) + .await?; Ok(workspace_subscriptions) } @@ -518,23 +498,25 @@ where async fn get_workspace_plan( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let client = try_get_client?; let plans = client - .get_active_workspace_subscriptions(&workspace_id) + .get_active_workspace_subscriptions(&workspace_id.to_string()) .await?; Ok(plans) } async fn get_workspace_usage( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result { let try_get_client = self.server.try_get_client(); let client = try_get_client?; - let usage = client.get_workspace_usage_and_limit(&workspace_id).await?; + let usage = client + .get_workspace_usage_and_limit(&workspace_id.to_string()) + .await?; Ok(usage) } @@ -547,7 +529,7 @@ where async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> Result<(), FlowyError> { @@ -555,7 +537,7 @@ where let client = try_get_client?; client .set_subscription_recurring_interval(&SetSubscriptionRecurringInterval { - workspace_id, + workspace_id: workspace_id.to_string(), plan, recurring_interval, }) @@ -572,7 +554,7 @@ where async fn get_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, ) -> Result { let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); @@ -583,9 +565,10 @@ where async fn update_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, workspace_settings: AFWorkspaceSettingsChange, ) -> Result { + trace!("Sync workspace settings: {:?}", workspace_settings); let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); let client = try_get_client?; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index 0710bcc2b2..ba13a7fbca 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -3,22 +3,12 @@ use client_api::entity::auth_dto::{UpdateUserParams, UserMetaData}; use client_api::entity::{AFRole, AFUserProfile, AFWorkspaceInvitationStatus, AFWorkspaceMember}; use flowy_user_pub::entities::{ - Authenticator, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, - WorkspaceMember, USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, - USER_METADATA_STABILITY_AI_KEY, + AuthType, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, WorkspaceMember, + USER_METADATA_ICON_URL, }; -use crate::af_cloud::impls::user::util::encryption_type_from_profile; - pub fn af_update_from_update_params(update: UpdateUserProfileParams) -> UpdateUserParams { let mut user_metadata = UserMetaData::new(); - if let Some(openai_key) = update.openai_key { - user_metadata.insert(USER_METADATA_OPEN_AI_KEY, openai_key); - } - - if let Some(stability_ai_key) = update.stability_ai_key { - user_metadata.insert(USER_METADATA_STABILITY_AI_KEY, stability_ai_key); - } if let Some(icon_url) = update.icon_url { user_metadata.insert(USER_METADATA_ICON_URL, icon_url); @@ -36,19 +26,12 @@ pub fn user_profile_from_af_profile( token: String, profile: AFUserProfile, ) -> Result { - let encryption_type = encryption_type_from_profile(&profile); - let (icon_url, openai_key, stability_ai_key) = { + let icon_url = { profile .metadata .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - ) + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) }) .unwrap_or_default() }; @@ -58,13 +41,9 @@ pub fn user_profile_from_af_profile( name: profile.name.unwrap_or("".to_string()), token, icon_url: icon_url.unwrap_or_default(), - openai_key: openai_key.unwrap_or_default(), - stability_ai_key: stability_ai_key.unwrap_or_default(), - authenticator: Authenticator::AppFlowyCloud, - encryption_type, + auth_type: AuthType::AppFlowyCloud, uid: profile.uid, updated_at: profile.updated_at, - ai_model: "".to_string(), }) } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs index 4075a5b908..300738c833 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs @@ -1,22 +1,24 @@ -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use flowy_error::{FlowyError, FlowyResult}; -use std::sync::Arc; +use std::sync::Weak; use tracing::warn; +use uuid::Uuid; /// Validates the workspace_id provided in the request. /// It checks that the workspace_id from the request matches the current user's active workspace_id. /// This ensures that the operation is being performed in the correct workspace context, enhancing security. pub fn check_request_workspace_id_is_match( - expected_workspace_id: &str, - user: &Arc, + expected_workspace_id: &Uuid, + user: &Weak, action: impl AsRef, ) -> FlowyResult<()> { + let user = user.upgrade().ok_or_else(FlowyError::user_not_login)?; let actual_workspace_id = user.workspace_id()?; - if expected_workspace_id != actual_workspace_id { + if expected_workspace_id != &actual_workspace_id { warn!( "{}, expect workspace_id: {}, actual workspace_id: {}", action.as_ref(), - expected_workspace_id, + expected_workspace_id.to_string(), actual_workspace_id ); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index ee908ac9cc..66abb32031 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -1,13 +1,11 @@ -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::{AIUserServiceImpl, LoggedUser}; use anyhow::Error; use arc_swap::ArcSwap; use client_api::collab_sync::ServerCollabMessage; -use client_api::entity::ai_dto::AIModel; use client_api::entity::UserMessage; use client_api::notify::{TokenState, TokenStateReceiver}; use client_api::ws::{ @@ -26,6 +24,11 @@ use flowy_storage_pub::cloud::StorageCloudService; use flowy_user_pub::cloud::{UserCloudService, UserUpdate}; use flowy_user_pub::entities::UserTokenState; +use crate::af_cloud::impls::{ + AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, AFCloudFileStorageServiceImpl, + AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, CloudChatServiceImpl, +}; +use flowy_ai::offline::offline_message_sync::AutoSyncChatService; use rand::Rng; use semver::Version; use tokio::select; @@ -36,11 +39,6 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use uuid::Uuid; -use crate::af_cloud::impls::{ - AFCloudChatCloudServiceImpl, AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, - AFCloudFileStorageServiceImpl, AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, -}; - use crate::AppFlowyServer; use super::impls::AFCloudSearchCloudServiceImpl; @@ -55,7 +53,7 @@ pub struct AppFlowyCloudServer { network_reachable: Arc, pub device_id: String, ws_client: Arc, - user: Arc, + logged_user: Weak, } impl AppFlowyCloudServer { @@ -64,7 +62,7 @@ impl AppFlowyCloudServer { enable_sync: bool, mut device_id: String, client_version: Version, - user: Arc, + logged_user: Weak, ) -> Self { // The device id can't be empty, so we generate a new one if it is. if device_id.is_empty() { @@ -93,8 +91,8 @@ impl AppFlowyCloudServer { ); let ws_client = Arc::new(ws_client); let api_client = Arc::new(api_client); - spawn_ws_conn(token_state_rx, &ws_client, &api_client, &enable_sync); + Self { config, client: api_client, @@ -102,16 +100,17 @@ impl AppFlowyCloudServer { network_reachable, device_id, ws_client, - user, + logged_user, } } - fn get_client(&self) -> Option> { - if self.enable_sync.load(Ordering::SeqCst) { + fn get_server_impl(&self) -> AFServerImpl { + let client = if self.enable_sync.load(Ordering::SeqCst) { Some(self.client.clone()) } else { None - } + }; + AFServerImpl { client } } } @@ -124,7 +123,7 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn set_ai_model(&self, ai_model: &str) -> Result<(), Error> { - self.client.set_ai_model(AIModel::from_str(ai_model)?); + self.client.set_ai_model(ai_model.to_string()); Ok(()) } @@ -167,9 +166,6 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn user_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; let mut user_change = self.ws_client.subscribe_user_changed(); let (tx, rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { @@ -187,57 +183,47 @@ impl AppFlowyServer for AppFlowyCloudServer { }); Arc::new(AFCloudUserAuthServiceImpl::new( - server, + self.get_server_impl(), rx, - self.user.clone(), + self.logged_user.clone(), )) } fn folder_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudFolderCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } fn database_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDatabaseCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } fn database_ai_service(&self) -> Option> { - let server = AFServerImpl { - client: self.get_client(), - }; Some(Arc::new(AFCloudDatabaseCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), })) } fn document_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDocumentCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } fn chat_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; - Arc::new(AFCloudChatCloudServiceImpl { inner: server }) + Arc::new(AutoSyncChatService::new( + Arc::new(CloudChatServiceImpl { + inner: self.get_server_impl(), + }), + Arc::new(AIUserServiceImpl(self.logged_user.clone())), + )) } fn subscribe_ws_state(&self) -> Option { @@ -267,21 +253,16 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn file_storage(&self) -> Option> { - let client = AFServerImpl { - client: self.get_client(), - }; Some(Arc::new(AFCloudFileStorageServiceImpl::new( - client, + self.get_server_impl(), self.config.maximum_upload_file_size_in_bytes, ))) } fn search_service(&self) -> Option> { - let server = AFServerImpl { - client: self.get_client(), - }; - - Some(Arc::new(AFCloudSearchCloudServiceImpl { inner: server })) + Some(Arc::new(AFCloudSearchCloudServiceImpl { + inner: self.get_server_impl(), + })) } } diff --git a/frontend/rust-lib/flowy-server/src/default_impl.rs b/frontend/rust-lib/flowy-server/src/default_impl.rs deleted file mode 100644 index 792db6e23a..0000000000 --- a/frontend/rust-lib/flowy-server/src/default_impl.rs +++ /dev/null @@ -1,147 +0,0 @@ -use client_api::entity::ai_dto::{LocalAIConfig, RepeatedRelatedQuestion}; -use flowy_ai_pub::cloud::{ - ChatCloudService, ChatMessage, ChatMessageMetadata, ChatMessageType, ChatSettings, - CompleteTextParams, MessageCursor, RepeatedChatMessage, ResponseFormat, StreamAnswer, - StreamComplete, SubscriptionPlan, UpdateChatParams, -}; -use flowy_error::FlowyError; -use lib_infra::async_trait::async_trait; -use serde_json::Value; -use std::collections::HashMap; -use std::path::Path; - -pub(crate) struct DefaultChatCloudServiceImpl; - -#[async_trait] -impl ChatCloudService for DefaultChatCloudServiceImpl { - async fn create_chat( - &self, - _uid: &i64, - _workspace_id: &str, - _chat_id: &str, - _rag_ids: Vec, - ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn create_question( - &self, - _workspace_id: &str, - _chat_id: &str, - _message: &str, - _message_type: ChatMessageType, - _metadata: &[ChatMessageMetadata], - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn create_answer( - &self, - _workspace_id: &str, - _chat_id: &str, - _message: &str, - _question_id: i64, - _metadata: Option, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn stream_answer( - &self, - _workspace_id: &str, - _chat_id: &str, - _message_id: i64, - _format: ResponseFormat, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn get_chat_messages( - &self, - _workspace_id: &str, - _chat_id: &str, - _offset: MessageCursor, - _limit: u64, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn get_question_from_answer_id( - &self, - _workspace_id: &str, - _chat_id: &str, - _answer_id: i64, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn get_related_message( - &self, - _workspace_id: &str, - _chat_id: &str, - _message_id: i64, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn get_answer( - &self, - _workspace_id: &str, - _chat_id: &str, - _question_message_id: i64, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn stream_complete( - &self, - _workspace_id: &str, - _params: CompleteTextParams, - ) -> Result { - Err(FlowyError::not_support().with_context("complete text is not supported in local server.")) - } - - async fn index_file( - &self, - _workspace_id: &str, - _file_path: &Path, - _chat_id: &str, - _metadata: Option>, - ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("indexing file is not supported in local server.")) - } - - async fn get_local_ai_config(&self, _workspace_id: &str) -> Result { - Err( - FlowyError::not_support() - .with_context("Get local ai config is not supported in local server."), - ) - } - - async fn get_workspace_plan( - &self, - _workspace_id: &str, - ) -> Result, FlowyError> { - Err( - FlowyError::not_support() - .with_context("Get local ai config is not supported in local server."), - ) - } - - async fn get_chat_settings( - &self, - _workspace_id: &str, - _chat_id: &str, - ) -> Result { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } - - async fn update_chat_settings( - &self, - _workspace_id: &str, - _chat_id: &str, - _params: UpdateChatParams, - ) -> Result<(), FlowyError> { - Err(FlowyError::not_support().with_context("Chat is not supported in local server.")) - } -} diff --git a/frontend/rust-lib/flowy-server/src/lib.rs b/frontend/rust-lib/flowy-server/src/lib.rs index 33f4b0c0d8..034991a984 100644 --- a/frontend/rust-lib/flowy-server/src/lib.rs +++ b/frontend/rust-lib/flowy-server/src/lib.rs @@ -5,5 +5,4 @@ pub mod local_server; mod response; mod server; -mod default_impl; pub mod util; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs new file mode 100644 index 0000000000..845b6dec1c --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -0,0 +1,355 @@ +use crate::af_cloud::define::LoggedUser; +use chrono::{TimeZone, Utc}; +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::CompletionStream; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai::local_ai::stream_util::QuestionStream; +use flowy_ai_pub::cloud::chat_dto::{ChatAuthor, ChatAuthorType}; +use flowy_ai_pub::cloud::{ + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, + ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, DEFAULT_AI_MODEL_NAME, +}; +use flowy_ai_pub::persistence::{ + deserialize_chat_metadata, deserialize_rag_ids, read_chat, + select_answer_where_match_reply_message_id, select_chat_messages, select_message_content, + serialize_chat_metadata, serialize_rag_ids, update_chat, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, ChatTableChangeset, +}; +use flowy_error::{FlowyError, FlowyResult}; +use futures_util::{stream, StreamExt, TryStreamExt}; +use lib_infra::async_trait::async_trait; +use lib_infra::util::timestamp; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use tracing::trace; +use uuid::Uuid; + +pub struct LocalChatServiceImpl { + pub logged_user: Arc, + pub local_ai: Arc, +} + +impl LocalChatServiceImpl { + fn get_message_content(&self, message_id: i64) -> FlowyResult { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let content = select_message_content(db, message_id)?.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) + })?; + Ok(content) + } + + async fn upsert_message(&self, chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let conn = self.logged_user.get_sqlite_db(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, true); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } +} + +#[async_trait] +impl ChatCloudService for LocalChatServiceImpl { + async fn create_chat( + &self, + _uid: &i64, + _workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + _name: &str, + metadata: Value, + ) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = ChatTable::new(chat_id.to_string(), metadata, rag_ids, true); + upsert_chat(db, &row)?; + Ok(()) + } + + async fn create_question( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = match message_type { + ChatMessageType::System => ChatMessage::new_system(timestamp(), message.to_string()), + ChatMessageType::User => ChatMessage::new_human(timestamp(), message.to_string(), None), + }; + + self.upsert_message(chat_id, message.clone()).await?; + Ok(message) + } + + async fn create_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let mut message = ChatMessage::new_ai(timestamp(), message.to_string(), Some(question_id)); + if let Some(metadata) = metadata { + message.metadata = metadata; + } + self.upsert_message(chat_id, message.clone()).await?; + Ok(message) + } + + async fn stream_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + format: ResponseFormat, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + let content = self.get_message_content(message_id)?; + match self + .local_ai + .stream_question( + &chat_id.to_string(), + &content, + Some(json!(format)), + json!({}), + ) + .await + { + Ok(stream) => Ok(QuestionStream::new(stream).boxed()), + Err(err) => Ok( + stream::once(async { Err(FlowyError::local_ai_unavailable().with_context(err)) }).boxed(), + ), + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } + + async fn get_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + + match select_answer_where_match_reply_message_id(db, &chat_id.to_string(), question_id)? { + None => Err(FlowyError::record_not_found()), + Some(message) => Ok(chat_message_from_row(message)), + } + } + + async fn get_chat_messages( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let result = select_chat_messages(db, &chat_id, limit, offset)?; + + let messages = result + .messages + .into_iter() + .map(chat_message_from_row) + .collect(); + + Ok(RepeatedChatMessage { + messages, + has_more: result.has_more, + total: result.total_count, + }) + } + + async fn get_question_from_answer_id( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = select_answer_where_match_reply_message_id(db, &chat_id, answer_message_id)? + .map(chat_message_from_row) + .ok_or_else(FlowyError::record_not_found)?; + Ok(row) + } + + async fn get_related_message( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], + }) + } + } + + async fn stream_complete( + &self, + _workspace_id: &Uuid, + params: CompleteTextParams, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), + ) + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), + ), + Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } + + async fn embed_file( + &self, + _workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + if self.local_ai.is_running() { + self + .local_ai + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + Ok(()) + } else { + Err(FlowyError::local_ai_not_ready()) + } + } + + async fn get_chat_settings( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = read_chat(db, &chat_id)?; + let rag_ids = deserialize_rag_ids(&row.rag_ids); + let metadata = deserialize_chat_metadata::(&row.metadata); + let setting = ChatSettings { + name: row.name, + rag_ids, + metadata, + }; + + Ok(setting) + } + + async fn update_chat_settings( + &self, + _workspace_id: &Uuid, + id: &Uuid, + s: UpdateChatParams, + ) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let mut db = self.logged_user.get_sqlite_db(uid)?; + let changeset = ChatTableChangeset { + chat_id: id.to_string(), + name: s.name, + metadata: s.metadata.map(|s| serialize_chat_metadata(&s)), + rag_ids: s.rag_ids.map(|s| serialize_rag_ids(&s)), + is_sync: None, + }; + + update_chat(&mut db, changeset)?; + Ok(()) + } + + async fn get_available_models(&self, _workspace_id: &Uuid) -> Result { + Ok(ModelList { models: vec![] }) + } + + async fn get_workspace_default_model(&self, _workspace_id: &Uuid) -> Result { + Ok(DEFAULT_AI_MODEL_NAME.to_string()) + } +} + +fn chat_message_from_row(row: ChatMessageTable) -> ChatMessage { + let created_at = Utc + .timestamp_opt(row.created_at, 0) + .single() + .unwrap_or_else(Utc::now); + + let author_id = row.author_id.parse::().unwrap_or_default(); + let author_type = match row.author_type { + 1 => ChatAuthorType::Human, + 2 => ChatAuthorType::System, + 3 => ChatAuthorType::AI, + _ => ChatAuthorType::Unknown, + }; + + let metadata = row + .metadata + .map(|s| deserialize_chat_metadata::(&s)) + .unwrap_or_else(|| json!({})); + + ChatMessage { + author: ChatAuthor { + author_id, + author_type, + meta: None, + }, + message_id: row.message_id, + content: row.content, + created_at, + metadata, + reply_message_id: row.reply_message_id, + } +} diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index d22088a2c4..ad1184a09a 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -1,63 +1,64 @@ -use collab::entity::EncodedCollab; -use collab_database::database::default_database_data; -use collab_database::workspace_database::default_workspace_database_data; -use collab_document::document_data::default_document_collab_data; -use collab_entity::CollabType; -use collab_user::core::default_user_awareness_data; -use flowy_database_pub::cloud::{DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid}; -use flowy_error::FlowyError; -use lib_infra::async_trait::async_trait; +#![allow(unused_variables)] -pub(crate) struct LocalServerDatabaseCloudServiceImpl(); +use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use flowy_database_pub::cloud::{DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid}; +use flowy_error::{ErrorCode, FlowyError}; +use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; + +pub(crate) struct LocalServerDatabaseCloudServiceImpl { + pub logged_user: Arc, +} #[async_trait] impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - _workspace_id: &str, + _workspace_id: &Uuid, // underscore to silence “unused” warning ) -> Result, FlowyError> { - match collab_type { - CollabType::Document => { - let encode_collab = default_document_collab_data(object_id)?; - Ok(Some(encode_collab)) - }, - CollabType::Database => default_database_data(object_id) - .await - .map(Some) - .map_err(Into::into), - CollabType::WorkspaceDatabase => Ok(Some(default_workspace_database_data(object_id))), - CollabType::Folder => Ok(None), - CollabType::DatabaseRow => Ok(None), - CollabType::UserAwareness => Ok(Some(default_user_awareness_data(object_id))), - CollabType::Unknown => Ok(None), - } + let uid = self.logged_user.user_id()?; + let object_id = object_id.to_string(); + default_encode_collab_for_collab_type(uid, &object_id, collab_type) + .await + .map(Some) + .or_else(|err| { + if matches!(err.code, ErrorCode::NotSupportYet) { + Ok(None) + } else { + Err(err) + } + }) } async fn create_database_encode_collab( &self, - _object_id: &str, - _collab_type: CollabType, - _workspace_id: &str, - _encoded_collab: EncodedCollab, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } async fn batch_get_database_encode_collab( &self, - _object_ids: Vec, - _object_ty: CollabType, - _workspace_id: &str, + object_ids: Vec, + object_ty: CollabType, + workspace_id: &Uuid, ) -> Result { Ok(EncodeCollabByOid::default()) } async fn get_database_collab_object_snapshots( &self, - _object_id: &str, - _limit: usize, + object_id: &Uuid, + limit: usize, ) -> Result, FlowyError> { Ok(vec![]) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs index 152dcb78d8..c553026274 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs @@ -1,7 +1,9 @@ +#![allow(unused_variables)] use collab::entity::EncodedCollab; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError}; use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub(crate) struct LocalServerDocumentCloudServiceImpl(); @@ -9,8 +11,8 @@ pub(crate) struct LocalServerDocumentCloudServiceImpl(); impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { let document_id = document_id.to_string(); @@ -22,26 +24,26 @@ impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, + document_id: &Uuid, + limit: usize, + workspace_id: &str, ) -> Result, FlowyError> { Ok(vec![]) } async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, + document_id: &Uuid, + workspace_id: &Uuid, ) -> Result, FlowyError> { Ok(None) } async fn create_document_collab( &self, - _workspace_id: &str, - _document_id: &str, - _encoded_collab: EncodedCollab, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, ) -> Result<(), FlowyError> { Ok(()) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index 52d9a9e98c..483ca3d100 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,10 +1,14 @@ -use std::sync::Arc; +#![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::PublishInfo; +use collab::core::origin::CollabOrigin; +use collab::preclude::Collab; use collab_entity::CollabType; - -use crate::local_server::LocalServerDB; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ gen_workspace_id, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, @@ -12,10 +16,12 @@ use flowy_folder_pub::cloud::{ }; use flowy_folder_pub::entities::PublishPayload; use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; pub(crate) struct LocalServerFolderCloudServiceImpl { #[allow(dead_code)] - pub db: Arc, + pub logged_user: Arc, } #[async_trait] @@ -29,7 +35,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { )) } - async fn open_workspace(&self, _workspace_id: &str) -> Result<(), FlowyError> { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Ok(()) } @@ -39,8 +45,8 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn get_folder_data( &self, - _workspace_id: &str, - _uid: &i64, + workspace_id: &Uuid, + uid: &i64, ) -> Result, FlowyError> { Ok(None) } @@ -55,18 +61,38 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn get_folder_doc_state( &self, - _workspace_id: &str, - _uid: i64, - _collab_type: CollabType, - _object_id: &str, + workspace_id: &Uuid, + uid: i64, + collab_type: CollabType, + object_id: &Uuid, ) -> Result, FlowyError> { - Err(FlowyError::local_version_not_support()) + let object_id = object_id.to_string(); + let workspace_id = workspace_id.to_string(); + let collab_db = self.logged_user.get_collab_db(uid)?.upgrade().unwrap(); + let read_txn = collab_db.read_txn(); + let is_exist = read_txn.is_exist(uid, &workspace_id.to_string(), &object_id.to_string()); + if is_exist { + // load doc + let collab = Collab::new_with_origin(CollabOrigin::Empty, &object_id, vec![], false); + read_txn.load_doc(uid, &workspace_id, &object_id, collab.doc())?; + let data = collab.encode_collab_v1(|c| { + collab_type + .validate_require_data(c) + .map_err(|err| FlowyError::invalid_data().with_context(err))?; + Ok::<_, FlowyError>(()) + })?; + Ok(data.doc_state.to_vec()) + } else { + let data = default_encode_collab_for_collab_type(uid, &object_id, collab_type).await?; + drop(read_txn); + Ok(data.doc_state.to_vec()) + } } async fn batch_create_folder_collab_objects( &self, - _workspace_id: &str, - _objects: Vec, + workspace_id: &Uuid, + objects: Vec, ) -> Result<(), FlowyError> { Ok(()) } @@ -77,68 +103,68 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn publish_view( &self, - _workspace_id: &str, - _payload: Vec, + workspace_id: &Uuid, + payload: Vec, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } async fn unpublish_views( &self, - _workspace_id: &str, - _view_ids: Vec, + workspace_id: &Uuid, + view_ids: Vec, ) -> Result<(), FlowyError> { - Err(FlowyError::local_version_not_support()) + Ok(()) } - async fn get_publish_info(&self, _view_id: &str) -> Result { + async fn get_publish_info(&self, view_id: &Uuid) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_publish_namespace( &self, - _workspace_id: &str, - _new_namespace: String, + workspace_id: &Uuid, + new_namespace: String, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } - async fn get_publish_namespace(&self, _workspace_id: &str) -> Result { + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_publish_name( &self, - _workspace_id: &str, - _view_id: String, - _new_name: String, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } async fn list_published_views( &self, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result, FlowyError> { Err(FlowyError::local_version_not_support()) } async fn get_default_published_view_info( &self, - _workspace_id: &str, + workspace_id: &Uuid, ) -> Result { Err(FlowyError::local_version_not_support()) } async fn set_default_published_view( &self, - _workspace_id: &str, - _view_id: uuid::Uuid, + workspace_id: &Uuid, + view_id: uuid::Uuid, ) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } - async fn remove_default_published_view(&self, _workspace_id: &str) -> Result<(), FlowyError> { + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Err(FlowyError::local_version_not_support()) } @@ -148,8 +174,8 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { async fn full_sync_collab_object( &self, - _workspace_id: &str, - _params: FullSyncCollabParams, + workspace_id: &Uuid, + params: FullSyncCollabParams, ) -> Result<(), FlowyError> { Ok(()) } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs index 0280cfbefb..f63265e734 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs @@ -1,8 +1,10 @@ +pub(crate) use chat::*; pub(crate) use database::*; pub(crate) use document::*; pub(crate) use folder::*; pub(crate) use user::*; +mod chat; mod database; mod document; mod folder; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index c800cc7ced..0023defb41 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,40 +1,47 @@ +#![allow(unused_variables)] + +use crate::af_cloud::define::LoggedUser; +use crate::local_server::uid::UserIDGenerator; +use client_api::entity::GotrueTokenResponse; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::CollabObject; use collab_user::core::UserAwareness; +use flowy_ai_pub::cloud::billing_dto::WorkspaceUsageAndLimit; +use flowy_ai_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; +use flowy_error::FlowyError; +use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; +use flowy_user_pub::entities::*; +use flowy_user_pub::sql::{ + select_all_user_workspace, select_user_profile, select_user_workspace, select_workspace_member, + select_workspace_setting, update_user_profile, update_workspace_setting, upsert_workspace_member, + upsert_workspace_setting, UserTableChangeset, WorkspaceMemberTable, WorkspaceSettingsChangeset, + WorkspaceSettingsTable, +}; +use flowy_user_pub::DEFAULT_USER_NAME; use lazy_static::lazy_static; +use lib_infra::async_trait::async_trait; +use lib_infra::box_any::BoxAny; +use lib_infra::util::timestamp; use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; -use flowy_error::FlowyError; -use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; -use flowy_user_pub::entities::*; -use flowy_user_pub::DEFAULT_USER_NAME; -use lib_infra::async_trait::async_trait; -use lib_infra::box_any::BoxAny; -use lib_infra::util::timestamp; - -use crate::local_server::uid::UserIDGenerator; -use crate::local_server::LocalServerDB; - lazy_static! { - //FIXME: seriously, userID generation should work using lock-free algorithm static ref ID_GEN: Mutex = Mutex::new(UserIDGenerator::new(1)); } -pub(crate) struct LocalServerUserAuthServiceImpl { - #[allow(dead_code)] - pub db: Arc, +pub(crate) struct LocalServerUserServiceImpl { + pub logged_user: Arc, } #[async_trait] -impl UserCloudService for LocalServerUserAuthServiceImpl { +impl UserCloudService for LocalServerUserServiceImpl { async fn sign_up(&self, params: BoxAny) -> Result { let params = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - let workspace_id = uuid::Uuid::new_v4().to_string(); - let user_workspace = UserWorkspace::new_local(&workspace_id, uid); + let workspace_id = Uuid::new_v4().to_string(); + let user_workspace = UserWorkspace::new_local(workspace_id, ""); let user_name = if params.name.is_empty() { DEFAULT_USER_NAME() } else { @@ -47,7 +54,8 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { latest_workspace: user_workspace.clone(), user_workspaces: vec![user_workspace], is_new_user: true, - email: Some(params.email), + // Anon user doesn't have email + email: None, token: None, encryption_type: EncryptionType::NoEncryption, updated_at: timestamp(), @@ -56,13 +64,11 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { } async fn sign_in(&self, params: BoxAny) -> Result { - let db = self.db.clone(); let params: SignInParams = params.unbox_or_error::()?; let uid = ID_GEN.lock().await.next_id(); - let user_workspace = db - .get_user_workspace(uid)? - .unwrap_or_else(make_user_workspace); + let workspace_id = Uuid::new_v4(); + let user_workspace = UserWorkspace::new_local(workspace_id.to_string(), "My Workspace"); Ok(AuthResponse { user_id: uid, user_uuid: Uuid::new_v4(), @@ -97,7 +103,7 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { &self, _email: &str, _password: &str, - ) -> Result { + ) -> Result { Err(FlowyError::local_version_not_support().with_context("Not support")) } @@ -109,58 +115,84 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { Err(FlowyError::local_version_not_support().with_context("Not support")) } + async fn sign_in_with_passcode( + &self, + _email: &str, + _passcode: &str, + ) -> Result { + Err(FlowyError::local_version_not_support().with_context("Not support")) + } + async fn generate_oauth_url_with_provider(&self, _provider: &str) -> Result { Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) } - async fn update_user( + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let changeset = UserTableChangeset::new(params); + update_user_profile(&mut conn, changeset)?; + Ok(()) + } + + async fn get_user_profile(&self, uid: i64) -> Result { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, &mut conn)?; + Ok(profile) + } + + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + let workspace = select_user_workspace(&workspace_id.to_string(), &mut conn)?; + Ok(UserWorkspace::from(workspace)) + } + + async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { + let conn = self.logged_user.get_sqlite_db(uid)?; + let workspaces = select_all_user_workspace(uid, conn)?; + Ok(workspaces) + } + + async fn create_workspace(&self, workspace_name: &str) -> Result { + let workspace_id = Uuid::new_v4(); + Ok(UserWorkspace::new_local( + workspace_id.to_string(), + workspace_name, + )) + } + + async fn patch_workspace( &self, - _credential: UserCredentials, - _params: UpdateUserProfileParams, + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError> { Ok(()) } - async fn get_user_profile(&self, credential: UserCredentials) -> Result { - match credential.uid { - None => Err(FlowyError::record_not_found()), - Some(uid) => { - self.db.get_user_profile(uid).map(|mut profile| { - // We don't want to expose the email in the local server - profile.email = "".to_string(); - profile - }) - }, - } - } - - async fn open_workspace(&self, _workspace_id: &str) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support open workspace"), - ) - } - - async fn get_all_workspace(&self, _uid: i64) -> Result, FlowyError> { - Ok(vec![]) + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + Ok(()) } async fn get_user_awareness_doc_state( &self, - _uid: i64, - _workspace_id: &str, - object_id: &str, + uid: i64, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError> { - let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let collab = Collab::new_with_origin( + CollabOrigin::Empty, + object_id.to_string().as_str(), + vec![], + false, + ); let awareness = UserAwareness::create(collab, None)?; let encode_collab = awareness.encode_collab_v1(|_collab| Ok::<_, FlowyError>(()))?; Ok(encode_collab.doc_state.to_vec()) } - async fn reset_workspace(&self, _collab_object: CollabObject) -> Result<(), FlowyError> { - Ok(()) - } - async fn create_collab_object( &self, _collab_object: &CollabObject, @@ -171,50 +203,122 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { async fn batch_create_collab_object( &self, - _workspace_id: &str, - _objects: Vec, + workspace_id: &Uuid, + objects: Vec, ) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support batch create collab object"), - ) + Ok(()) } - async fn create_workspace(&self, _workspace_name: &str) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } - - async fn delete_workspace(&self, _workspace_id: &str) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } - - async fn patch_workspace( + async fn get_workspace_member( &self, - _workspace_id: &str, - _new_workspace_name: Option<&str>, - _new_workspace_icon: Option<&str>, - ) -> Result<(), FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - } -} + workspace_id: &Uuid, + uid: i64, + ) -> Result { + // For local server, only current user is the member + let conn = self.logged_user.get_sqlite_db(uid)?; + let result = select_workspace_member(conn, &workspace_id.to_string(), uid); -fn make_user_workspace() -> UserWorkspace { - UserWorkspace { - id: uuid::Uuid::new_v4().to_string(), - name: "My Workspace".to_string(), - created_at: Default::default(), - workspace_database_id: uuid::Uuid::new_v4().to_string(), - icon: "".to_string(), - member_count: 1, - role: None, + match result { + Ok(row) => Ok(WorkspaceMember::from(row)), + Err(err) => { + if err.is_record_not_found() { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, &mut conn)?; + let row = WorkspaceMemberTable { + email: profile.email.to_string(), + role: 0, + name: profile.name.to_string(), + avatar_url: Some(profile.icon_url), + uid, + workspace_id: workspace_id.to_string(), + updated_at: Default::default(), + }; + + let member = WorkspaceMember::from(row.clone()); + upsert_workspace_member(&mut conn, row)?; + Ok(member) + } else { + Err(err) + } + }, + } + } + + async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> Result { + Ok(WorkspaceUsageAndLimit { + member_count: 1, + member_count_limit: 1, + storage_bytes: i64::MAX, + storage_bytes_limit: i64::MAX, + storage_bytes_unlimited: true, + single_upload_limit: i64::MAX, + single_upload_unlimited: true, + ai_responses_count: i64::MAX, + ai_responses_count_limit: i64::MAX, + ai_image_responses_count: i64::MAX, + ai_image_responses_count_limit: 0, + local_ai: true, + ai_responses_unlimited: true, + }) + } + + async fn get_workspace_setting( + &self, + workspace_id: &Uuid, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + // By default, workspace setting is existed in local server + let result = select_workspace_setting(&mut conn, &workspace_id.to_string()); + match result { + Ok(row) => Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, + }), + Err(err) => { + if err.is_record_not_found() { + let row = WorkspaceSettingsTable { + id: workspace_id.to_string(), + disable_search_indexing: false, + ai_model: "".to_string(), + }; + let setting = AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model.clone(), + }; + upsert_workspace_setting(&mut conn, row)?; + Ok(setting) + } else { + Err(err) + } + }, + } + } + + async fn update_workspace_setting( + &self, + workspace_id: &Uuid, + workspace_settings: AFWorkspaceSettingsChange, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: workspace_settings.disable_search_indexing, + ai_model: workspace_settings.ai_model, + }; + + update_workspace_setting(&mut conn, changeset)?; + let row = select_workspace_setting(&mut conn, &workspace_id.to_string())?; + + Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, + }) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/mod.rs index 6e67356fd9..2b9fe07250 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/mod.rs @@ -3,3 +3,4 @@ pub use server::*; pub mod impls; mod server; pub(crate) mod uid; +mod util; diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index cb8b545c53..9ce19f5df6 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,39 +1,32 @@ use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; -use tokio::sync::mpsc; - -use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; -use flowy_document_pub::cloud::DocumentCloudService; -use flowy_error::FlowyError; -use flowy_folder_pub::cloud::FolderCloudService; -use flowy_storage_pub::cloud::StorageCloudService; -// use flowy_user::services::database::{ -// get_user_profile, get_user_workspace, open_collab_db, open_user_db, -// }; -use flowy_user_pub::cloud::UserCloudService; -use flowy_user_pub::entities::*; - +use crate::af_cloud::define::LoggedUser; use crate::local_server::impls::{ - LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, - LocalServerFolderCloudServiceImpl, LocalServerUserAuthServiceImpl, + LocalChatServiceImpl, LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, + LocalServerFolderCloudServiceImpl, LocalServerUserServiceImpl, }; use crate::AppFlowyServer; - -pub trait LocalServerDB: Send + Sync + 'static { - fn get_user_profile(&self, uid: i64) -> Result; - fn get_user_workspace(&self, uid: i64) -> Result, FlowyError>; -} +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai_pub::cloud::ChatCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; +use flowy_document_pub::cloud::DocumentCloudService; +use flowy_folder_pub::cloud::FolderCloudService; +use flowy_storage_pub::cloud::StorageCloudService; +use flowy_user_pub::cloud::UserCloudService; +use tokio::sync::mpsc; pub struct LocalServer { - local_db: Arc, + logged_user: Arc, + local_ai: Arc, stop_tx: Option>, } impl LocalServer { - pub fn new(local_db: Arc) -> Self { + pub fn new(logged_user: Arc, local_ai: Arc) -> Self { Self { - local_db, + logged_user, + local_ai, stop_tx: Default::default(), } } @@ -48,34 +41,43 @@ impl LocalServer { impl AppFlowyServer for LocalServer { fn user_service(&self) -> Arc { - Arc::new(LocalServerUserAuthServiceImpl { - db: self.local_db.clone(), + Arc::new(LocalServerUserServiceImpl { + logged_user: self.logged_user.clone(), }) } fn folder_service(&self) -> Arc { Arc::new(LocalServerFolderCloudServiceImpl { - db: self.local_db.clone(), + logged_user: self.logged_user.clone(), }) } fn database_service(&self) -> Arc { - Arc::new(LocalServerDatabaseCloudServiceImpl()) + Arc::new(LocalServerDatabaseCloudServiceImpl { + logged_user: self.logged_user.clone(), + }) + } + + fn database_ai_service(&self) -> Option> { + None } fn document_service(&self) -> Arc { Arc::new(LocalServerDocumentCloudServiceImpl()) } - fn file_storage(&self) -> Option> { - None + fn chat_service(&self) -> Arc { + Arc::new(LocalChatServiceImpl { + logged_user: self.logged_user.clone(), + local_ai: self.local_ai.clone(), + }) } fn search_service(&self) -> Option> { None } - fn database_ai_service(&self) -> Option> { + fn file_storage(&self) -> Option> { None } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/util.rs b/frontend/rust-lib/flowy-server/src/local_server/util.rs new file mode 100644 index 0000000000..378ccee6a2 --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/local_server/util.rs @@ -0,0 +1,47 @@ +use collab::core::origin::CollabOrigin; +use collab::entity::EncodedCollab; +use collab::preclude::Collab; +use collab_database::database::default_database_data; +use collab_database::workspace_database::default_workspace_database_data; +use collab_document::document_data::default_document_collab_data; +use collab_entity::CollabType; +use collab_user::core::default_user_awareness_data; +use flowy_error::{FlowyError, FlowyResult}; + +pub async fn default_encode_collab_for_collab_type( + _uid: i64, + object_id: &str, + collab_type: CollabType, +) -> FlowyResult { + match collab_type { + CollabType::Document => { + let encode_collab = default_document_collab_data(object_id)?; + Ok(encode_collab) + }, + CollabType::Database => default_database_data(object_id).await.map_err(Into::into), + CollabType::WorkspaceDatabase => Ok(default_workspace_database_data(object_id)), + CollabType::Folder => { + // let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + // let workspace = Workspace::new(object_id.to_string(), "".to_string(), uid); + // let folder_data = FolderData::new(workspace); + // let folder = Folder::create(uid, collab, None, folder_data); + // let data = folder.encode_collab_v1(|c| { + // collab_type + // .validate_require_data(c) + // .map_err(|err| FlowyError::invalid_data().with_context(err))?; + // Ok::<_, FlowyError>(()) + // })?; + // Ok(data) + Err(FlowyError::not_support().with_context("Can not create default folder")) + }, + CollabType::DatabaseRow => { + Err(FlowyError::not_support().with_context("Can not create default database row")) + }, + CollabType::UserAwareness => Ok(default_user_awareness_data(object_id)), + CollabType::Unknown => { + let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let data = collab.encode_collab_v1(|_| Ok::<_, FlowyError>(()))?; + Ok(data) + }, + } +} diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index ee07eefa5a..4c92fe28d2 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -12,7 +12,6 @@ use tokio_stream::wrappers::WatchStream; #[cfg(feature = "enable_supabase")] use {collab_entity::CollabObject, collab_plugins::cloud_storage::RemoteCollabStorage}; -use crate::default_impl::DefaultChatCloudServiceImpl; use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; @@ -103,9 +102,7 @@ pub trait AppFlowyServer: Send + Sync + 'static { /// An `Arc` wrapping the `DocumentCloudService` interface. fn document_service(&self) -> Arc; - fn chat_service(&self) -> Arc { - Arc::new(DefaultChatCloudServiceImpl) - } + fn chat_service(&self) -> Arc; /// Bridge for the Cloud AI Search features /// diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs index 65d02c704a..3712307af4 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -199,6 +199,16 @@ where }) } + fn sign_in_with_passcode( + &self, + _email: &str, + _passcode: &str, + ) -> FutureResult { + FutureResult::new(async { + Err(FlowyError::not_support().with_context("Can't sign in with passcode when using supabase")) + }) + } + fn generate_oauth_url_with_provider(&self, _provider: &str) -> FutureResult { FutureResult::new(async { Err(FlowyError::internal().with_context("Can't generate oauth url when using supabase")) diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index ecf34ec31d..7e38f423cc 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -1,16 +1,18 @@ use client_api::ClientConfiguration; +use collab_plugins::CollabKVDB; +use flowy_error::{FlowyError, FlowyResult}; use semver::Version; use std::collections::HashMap; -use std::sync::Arc; - -use flowy_error::FlowyResult; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; use uuid::Uuid; -use flowy_server::af_cloud::define::ServerUser; +use crate::setup_log; +use flowy_server::af_cloud::define::LoggedUser; use flowy_server::af_cloud::AppFlowyCloudServer; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; - -use crate::setup_log; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; /// To run the test, create a .env.ci file in the 'flowy-server' directory and set the following environment variables: /// @@ -28,18 +30,42 @@ pub fn get_af_cloud_config() -> Option { pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc { let fake_device_id = uuid::Uuid::new_v4().to_string(); + let logged_user = Arc::new(FakeServerUserImpl) as Arc; Arc::new(AppFlowyCloudServer::new( config, true, fake_device_id, Version::new(0, 5, 8), - Arc::new(FakeServerUserImpl), + // do nothing, just for test + Arc::downgrade(&logged_user), )) } struct FakeServerUserImpl; -impl ServerUser for FakeServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + +#[async_trait] +impl LoggedUser for FakeServerUserImpl { + fn workspace_id(&self) -> FlowyResult { + todo!() + } + + fn user_id(&self) -> FlowyResult { + todo!() + } + + async fn is_local_mode(&self) -> FlowyResult { + Ok(true) + } + + fn get_sqlite_db(&self, _uid: i64) -> Result { + todo!() + } + + fn get_collab_db(&self, _uid: i64) -> Result, FlowyError> { + todo!() + } + + fn application_root_dir(&self) -> Result { todo!() } } diff --git a/frontend/rust-lib/flowy-sqlite/Cargo.toml b/frontend/rust-lib/flowy-sqlite/Cargo.toml index 0e85aebee5..345b05f903 100644 --- a/frontend/rust-lib/flowy-sqlite/Cargo.toml +++ b/frontend/rust-lib/flowy-sqlite/Cargo.toml @@ -7,7 +7,7 @@ edition = "2018" [dependencies] diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } +diesel_derives = { workspace = true, features = ["sqlite", "r2d2"] } diesel_migrations = { version = "2.1.0", features = ["sqlite"] } tracing.workspace = true serde.workspace = true diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql new file mode 100644 index 0000000000..8b07e6189d --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql @@ -0,0 +1,9 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table + ADD COLUMN local_enabled INTEGER; +ALTER TABLE chat_table + ADD COLUMN sync_to_cloud INTEGER; +ALTER TABLE chat_table + ADD COLUMN local_files TEXT; + +ALTER TABLE chat_table DROP COLUMN rag_ids; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql new file mode 100644 index 0000000000..0604601486 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE chat_table DROP COLUMN local_enabled; +ALTER TABLE chat_table DROP COLUMN local_files; +ALTER TABLE chat_table DROP COLUMN sync_to_cloud; +ALTER TABLE chat_table ADD COLUMN rag_ids TEXT; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql new file mode 100644 index 0000000000..65dec0f30a --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table DROP COLUMN is_sync; +ALTER TABLE chat_message_table DROP COLUMN is_sync; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql new file mode 100644 index 0000000000..ff8dce94bc --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +ALTER TABLE chat_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; +ALTER TABLE chat_message_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql new file mode 100644 index 0000000000..50602eb129 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE user_workspace_table +DROP COLUMN auth_type; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql new file mode 100644 index 0000000000..7d986e3e57 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql @@ -0,0 +1,24 @@ +-- Your SQL goes here +ALTER TABLE user_workspace_table + ADD COLUMN workspace_type INTEGER NOT NULL DEFAULT 1; + +-- 2. Back‑fill from user_table.auth_type +UPDATE user_workspace_table +SET workspace_type = (SELECT ut.auth_type + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)) +WHERE EXISTS (SELECT 1 + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)); + +ALTER TABLE user_table DROP COLUMN stability_ai_key; +ALTER TABLE user_table DROP COLUMN openai_key; +ALTER TABLE user_table DROP COLUMN workspace; +ALTER TABLE user_table DROP COLUMN encryption_type; +ALTER TABLE user_table DROP COLUMN ai_model; + +CREATE TABLE workspace_setting_table ( + id TEXT PRIMARY KEY NOT NULL , + disable_search_indexing BOOLEAN DEFAULT FALSE NOT NULL , + ai_model TEXT DEFAULT "" NOT NULL +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 4ff70bf3c6..f91d187b75 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -27,6 +27,7 @@ diesel::table! { author_id -> Text, reply_message_id -> Nullable, metadata -> Nullable, + is_sync -> Bool, } } @@ -35,10 +36,9 @@ diesel::table! { chat_id -> Text, created_at -> BigInt, name -> Text, - local_files -> Text, metadata -> Text, - local_enabled -> Bool, - sync_to_cloud -> Bool, + rag_ids -> Nullable, + is_sync -> Bool, } } @@ -89,16 +89,11 @@ diesel::table! { user_table (id) { id -> Text, name -> Text, - workspace -> Text, icon_url -> Text, - openai_key -> Text, token -> Text, email -> Text, auth_type -> Integer, - encryption_type -> Text, - stability_ai_key -> Text, updated_at -> BigInt, - ai_model -> Text, } } @@ -112,6 +107,7 @@ diesel::table! { icon -> Text, member_count -> BigInt, role -> Nullable, + workspace_type -> Integer, } } @@ -127,6 +123,14 @@ diesel::table! { } } +diesel::table! { + workspace_setting_table (id) { + id -> Text, + disable_search_indexing -> Bool, + ai_model -> Text, + } +} + diesel::allow_tables_to_appear_in_same_query!( af_collab_metadata, chat_local_setting_table, @@ -139,4 +143,5 @@ diesel::allow_tables_to_appear_in_same_query!( user_table, user_workspace_table, workspace_members_table, + workspace_setting_table, ); diff --git a/frontend/rust-lib/flowy-storage-pub/Cargo.toml b/frontend/rust-lib/flowy-storage-pub/Cargo.toml index ecab2212f8..d36c997432 100644 --- a/frontend/rust-lib/flowy-storage-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-storage-pub/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" [dependencies] lib-infra.workspace = true -serde_json.workspace = true serde.workspace = true async-trait.workspace = true mime = "0.3.17" @@ -17,4 +16,4 @@ mime_guess = "2.0.4" client-api-entity = { workspace = true } tokio = { workspace = true, features = ["sync", "io-util"] } anyhow = "1.0.86" -tracing.workspace = true +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage-pub/src/cloud.rs b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs index 6f12779899..5a72262ac9 100644 --- a/frontend/rust-lib/flowy-storage-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use bytes::Bytes; use flowy_error::{FlowyError, FlowyResult}; use mime::Mime; +use uuid::Uuid; #[async_trait] pub trait StorageCloudService: Send + Sync { @@ -47,17 +48,17 @@ pub trait StorageCloudService: Send + Sync { async fn get_object(&self, url: String) -> Result; async fn get_object_url_v1( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, ) -> FlowyResult; /// Return workspace_id, parent_dir, file_id - async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)>; + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)>; async fn create_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, file_id: &str, content_type: &str, @@ -66,7 +67,7 @@ pub trait StorageCloudService: Send + Sync { async fn upload_part( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -76,7 +77,7 @@ pub trait StorageCloudService: Send + Sync { async fn complete_upload( &self, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -85,7 +86,7 @@ pub trait StorageCloudService: Send + Sync { } pub struct ObjectIdentity { - pub workspace_id: String, + pub workspace_id: Uuid, pub file_id: String, pub ext: String, } @@ -97,7 +98,7 @@ pub struct ObjectValue { } pub struct StorageObject { - pub workspace_id: String, + pub workspace_id: Uuid, pub file_name: String, pub value: ObjectValueSupabase, } @@ -126,9 +127,9 @@ impl StorageObject { /// * `name`: The name of the storage object. /// * `file_path`: The file path to the storage object's data. /// - pub fn from_file(workspace_id: &str, file_name: &str, file_path: T) -> Self { + pub fn from_file(workspace_id: &Uuid, file_name: &str, file_path: T) -> Self { Self { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, file_name: file_name.to_string(), value: ObjectValueSupabase::File { file_path: file_path.to_string(), @@ -145,14 +146,14 @@ impl StorageObject { /// * `mime`: The MIME type of the storage object. /// pub fn from_bytes>( - workspace_id: &str, + workspace_id: &Uuid, file_name: &str, bytes: B, mime: String, ) -> Self { let bytes = bytes.into(); Self { - workspace_id: workspace_id.to_string(), + workspace_id: *workspace_id, file_name: file_name.to_string(), value: ObjectValueSupabase::Bytes { bytes, mime }, } diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index 405faed1ba..add7996439 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -17,8 +17,6 @@ tokio = { workspace = true, features = ["sync", "io-util"] } tracing.workspace = true flowy-sqlite.workspace = true mime_guess = "2.0.4" -fxhash = "0.2.1" -anyhow = "1.0.86" chrono = "0.4.33" flowy-notification = { workspace = true } flowy-derive.workspace = true @@ -26,8 +24,8 @@ protobuf = { workspace = true } dashmap.workspace = true strum_macros = "0.25.2" allo-isolate = { version = "^0.1", features = ["catch-unwind"] } -futures-util = "0.3.30" collab-importer = { workspace = true } +uuid.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["full"] } @@ -36,7 +34,6 @@ rand = { version = "0.8", features = ["std_rng"] } [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] [build-dependencies] flowy-codegen.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage/build.rs b/frontend/rust-lib/flowy-storage/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-storage/build.rs +++ b/frontend/rust-lib/flowy-storage/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs index 66ad44e0fd..0dd729b087 100644 --- a/frontend/rust-lib/flowy-storage/src/manager.rs +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -24,15 +24,17 @@ use lib_infra::box_any::BoxAny; use lib_infra::isolate_stream::{IsolateSink, SinkExt}; use lib_infra::util::timestamp; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::atomic::AtomicBool; use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio::sync::{broadcast, watch}; use tracing::{debug, error, info, instrument, trace}; +use uuid::Uuid; pub trait StorageUserService: Send + Sync + 'static { fn user_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn sqlite_connection(&self, uid: i64) -> Result; fn get_application_root_dir(&self) -> &str; } @@ -157,7 +159,8 @@ impl StorageManager { let uid = self.user_service.user_id().ok()?; let mut conn = self.user_service.sqlite_connection(uid).ok()?; - let is_finish = is_upload_completed(&mut conn, &workspace_id, &parent_dir, &file_id).ok()?; + let is_finish = + is_upload_completed(&mut conn, &workspace_id.to_string(), &parent_dir, &file_id).ok()?; if let Err(err) = self.global_notifier.send(FileProgress::new_progress( url.to_string(), @@ -178,6 +181,14 @@ impl StorageManager { } } + pub async fn initialize_after_open_workspace(&self, workspace_id: &Uuid) { + self.enable_storage_write_access(); + + if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await { + error!("prepare {} upload task failed: {}", workspace_id, err); + } + } + pub fn update_network_reachable(&self, reachable: bool) { if reachable { self.uploader.resume(); @@ -229,7 +240,7 @@ async fn prepare_upload_task( if let Ok(uid) = user_service.user_id() { let workspace_id = user_service.workspace_id()?; let conn = user_service.sqlite_connection(uid)?; - let upload_files = batch_select_upload_file(conn, &workspace_id, 100, false)?; + let upload_files = batch_select_upload_file(conn, &workspace_id.to_string(), 100, false)?; let tasks = upload_files .into_iter() .map(|upload_file| UploadTask::BackgroundTask { @@ -269,7 +280,7 @@ impl StorageService for StorageServiceImpl { self .task_queue - .remove_task(&workspace_id, &parent_dir, &file_id) + .remove_task(&workspace_id.to_string(), &parent_dir, &file_id) .await; trace!("[File] delete progress notifier: {}", file_id); @@ -278,7 +289,7 @@ impl StorageService for StorageServiceImpl { self .user_service .sqlite_connection(self.user_service.user_id()?)?, - &workspace_id, + &workspace_id.to_string(), &parent_dir, &file_id, ) { @@ -384,9 +395,10 @@ impl StorageService for StorageServiceImpl { let conn = self .user_service .sqlite_connection(self.user_service.user_id()?)?; + let workspace_id = Uuid::from_str(&record.workspace_id)?; let url = self .cloud_service - .get_object_url_v1(&record.workspace_id, &record.parent_dir, &record.file_id) + .get_object_url_v1(&workspace_id, &record.parent_dir, &record.file_id) .await?; let file_id = record.file_id.clone(); match insert_upload_file(conn, &record) { @@ -478,7 +490,8 @@ impl StorageService for StorageServiceImpl { .user_service .sqlite_connection(self.user_service.user_id()?)?; let workspace_id = self.user_service.workspace_id()?; - is_upload_completed(&mut conn, &workspace_id, parent_idr, file_id).unwrap_or(false) + is_upload_completed(&mut conn, &workspace_id.to_string(), parent_idr, file_id) + .unwrap_or(false) }; if is_completed { @@ -590,9 +603,10 @@ async fn start_upload( upload_file.file_id ); + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; let create_upload_resp_result = cloud_service .create_upload( - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.file_id, &upload_file.content_type, @@ -601,11 +615,7 @@ async fn start_upload( .await; let file_url = cloud_service - .get_object_url_v1( - &upload_file.workspace_id, - &upload_file.parent_dir, - &upload_file.file_id, - ) + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) .await?; if let Err(err) = create_upload_resp_result.as_ref() { @@ -653,7 +663,7 @@ async fn start_upload( match upload_part( cloud_service, user_service, - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.upload_id, &upload_file.file_id, @@ -782,7 +792,7 @@ async fn resume_upload( async fn upload_part( cloud_service: &Arc, user_service: &Arc, - workspace_id: &str, + workspace_id: &Uuid, parent_dir: &str, upload_id: &str, file_id: &str, @@ -822,12 +832,9 @@ async fn complete_upload( parts: Vec, global_notifier: &GlobalNotifier, ) -> Result<(), FlowyError> { + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; let file_url = cloud_service - .get_object_url_v1( - &upload_file.workspace_id, - &upload_file.parent_dir, - &upload_file.file_id, - ) + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) .await?; info!( @@ -838,7 +845,7 @@ async fn complete_upload( ); match cloud_service .complete_upload( - &upload_file.workspace_id, + &workspace_id, &upload_file.parent_dir, &upload_file.upload_id, &upload_file.file_id, diff --git a/frontend/rust-lib/flowy-user-pub/Cargo.toml b/frontend/rust-lib/flowy-user-pub/Cargo.toml index 0228e25d35..f8a673e918 100644 --- a/frontend/rust-lib/flowy-user-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-user-pub/Cargo.toml @@ -15,7 +15,6 @@ collab-entity = { workspace = true } serde_json.workspace = true serde_repr.workspace = true chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } -anyhow.workspace = true tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.14" flowy-folder-pub.workspace = true @@ -23,3 +22,4 @@ collab-folder = { workspace = true } tracing.workspace = true base64 = "0.21" client-api = { workspace = true } +flowy-sqlite.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index d68bf3f809..e58c626532 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -4,6 +4,7 @@ use client_api::entity::billing_dto::SubscriptionPlanDetail; pub use client_api::entity::billing_dto::SubscriptionStatus; use client_api::entity::billing_dto::WorkspaceSubscriptionStatus; use client_api::entity::billing_dto::WorkspaceUsageAndLimit; +use client_api::entity::GotrueTokenResponse; pub use client_api::entity::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use collab_entity::{CollabObject, CollabType}; use flowy_error::{internal_error, ErrorCode, FlowyError}; @@ -19,8 +20,8 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AuthResponse, AuthType, Role, UpdateUserProfileParams, UserProfile, UserTokenState, + UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -83,13 +84,9 @@ pub trait UserCloudServiceProvider: Send + Sync { /// * `enable_sync`: A boolean indicating whether synchronization should be enabled or disabled. fn set_enable_sync(&self, uid: i64, enable_sync: bool); - /// Sets the authenticator when user sign in or sign up. - /// - /// # Arguments - /// * `authenticator`: An `Authenticator` object. - fn set_user_authenticator(&self, authenticator: &Authenticator); + fn set_server_auth_type(&self, auth_type: &AuthType); - fn get_user_authenticator(&self) -> Authenticator; + fn get_server_auth_type(&self) -> AuthType; /// Sets the network reachability /// @@ -135,7 +132,7 @@ pub trait UserCloudService: Send + Sync + 'static { /// Delete an account and all the data associated with the account async fn delete_account(&self) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } /// Generate a sign in url for the user with the given email @@ -148,11 +145,17 @@ pub trait UserCloudService: Send + Sync + 'static { &self, email: &str, password: &str, - ) -> Result; + ) -> Result; async fn sign_in_with_magic_link(&self, email: &str, redirect_to: &str) -> Result<(), FlowyError>; + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result; + /// When the user opens the OAuth URL, it redirects to the corresponding provider's OAuth web page. /// After the user is authenticated, the browser will open a deep link to the AppFlowy app (iOS, macOS, etc.), /// which will call [Client::sign_in_with_url]generate_sign_in_url_with_email to sign in. @@ -161,17 +164,13 @@ pub trait UserCloudService: Send + Sync + 'static { async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result; /// Using the user's token to update the user information - async fn update_user( - &self, - credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError>; + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError>; /// Get the user information using the user's token or uid /// return None if the user is not found - async fn get_user_profile(&self, credential: UserCredentials) -> Result; + async fn get_user_profile(&self, uid: i64) -> Result; - async fn open_workspace(&self, workspace_id: &str) -> Result; + async fn open_workspace(&self, workspace_id: &Uuid) -> Result; /// Return the all the workspaces of the user async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError>; @@ -183,18 +182,18 @@ pub trait UserCloudService: Send + Sync + 'static { // Updates the workspace name and icon async fn patch_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, ) -> Result<(), FlowyError>; /// Deletes a workspace owned by the user. - async fn delete_workspace(&self, workspace_id: &str) -> Result<(), FlowyError>; + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { Ok(()) @@ -214,7 +213,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> Result<(), FlowyError> { Ok(()) } @@ -222,7 +221,7 @@ pub trait UserCloudService: Send + Sync + 'static { async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> Result<(), FlowyError> { Ok(()) @@ -230,24 +229,16 @@ pub trait UserCloudService: Send + Sync + 'static { async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { Ok(vec![]) } - async fn get_workspace_member( - &self, - workspace_id: String, - uid: i64, - ) -> Result { - Err(FlowyError::not_support()) - } - async fn get_user_awareness_doc_state( &self, uid: i64, - workspace_id: &str, - object_id: &str, + workspace_id: &Uuid, + object_id: &Uuid, ) -> Result, FlowyError>; fn receive_realtime_event(&self, _json: Value) {} @@ -256,8 +247,6 @@ pub trait UserCloudService: Send + Sync + 'static { None } - async fn reset_workspace(&self, collab_object: CollabObject) -> Result<(), FlowyError>; - async fn create_collab_object( &self, collab_object: &CollabObject, @@ -266,17 +255,17 @@ pub trait UserCloudService: Send + Sync + 'static { async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, ) -> Result<(), FlowyError>; - async fn leave_workspace(&self, workspace_id: &str) -> Result<(), FlowyError> { + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { Ok(()) } async fn subscribe_workspace( &self, - workspace_id: String, + workspace_id: Uuid, recurring_interval: RecurringInterval, workspace_subscription_plan: SubscriptionPlan, success_url: String, @@ -284,27 +273,24 @@ pub trait UserCloudService: Send + Sync + 'static { Err(FlowyError::not_support()) } - async fn get_workspace_member_info( + async fn get_workspace_member( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, - ) -> Result { - Err(FlowyError::not_support()) - } - + ) -> Result; /// Get all subscriptions for all workspaces for a user (email) async fn get_workspace_subscriptions( &self, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } /// Get the workspace subscriptions for a workspace async fn get_workspace_subscription_one( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn cancel_workspace_subscription( @@ -313,22 +299,20 @@ pub trait UserCloudService: Send + Sync + 'static { plan: SubscriptionPlan, reason: Option, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } async fn get_workspace_plan( &self, - workspace_id: String, + workspace_id: Uuid, ) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn get_workspace_usage( &self, - workspace_id: String, - ) -> Result { - Err(FlowyError::not_support()) - } + workspace_id: &Uuid, + ) -> Result; async fn get_billing_portal_url(&self) -> Result { Err(FlowyError::not_support()) @@ -336,31 +320,27 @@ pub trait UserCloudService: Send + Sync + 'static { async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> Result<(), FlowyError> { - Err(FlowyError::not_support()) + Ok(()) } async fn get_subscription_plan_details(&self) -> Result, FlowyError> { - Err(FlowyError::not_support()) + Ok(vec![]) } async fn get_workspace_setting( &self, - workspace_id: &str, - ) -> Result { - Err(FlowyError::not_support()) - } + workspace_id: &Uuid, + ) -> Result; async fn update_workspace_setting( &self, - workspace_id: &str, + workspace_id: &Uuid, workspace_settings: AFWorkspaceSettingsChange, - ) -> Result { - Err(FlowyError::not_support()) - } + ) -> Result; } pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver; diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 8f31edf740..efceb8b5f6 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -1,15 +1,15 @@ +use std::fmt::{Display, Formatter}; use std::str::FromStr; use chrono::{DateTime, Utc}; pub use client_api::entity::billing_dto::RecurringInterval; use client_api::entity::AFRole; +use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_repr::*; use uuid::Uuid; -pub const USER_METADATA_OPEN_AI_KEY: &str = "openai_key"; -pub const USER_METADATA_STABILITY_AI_KEY: &str = "stability_ai_key"; pub const USER_METADATA_ICON_URL: &str = "icon_url"; pub const USER_METADATA_UPDATE_AT: &str = "updated_at"; @@ -31,7 +31,7 @@ pub struct SignInParams { pub email: String, pub password: String, pub name: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, } #[derive(Serialize, Deserialize, Default, Debug)] @@ -39,7 +39,7 @@ pub struct SignUpParams { pub email: String, pub name: String, pub password: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, pub device_id: String, } @@ -100,40 +100,6 @@ impl UserAuthResponse for AuthResponse { } } -#[derive(Clone, Debug)] -pub struct UserCredentials { - /// Currently, the token is only used when the [Authenticator] is AppFlowyCloud - pub token: Option, - - /// The user id - pub uid: Option, - - /// The user id - pub uuid: Option, -} - -impl UserCredentials { - pub fn from_uid(uid: i64) -> Self { - Self { - token: None, - uid: Some(uid), - uuid: None, - } - } - - pub fn from_uuid(uuid: String) -> Self { - Self { - token: None, - uid: None, - uuid: Some(uuid), - } - } - - pub fn new(token: Option, uid: Option, uuid: Option) -> Self { - Self { token, uid, uuid } - } -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserWorkspace { pub id: String, @@ -151,34 +117,33 @@ pub struct UserWorkspace { } impl UserWorkspace { - pub fn new_local(workspace_id: &str, _uid: i64) -> Self { + pub fn workspace_id(&self) -> FlowyResult { + let id = Uuid::from_str(&self.id)?; + Ok(id) + } + + pub fn new_local(workspace_id: String, name: &str) -> Self { Self { - id: workspace_id.to_string(), - name: "".to_string(), + id: workspace_id, + name: name.to_string(), created_at: Utc::now(), workspace_database_id: Uuid::new_v4().to_string(), icon: "".to_string(), member_count: 1, - role: None, + role: Some(Role::Owner), } } } -#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct UserProfile { - #[serde(rename = "id")] pub uid: i64, pub email: String, pub name: String, pub token: String, pub icon_url: String, - pub openai_key: String, - pub stability_ai_key: String, - pub authenticator: Authenticator, - // If the encryption_sign is not empty, which means the user has enabled the encryption. - pub encryption_type: EncryptionType, + pub auth_type: AuthType, pub updated_at: i64, - pub ai_model: String, } #[derive(Serialize, Deserialize, Debug, Clone, Default, Eq, PartialEq)] @@ -220,43 +185,29 @@ impl FromStr for EncryptionType { } } -impl From<(&T, &Authenticator)> for UserProfile +impl From<(&T, &AuthType)> for UserProfile where T: UserAuthResponse, { - fn from(params: (&T, &Authenticator)) -> Self { + fn from(params: (&T, &AuthType)) -> Self { let (value, auth_type) = params; - let (icon_url, openai_key, stability_ai_key) = { - value - .metadata() - .as_ref() - .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - ) - }) - .unwrap_or_default() - }; + let icon_url = value + .metadata() + .as_ref() + .map(|m| { + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) + .unwrap_or_default() + }) + .unwrap_or_default(); Self { uid: value.user_id(), email: value.user_email().unwrap_or_default(), name: value.user_name().to_owned(), token: value.user_token().unwrap_or_default(), icon_url, - openai_key, - authenticator: auth_type.clone(), - encryption_type: value.encryption_type(), - stability_ai_key, + auth_type: *auth_type, updated_at: value.updated_at(), - ai_model: "".to_string(), } } } @@ -268,11 +219,7 @@ pub struct UpdateUserProfileParams { pub email: Option, pub password: Option, pub icon_url: Option, - pub openai_key: Option, - pub stability_ai_key: Option, - pub encryption_sign: Option, pub token: Option, - pub ai_model: Option, } impl UpdateUserProfileParams { @@ -307,45 +254,11 @@ impl UpdateUserProfileParams { self.icon_url = Some(icon_url.to_string()); self } - - pub fn with_openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn with_stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } - - pub fn with_encryption_type(mut self, encryption_type: EncryptionType) -> Self { - let sign = match encryption_type { - EncryptionType::NoEncryption => "".to_string(), - EncryptionType::SelfEncryption(sign) => sign, - }; - self.encryption_sign = Some(sign); - self - } - - pub fn with_ai_model(mut self, ai_model: &str) -> Self { - self.ai_model = Some(ai_model.to_owned()); - self - } - - pub fn is_empty(&self) -> bool { - self.name.is_none() - && self.email.is_none() - && self.password.is_none() - && self.icon_url.is_none() - && self.openai_key.is_none() - && self.encryption_sign.is_none() - && self.stability_ai_key.is_none() - } } -#[derive(Debug, Clone, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] -pub enum Authenticator { +pub enum AuthType { /// It's a local server, we do fake sign in default. Local = 0, /// Currently not supported. It will be supported in the future when the @@ -353,28 +266,37 @@ pub enum Authenticator { AppFlowyCloud = 1, } -impl Default for Authenticator { +impl Display for AuthType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AuthType::Local => write!(f, "Local"), + AuthType::AppFlowyCloud => write!(f, "AppFlowyCloud"), + } + } +} + +impl Default for AuthType { fn default() -> Self { Self::Local } } -impl Authenticator { +impl AuthType { pub fn is_local(&self) -> bool { - matches!(self, Authenticator::Local) + matches!(self, AuthType::Local) } pub fn is_appflowy_cloud(&self) -> bool { - matches!(self, Authenticator::AppFlowyCloud) + matches!(self, AuthType::AppFlowyCloud) } } -impl From for Authenticator { +impl From for AuthType { fn from(value: i32) -> Self { match value { - 0 => Authenticator::Local, - 1 => Authenticator::AppFlowyCloud, - _ => Authenticator::Local, + 0 => AuthType::Local, + 1 => AuthType::AppFlowyCloud, + _ => AuthType::Local, } } } diff --git a/frontend/rust-lib/flowy-user-pub/src/lib.rs b/frontend/rust-lib/flowy-user-pub/src/lib.rs index 2e51ecc626..773ae96a9a 100644 --- a/frontend/rust-lib/flowy-user-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-user-pub/src/lib.rs @@ -1,6 +1,7 @@ pub mod cloud; pub mod entities; pub mod session; +pub mod sql; pub mod workspace_service; pub const DEFAULT_USER_NAME: fn() -> String = || "Me".to_string(); diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs similarity index 71% rename from frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs rename to frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs index 70351ab105..58ca65e732 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/member_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs @@ -1,12 +1,11 @@ +use crate::entities::{Role, WorkspaceMember}; use diesel::{insert_into, RunQueryDsl}; use flowy_error::FlowyResult; - use flowy_sqlite::schema::workspace_members_table; - use flowy_sqlite::schema::workspace_members_table::dsl; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods}; -#[derive(Queryable, Insertable, AsChangeset, Debug)] +#[derive(Queryable, Insertable, AsChangeset, Debug, Clone)] #[diesel(table_name = workspace_members_table)] #[diesel(primary_key(email, workspace_id))] pub struct WorkspaceMemberTable { @@ -19,8 +18,19 @@ pub struct WorkspaceMemberTable { pub updated_at: chrono::NaiveDateTime, } +impl From for WorkspaceMember { + fn from(value: WorkspaceMemberTable) -> Self { + Self { + email: value.email, + role: Role::from(value.role), + name: value.name, + avatar_url: value.avatar_url, + } + } +} + pub fn upsert_workspace_member>( - mut conn: DBConnection, + conn: &mut SqliteConnection, member: T, ) -> FlowyResult<()> { let member = member.into(); @@ -33,7 +43,7 @@ pub fn upsert_workspace_member>( )) .do_update() .set(&member) - .execute(&mut conn)?; + .execute(conn)?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs new file mode 100644 index 0000000000..2a5f7bf891 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs @@ -0,0 +1,9 @@ +mod member_sql; +mod user_sql; +mod workspace_setting_sql; +mod workspace_sql; + +pub use member_sql::*; +pub use user_sql::*; +pub use workspace_setting_sql::*; +pub use workspace_sql::*; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs similarity index 52% rename from frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs rename to frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs index 6da6f183cb..4cdf26520e 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs @@ -1,13 +1,9 @@ -use diesel::{sql_query, RunQueryDsl}; -use flowy_error::{internal_error, FlowyError}; -use std::str::FromStr; - -use flowy_user_pub::cloud::UserUpdate; -use flowy_user_pub::entities::*; - +use crate::cloud::UserUpdate; +use crate::entities::{AuthType, UpdateUserProfileParams, UserProfile}; +use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_table; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods, RunQueryDsl}; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; /// The order of the fields in the struct must be the same as the order of the fields in the table. /// Check out the [schema.rs] for table schema. #[derive(Clone, Default, Queryable, Identifiable, Insertable)] @@ -15,40 +11,26 @@ use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; pub struct UserTable { pub(crate) id: String, pub(crate) name: String, - #[deprecated( - note = "The workspace_id is deprecated, please use the [Session::UserWorkspace] instead" - )] - pub(crate) workspace: String, pub(crate) icon_url: String, - pub(crate) openai_key: String, pub(crate) token: String, pub(crate) email: String, pub(crate) auth_type: i32, - pub(crate) encryption_type: String, - pub(crate) stability_ai_key: String, pub(crate) updated_at: i64, - pub(crate) ai_model: String, } #[allow(deprecated)] -impl From<(UserProfile, Authenticator)> for UserTable { - fn from(value: (UserProfile, Authenticator)) -> Self { +impl From<(UserProfile, AuthType)> for UserTable { + fn from(value: (UserProfile, AuthType)) -> Self { let (user_profile, auth_type) = value; - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTable { id: user_profile.uid.to_string(), name: user_profile.name, #[allow(deprecated)] - workspace: "".to_string(), icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, token: user_profile.token, email: user_profile.email, auth_type: auth_type as i32, - encryption_type, - stability_ai_key: user_profile.stability_ai_key, updated_at: user_profile.updated_at, - ai_model: user_profile.ai_model, } } } @@ -61,12 +43,8 @@ impl From for UserProfile { name: table.name, token: table.token, icon_url: table.icon_url, - openai_key: table.openai_key, - authenticator: Authenticator::from(table.auth_type), - encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), - stability_ai_key: table.stability_ai_key, + auth_type: AuthType::from(table.auth_type), updated_at: table.updated_at, - ai_model: table.ai_model, } } } @@ -75,50 +53,30 @@ impl From for UserProfile { #[diesel(table_name = user_table)] pub struct UserTableChangeset { pub id: String, - pub workspace: Option, // deprecated pub name: Option, pub email: Option, pub icon_url: Option, - pub openai_key: Option, - pub encryption_type: Option, pub token: Option, - pub stability_ai_key: Option, - pub ai_model: Option, } impl UserTableChangeset { pub fn new(params: UpdateUserProfileParams) -> Self { - let encryption_type = params.encryption_sign.map(|sign| { - let ty = EncryptionType::from_sign(&sign); - serde_json::to_string(&ty).unwrap_or_default() - }); UserTableChangeset { id: params.uid.to_string(), - workspace: None, name: params.name, email: params.email, icon_url: params.icon_url, - openai_key: params.openai_key, - encryption_type, token: params.token, - stability_ai_key: params.stability_ai_key, - ai_model: params.ai_model, } } pub fn from_user_profile(user_profile: UserProfile) -> Self { - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); UserTableChangeset { id: user_profile.uid.to_string(), - workspace: None, name: Some(user_profile.name), email: Some(user_profile.email), icon_url: Some(user_profile.icon_url), - openai_key: Some(user_profile.openai_key), - encryption_type: Some(encryption_type), token: Some(user_profile.token), - stability_ai_key: Some(user_profile.stability_ai_key), - ai_model: Some(user_profile.ai_model), } } } @@ -134,10 +92,24 @@ impl From for UserTableChangeset { } } -pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result { +pub fn update_user_profile( + conn: &mut SqliteConnection, + changeset: UserTableChangeset, +) -> Result<(), FlowyError> { + let user_id = changeset.id.clone(); + update(user_table::dsl::user_table.filter(user_table::id.eq(&user_id))) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +pub fn select_user_profile( + uid: i64, + conn: &mut SqliteConnection, +) -> Result { let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) - .first::(&mut *conn) + .first::(conn) .map_err(|err| { FlowyError::record_not_found().with_context(format!( "Can't find the user profile for user id: {}, error: {:?}", @@ -149,9 +121,16 @@ pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result Result<(), FlowyError> { - sql_query("VACUUM") - .execute(&mut *conn) - .map_err(internal_error)?; +pub fn upsert_user(user: UserTable, mut conn: DBConnection) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + // delete old user if exists + diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) + .execute(conn)?; + + let _ = diesel::insert_into(user_table::table) + .values(user) + .execute(conn)?; + Ok::<(), FlowyError>(()) + })?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs new file mode 100644 index 0000000000..7eeafaf1e4 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs @@ -0,0 +1,72 @@ +use client_api::entity::AFWorkspaceSettings; +use flowy_error::FlowyError; +use flowy_sqlite::schema::workspace_setting_table; +use flowy_sqlite::schema::workspace_setting_table::dsl; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{prelude::*, ExpressionMethods}; +use uuid::Uuid; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsTable { + pub id: String, + pub disable_search_indexing: bool, + pub ai_model: String, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsChangeset { + pub id: String, + pub disable_search_indexing: Option, + pub ai_model: Option, +} + +impl WorkspaceSettingsTable { + pub fn from_workspace_settings(workspace_id: &Uuid, settings: &AFWorkspaceSettings) -> Self { + Self { + id: workspace_id.to_string(), + disable_search_indexing: settings.disable_search_indexing, + ai_model: settings.ai_model.clone(), + } + } +} + +pub fn update_workspace_setting( + conn: &mut DBConnection, + changeset: WorkspaceSettingsChangeset, +) -> Result<(), FlowyError> { + diesel::update(dsl::workspace_setting_table) + .filter(workspace_setting_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +/// Upserts a workspace setting into the database. +pub fn upsert_workspace_setting( + conn: &mut SqliteConnection, + settings: WorkspaceSettingsTable, +) -> Result<(), FlowyError> { + diesel::insert_into(dsl::workspace_setting_table) + .values(settings.clone()) + .on_conflict(workspace_setting_table::id) + .do_update() + .set(( + workspace_setting_table::disable_search_indexing.eq(settings.disable_search_indexing), + workspace_setting_table::ai_model.eq(settings.ai_model), + )) + .execute(conn)?; + Ok(()) +} + +/// Selects a workspace setting by id from the database. +pub fn select_workspace_setting( + conn: &mut SqliteConnection, + workspace_id: &str, +) -> Result { + let setting = dsl::workspace_setting_table + .filter(workspace_setting_table::id.eq(workspace_id)) + .first::(conn)?; + Ok(setting) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs new file mode 100644 index 0000000000..709e218514 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -0,0 +1,186 @@ +use crate::entities::{AuthType, UserWorkspace}; +use chrono::{TimeZone, Utc}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::schema::user_workspace_table; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{prelude::*, ExpressionMethods, RunQueryDsl, SqliteConnection}; +use tracing::{info, warn}; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceTable { + pub id: String, + pub name: String, + pub uid: i64, + pub created_at: i64, + pub database_storage_id: String, + pub icon: String, + pub member_count: i64, + pub role: Option, + pub workspace_type: i32, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceChangeset { + pub id: String, + pub name: Option, + pub icon: Option, +} + +impl UserWorkspaceTable { + pub fn from_workspace( + uid: i64, + workspace: &UserWorkspace, + auth_type: AuthType, + ) -> Result { + if workspace.id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The id is empty")); + } + if workspace.workspace_database_id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); + } + + Ok(Self { + id: workspace.id.clone(), + name: workspace.name.clone(), + uid, + created_at: workspace.created_at.timestamp(), + database_storage_id: workspace.workspace_database_id.clone(), + icon: workspace.icon.clone(), + member_count: workspace.member_count, + role: workspace.role.clone().map(|v| v as i32), + workspace_type: auth_type as i32, + }) + } +} + +pub fn select_user_workspace( + workspace_id: &str, + conn: &mut SqliteConnection, +) -> FlowyResult { + let row = user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::id.eq(workspace_id)) + .first::(conn)?; + Ok(row) +} + +pub fn select_all_user_workspace( + user_id: i64, + mut conn: DBConnection, +) -> Result, FlowyError> { + let rows = user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(user_id)) + .load::(&mut *conn)?; + Ok(rows.into_iter().map(UserWorkspace::from).collect()) +} + +pub fn update_user_workspace( + mut conn: DBConnection, + changeset: UserWorkspaceChangeset, +) -> Result<(), FlowyError> { + diesel::update(user_workspace_table::dsl::user_workspace_table) + .filter(user_workspace_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(&mut conn)?; + + Ok(()) +} + +pub fn upsert_user_workspace( + uid: i64, + auth_type: AuthType, + user_workspace: UserWorkspace, + conn: &mut SqliteConnection, +) -> Result<(), FlowyError> { + let row = UserWorkspaceTable::from_workspace(uid, &user_workspace, auth_type)?; + diesel::insert_into(user_workspace_table::table) + .values(row.clone()) + .on_conflict(user_workspace_table::id) + .do_update() + .set(( + user_workspace_table::name.eq(row.name), + user_workspace_table::uid.eq(row.uid), + user_workspace_table::created_at.eq(row.created_at), + user_workspace_table::database_storage_id.eq(row.database_storage_id), + user_workspace_table::icon.eq(row.icon), + user_workspace_table::member_count.eq(row.member_count), + user_workspace_table::role.eq(row.role), + user_workspace_table::workspace_type.eq(row.workspace_type), + )) + .execute(conn)?; + + Ok(()) +} + +pub fn delete_user_workspace(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { + let n = conn.immediate_transaction(|conn| { + let rows_affected: usize = + diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) + .execute(conn)?; + Ok::(rows_affected) + })?; + + if n != 1 { + warn!("expected to delete 1 row, but deleted {} rows", n); + } + Ok(()) +} + +impl From for UserWorkspace { + fn from(value: UserWorkspaceTable) -> Self { + Self { + id: value.id, + name: value.name, + created_at: Utc + .timestamp_opt(value.created_at, 0) + .single() + .unwrap_or_default(), + workspace_database_id: value.database_storage_id, + icon: value.icon, + member_count: value.member_count, + role: value.role.map(|v| v.into()), + } + } +} + +/// Delete all user workspaces for the given user and auth type. +pub fn delete_user_all_workspace( + uid: i64, + auth_type: AuthType, + conn: &mut SqliteConnection, +) -> FlowyResult<()> { + let n = diesel::delete( + user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid)) + .filter(user_workspace_table::workspace_type.eq(auth_type as i32)), + ) + .execute(conn)?; + info!( + "Delete {} workspaces for user {} and auth type {:?}", + n, uid, auth_type + ); + Ok(()) +} + +/// Delete all user workspaces for the given user and auth type, then insert the provided user workspaces. +pub fn delete_all_then_insert_user_workspaces( + uid: i64, + mut conn: DBConnection, + auth_type: AuthType, + user_workspaces: &[UserWorkspace], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + delete_user_all_workspace(uid, auth_type, conn)?; + info!( + "Insert {} workspaces for user {} and auth type {:?}", + user_workspaces.len(), + uid, + auth_type + ); + for user_workspace in user_workspaces { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), conn)?; + } + Ok::<(), FlowyError>(()) + }) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs index 2b1b58ae05..84185d310f 100644 --- a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs +++ b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs @@ -3,6 +3,7 @@ use flowy_error::FlowyResult; use flowy_folder_pub::entities::ImportFrom; use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; #[async_trait] pub trait UserWorkspaceService: Send + Sync { @@ -19,5 +20,5 @@ pub trait UserWorkspaceService: Send + Sync { ) -> FlowyResult<()>; /// Removes local indexes when a workspace is left/deleted - fn did_delete_workspace(&self, workspace_id: String) -> FlowyResult<()>; + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 2a02043f38..65be4cc3f9 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -31,12 +31,9 @@ tracing.workspace = true bytes.workspace = true serde = { workspace = true, features = ["rc"] } serde_json.workspace = true -serde_repr.workspace = true protobuf.workspace = true lazy_static = "1.4.0" diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -once_cell = "1.17.1" strum = "0.25" strum_macros = "0.25.2" tokio = { workspace = true, features = ["rt"] } @@ -51,7 +48,6 @@ validator = { workspace = true, features = ["derive"] } rayon = "1.10.0" [dev-dependencies] -nanoid = "0.4.0" fake = "2.0.0" rand = "0.8.4" quickcheck = "1.0.3" @@ -60,7 +56,6 @@ quickcheck_macros = "1.0" [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] [build-dependencies] flowy-codegen.workspace = true diff --git a/frontend/rust-lib/flowy-user/build.rs b/frontend/rust-lib/flowy-user/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-user/build.rs +++ b/frontend/rust-lib/flowy-user/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index dbfd9b811a..a61ba5cc96 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use std::convert::TryInto; +use crate::entities::parser::*; +use crate::entities::AuthTypePB; +use crate::errors::ErrorCode; +use client_api::entity::GotrueTokenResponse; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; -use crate::entities::parser::*; -use crate::errors::ErrorCode; - #[derive(ProtoBuf, Default)] pub struct SignInPayloadPB { #[pb(index = 1)] @@ -19,7 +20,7 @@ pub struct SignInPayloadPB { pub name: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -30,11 +31,10 @@ impl TryInto for SignInPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; Ok(SignInParams { email: email.0, - password: password.0, + password: self.password, name: self.name, auth_type: self.auth_type.into(), }) @@ -53,7 +53,7 @@ pub struct SignUpPayloadPB { pub password: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -64,13 +64,13 @@ impl TryInto for SignUpPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; + let password = self.password; let name = UserName::parse(self.name)?; Ok(SignUpParams { email: email.0, name: name.0, - password: password.0, + password, auth_type: self.auth_type.into(), device_id: self.device_id, }) @@ -86,6 +86,53 @@ pub struct MagicLinkSignInPB { pub redirect_to: String, } +#[derive(ProtoBuf, Default)] +pub struct PasscodeSignInPB { + #[pb(index = 1)] + pub email: String, + + #[pb(index = 2)] + pub passcode: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct GotrueTokenResponsePB { + #[pb(index = 1)] + pub access_token: String, + + #[pb(index = 2)] + pub token_type: String, + + #[pb(index = 3)] + pub expires_in: i64, + + #[pb(index = 4)] + pub expires_at: i64, + + #[pb(index = 5)] + pub refresh_token: String, + + #[pb(index = 6, one_of)] + pub provider_access_token: Option, + + #[pb(index = 7, one_of)] + pub provider_refresh_token: Option, +} + +impl From for GotrueTokenResponsePB { + fn from(response: GotrueTokenResponse) -> Self { + Self { + access_token: response.access_token, + token_type: response.token_type, + expires_in: response.expires_in, + expires_at: response.expires_at, + refresh_token: response.refresh_token, + provider_access_token: response.provider_access_token, + provider_refresh_token: response.provider_refresh_token, + } + } +} + #[derive(ProtoBuf, Default)] pub struct OauthSignInPB { /// Use this field to store the third party auth information. @@ -97,7 +144,7 @@ pub struct OauthSignInPB { pub map: HashMap, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -106,7 +153,7 @@ pub struct SignInUrlPayloadPB { pub email: String, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -181,85 +228,10 @@ pub struct OauthProviderDataPB { pub oauth_url: String, } -#[repr(u8)] -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum AuthenticatorPB { - Local = 0, - AppFlowyCloud = 2, -} - -impl From for AuthenticatorPB { - fn from(auth_type: Authenticator) -> Self { - match auth_type { - Authenticator::Local => AuthenticatorPB::Local, - Authenticator::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, - } - } -} - -impl From for Authenticator { - fn from(pb: AuthenticatorPB) -> Self { - match pb { - AuthenticatorPB::Local => Authenticator::Local, - AuthenticatorPB::AppFlowyCloud => Authenticator::AppFlowyCloud, - } - } -} - -impl Default for AuthenticatorPB { - fn default() -> Self { - Self::Local - } -} - -#[derive(Debug, ProtoBuf, Default)] -pub struct UserCredentialsPB { - #[pb(index = 1, one_of)] - pub uid: Option, - - #[pb(index = 2, one_of)] - pub uuid: Option, - - #[pb(index = 3, one_of)] - pub token: Option, -} - -impl UserCredentialsPB { - pub fn from_uid(uid: i64) -> Self { - Self { - uid: Some(uid), - uuid: None, - token: None, - } - } - - pub fn from_token(token: &str) -> Self { - Self { - uid: None, - uuid: None, - token: Some(token.to_owned()), - } - } - - pub fn from_uuid(uuid: &str) -> Self { - Self { - uid: None, - uuid: Some(uuid.to_owned()), - token: None, - } - } -} - -impl From for UserCredentials { - fn from(value: UserCredentialsPB) -> Self { - Self::new(value.token, value.uid, value.uuid) - } -} - #[derive(Default, ProtoBuf)] pub struct UserStatePB { #[pb(index = 1)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, } #[derive(ProtoBuf, Debug, Default, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index facc9d8b41..6a95a89041 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -1,18 +1,14 @@ -use std::convert::TryInto; -use std::str::FromStr; - +use super::AFRolePB; +use crate::entities::parser::{UserEmail, UserIcon, UserName}; +use crate::entities::AuthTypePB; +use crate::errors::ErrorCode; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; +use flowy_user_pub::sql::UserWorkspaceTable; use lib_infra::validator_fn::required_not_empty_str; +use std::convert::TryInto; use validator::Validate; -use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey, UserPassword}; -use crate::entities::{AIModelPB, AuthenticatorPB}; -use crate::errors::ErrorCode; - -use super::parser::UserStabilityAIKey; -use super::AFRolePB; - #[derive(Default, ProtoBuf)] pub struct UserTokenPB { #[pb(index = 1)] @@ -43,22 +39,7 @@ pub struct UserProfilePB { pub icon_url: String, #[pb(index = 6)] - pub openai_key: String, - - #[pb(index = 7)] - pub authenticator: AuthenticatorPB, - - #[pb(index = 8)] - pub encryption_sign: String, - - #[pb(index = 9)] - pub encryption_type: EncryptionTypePB, - - #[pb(index = 10)] - pub stability_ai_key: String, - - #[pb(index = 11)] - pub ai_model: AIModelPB, + pub auth_type: AuthTypePB, } #[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] @@ -75,22 +56,13 @@ impl Default for EncryptionTypePB { impl From for UserProfilePB { fn from(user_profile: UserProfile) -> Self { - let (encryption_sign, encryption_ty) = match user_profile.encryption_type { - EncryptionType::NoEncryption => ("".to_string(), EncryptionTypePB::NoEncryption), - EncryptionType::SelfEncryption(sign) => (sign, EncryptionTypePB::Symmetric), - }; Self { id: user_profile.uid, email: user_profile.email, name: user_profile.name, token: user_profile.token, icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, - authenticator: user_profile.authenticator.into(), - encryption_sign, - encryption_type: encryption_ty, - stability_ai_key: user_profile.stability_ai_key, - ai_model: AIModelPB::from_str(&user_profile.ai_model).unwrap_or_default(), + auth_type: user_profile.auth_type.into(), } } } @@ -111,12 +83,6 @@ pub struct UpdateUserProfilePayloadPB { #[pb(index = 5, one_of)] pub icon_url: Option, - - #[pb(index = 6, one_of)] - pub openai_key: Option, - - #[pb(index = 7, one_of)] - pub stability_ai_key: Option, } impl UpdateUserProfilePayloadPB { @@ -146,16 +112,6 @@ impl UpdateUserProfilePayloadPB { self.icon_url = Some(icon_url.to_owned()); self } - - pub fn openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } } impl TryInto for UpdateUserProfilePayloadPB { @@ -172,37 +128,20 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(email) => Some(UserEmail::parse(email)?.0), }; - let password = match self.password { - None => None, - Some(password) => Some(UserPassword::parse(password)?.0), - }; + let password = self.password; let icon_url = match self.icon_url { None => None, Some(icon_url) => Some(UserIcon::parse(icon_url)?.0), }; - let openai_key = match self.openai_key { - None => None, - Some(openai_key) => Some(UserOpenaiKey::parse(openai_key)?.0), - }; - - let stability_ai_key = match self.stability_ai_key { - None => None, - Some(stability_ai_key) => Some(UserStabilityAIKey::parse(stability_ai_key)?.0), - }; - Ok(UpdateUserProfileParams { uid: self.id, name, email, password, icon_url, - openai_key, - encryption_sign: None, token: None, - stability_ai_key, - ai_model: None, }) } } @@ -213,10 +152,14 @@ pub struct RepeatedUserWorkspacePB { pub items: Vec, } -impl From> for RepeatedUserWorkspacePB { - fn from(workspaces: Vec) -> Self { +impl From<(AuthType, Vec)> for RepeatedUserWorkspacePB { + fn from(value: (AuthType, Vec)) -> Self { + let (auth_type, workspaces) = value; Self { - items: workspaces.into_iter().map(UserWorkspacePB::from).collect(), + items: workspaces + .into_iter() + .map(|w| UserWorkspacePB::from((auth_type, w))) + .collect(), } } } @@ -241,17 +184,35 @@ pub struct UserWorkspacePB { #[pb(index = 6, one_of)] pub role: Option, + + #[pb(index = 7)] + pub workspace_auth_type: AuthTypePB, } -impl From for UserWorkspacePB { - fn from(value: UserWorkspace) -> Self { +impl From<(AuthType, UserWorkspace)> for UserWorkspacePB { + fn from(value: (AuthType, UserWorkspace)) -> Self { + Self { + workspace_id: value.1.id, + name: value.1.name, + created_at_timestamp: value.1.created_at.timestamp(), + icon: value.1.icon, + member_count: value.1.member_count, + role: value.1.role.map(AFRolePB::from), + workspace_auth_type: AuthTypePB::from(value.0), + } + } +} + +impl From for UserWorkspacePB { + fn from(value: UserWorkspaceTable) -> Self { Self { workspace_id: value.id, name: value.name, - created_at_timestamp: value.created_at.timestamp(), + created_at_timestamp: value.created_at, icon: value.icon, member_count: value.member_count, role: value.role.map(AFRolePB::from), + workspace_auth_type: AuthTypePB::from(value.workspace_type), } } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index 736979f11a..99544eede4 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -3,12 +3,12 @@ use client_api::entity::billing_dto::{ WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, }; use serde::{Deserialize, Serialize}; -use std::str::FromStr; use validator::Validate; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; -use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::entities::{AuthType, Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::sql::WorkspaceSettingsTable; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] @@ -155,6 +155,17 @@ pub enum AFRolePB { Guest = 2, } +impl From for AFRolePB { + fn from(value: i32) -> Self { + match value { + 0 => AFRolePB::Owner, + 1 => AFRolePB::Member, + 2 => AFRolePB::Guest, + _ => AFRolePB::Guest, + } + } +} + impl From for Role { fn from(value: AFRolePB) -> Self { match value { @@ -182,6 +193,16 @@ pub struct UserWorkspaceIdPB { pub workspace_id: String, } +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct OpenUserWorkspacePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + #[derive(ProtoBuf, Default, Clone, Validate)] pub struct CancelWorkspaceSubscriptionPB { #[pb(index = 1)] @@ -216,6 +237,45 @@ pub struct CreateWorkspacePB { #[pb(index = 1)] #[validate(custom(function = "required_not_empty_str"))] pub name: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + +#[derive(ProtoBuf_Enum, Default, Debug, Clone, Eq, PartialEq)] +#[repr(u8)] +pub enum AuthTypePB { + #[default] + Local = 0, + Server = 1, +} + +impl From for AuthTypePB { + fn from(value: i32) -> Self { + match value { + 0 => AuthTypePB::Local, + 1 => AuthTypePB::Server, + _ => AuthTypePB::Server, + } + } +} + +impl From for AuthTypePB { + fn from(value: AuthType) -> Self { + match value { + AuthType::Local => AuthTypePB::Local, + AuthType::AppFlowyCloud => AuthTypePB::Server, + } + } +} + +impl From for AuthType { + fn from(value: AuthTypePB) -> Self { + match value { + AuthTypePB::Local => AuthType::Local, + AuthTypePB::Server => AuthType::AppFlowyCloud, + } + } } #[derive(ProtoBuf, Default, Clone, Validate)] @@ -376,25 +436,34 @@ pub struct BillingPortalPB { pub url: String, } -#[derive(ProtoBuf, Default, Clone, Validate)] -pub struct UseAISettingPB { +#[derive(ProtoBuf, Default, Clone, Validate, Eq, PartialEq)] +pub struct WorkspaceSettingsPB { #[pb(index = 1)] pub disable_search_indexing: bool, #[pb(index = 2)] - pub ai_model: AIModelPB, + pub ai_model: String, } -impl From for UseAISettingPB { - fn from(value: AFWorkspaceSettings) -> Self { +impl From<&AFWorkspaceSettings> for WorkspaceSettingsPB { + fn from(value: &AFWorkspaceSettings) -> Self { Self { disable_search_indexing: value.disable_search_indexing, - ai_model: AIModelPB::from_str(&value.ai_model).unwrap_or_default(), + ai_model: value.ai_model.clone(), } } } -#[derive(ProtoBuf, Default, Clone, Validate)] +impl From for WorkspaceSettingsPB { + fn from(value: WorkspaceSettingsTable) -> Self { + Self { + disable_search_indexing: value.disable_search_indexing, + ai_model: value.ai_model, + } + } +} + +#[derive(ProtoBuf, Default, Clone, Validate, Debug)] pub struct UpdateUserWorkspaceSettingPB { #[pb(index = 1)] #[validate(custom(function = "required_not_empty_str"))] @@ -404,58 +473,22 @@ pub struct UpdateUserWorkspaceSettingPB { pub disable_search_indexing: Option, #[pb(index = 3, one_of)] - pub ai_model: Option, + pub ai_model: Option, } impl From for AFWorkspaceSettingsChange { fn from(value: UpdateUserWorkspaceSettingPB) -> Self { let mut change = AFWorkspaceSettingsChange::new(); if let Some(disable_search_indexing) = value.disable_search_indexing { - change = change.disable_search_indexing(disable_search_indexing); + change.disable_search_indexing = Some(disable_search_indexing); } if let Some(ai_model) = value.ai_model { - change = change.ai_model(ai_model.to_str().to_string()); + change.ai_model = Some(ai_model); } change } } -#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] -pub enum AIModelPB { - #[default] - DefaultModel = 0, - GPT4oMini = 1, - GPT4o = 2, - Claude3Sonnet = 3, - Claude3Opus = 4, -} - -impl AIModelPB { - pub fn to_str(&self) -> &str { - match self { - AIModelPB::DefaultModel => "default-model", - AIModelPB::GPT4oMini => "gpt-4o-mini", - AIModelPB::GPT4o => "gpt-4o", - AIModelPB::Claude3Sonnet => "claude-3-sonnet", - AIModelPB::Claude3Opus => "claude-3-opus", - } - } -} - -impl FromStr for AIModelPB { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "gpt-3.5-turbo" => Ok(AIModelPB::GPT4oMini), - "gpt-4o" => Ok(AIModelPB::GPT4o), - "claude-3-sonnet" => Ok(AIModelPB::Claude3Sonnet), - "claude-3-opus" => Ok(AIModelPB::Claude3Opus), - _ => Ok(AIModelPB::DefaultModel), - } - } -} - #[derive(Debug, ProtoBuf, Default, Clone)] pub struct WorkspaceSubscriptionInfoPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index c22af4f1c1..b1ce8c5ec6 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -1,14 +1,3 @@ -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_sqlite::kv::KVStorePreferences; -use flowy_user_pub::cloud::UserCloudConfig; -use flowy_user_pub::entities::*; -use lib_dispatch::prelude::*; -use lib_infra::box_any::BoxAny; -use serde_json::Value; -use std::sync::Weak; -use std::{convert::TryInto, sync::Arc}; -use tracing::{event, trace}; - use crate::entities::*; use crate::notification::{send_notification, UserNotification}; use crate::services::cloud_config::{ @@ -16,6 +5,18 @@ use crate::services::cloud_config::{ }; use crate::services::data_import::prepare_import; use crate::user_manager::UserManager; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_user_pub::entities::*; +use flowy_user_pub::sql::UserWorkspaceChangeset; +use lib_dispatch::prelude::*; +use lib_infra::box_any::BoxAny; +use serde_json::Value; +use std::str::FromStr; +use std::sync::Weak; +use std::{convert::TryInto, sync::Arc}; +use tracing::{event, trace}; +use uuid::Uuid; fn upgrade_manager(manager: AFPluginState>) -> FlowyResult> { let manager = manager @@ -39,20 +40,16 @@ fn upgrade_store_preferences( pub async fn sign_in_with_email_password_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let auth_type = params.auth_type.clone(); - let old_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_in(params, auth_type).await { - Ok(profile) => data_result_ok(UserProfilePB::from(profile)), - Err(err) => { - manager - .cloud_services - .set_user_authenticator(&old_authenticator); - return Err(err); - }, + match manager + .sign_in_with_password(¶ms.email, ¶ms.password) + .await + { + Ok(token) => data_result_ok(token.into()), + Err(err) => Err(err), } } @@ -72,17 +69,11 @@ pub async fn sign_up( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignUpParams = data.into_inner().try_into()?; - let authenticator = params.auth_type.clone(); + let auth_type = params.auth_type; - let old_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_up(authenticator, BoxAny::new(params)).await { + match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), - Err(err) => { - manager - .cloud_services - .set_user_authenticator(&old_authenticator); - return Err(err); - }, + Err(err) => Err(err), } } @@ -115,7 +106,7 @@ pub async fn get_user_profile_handler( // When the user is logged in with a local account, the email field is a placeholder and should // not be exposed to the client. So we set the email field to an empty string. - if user_profile.authenticator == Authenticator::Local { + if user_profile.auth_type == AuthType::Local { user_profile.email = "".to_string(); } @@ -317,6 +308,19 @@ pub async fn sign_in_with_magic_link_handler( Ok(()) } +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub async fn sign_in_with_passcode_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.into_inner(); + let response = manager + .sign_in_with_passcode(¶ms.email, ¶ms.passcode) + .await?; + data_result_ok(response.into()) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn oauth_sign_in_handler( data: AFPluginData, @@ -324,7 +328,7 @@ pub async fn oauth_sign_in_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let user_profile = manager .sign_up(authenticator, BoxAny::new(params.map)) .await?; @@ -338,7 +342,7 @@ pub async fn gen_sign_in_url_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let sign_in_url = manager .generate_sign_in_url_with_email(&authenticator, ¶ms.email) .await?; @@ -359,66 +363,6 @@ pub async fn sign_in_with_provider_handler( }) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn set_encrypt_secret_handler( - manager: AFPluginState>, - data: AFPluginData, - store_preferences: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let store_preferences = upgrade_store_preferences(store_preferences)?; - let data = data.into_inner(); - match data.encryption_type { - EncryptionTypePB::NoEncryption => { - tracing::error!("Encryption type is NoEncryption, but set encrypt secret"); - }, - EncryptionTypePB::Symmetric => { - manager.check_encryption_sign_with_secret( - data.user_id, - &data.encryption_sign, - &data.encryption_secret, - )?; - - let config = UserCloudConfig::new(data.encryption_secret).with_enable_encrypt(true); - manager - .set_encrypt_secret( - data.user_id, - config.encrypt_secret.clone(), - EncryptionType::SelfEncryption(data.encryption_sign), - ) - .await?; - save_cloud_config(data.user_id, &store_preferences, &config)?; - }, - } - - manager.resume_sign_up().await?; - Ok(()) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn check_encrypt_secret_handler( - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let profile = manager.get_user_profile_from_disk(uid).await?; - - let is_need_secret = match profile.encryption_type { - EncryptionType::NoEncryption => false, - EncryptionType::SelfEncryption(sign) => { - if sign.is_empty() { - false - } else { - manager.check_encryption_sign(uid, &sign).is_err() - } - }, - }; - - data_result_ok(UserEncryptionConfigurationPB { - require_secret: is_need_secret, - }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_cloud_config_handler( manager: AFPluginState>, @@ -434,40 +378,18 @@ pub async fn set_cloud_config_handler( if let Some(enable_sync) = update.enable_sync { manager - .cloud_services + .cloud_service .set_enable_sync(session.user_id, enable_sync); config.enable_sync = enable_sync; } - if let Some(enable_encrypt) = update.enable_encrypt { - debug_assert!(enable_encrypt, "Disable encryption is not supported"); - - if enable_encrypt { - tracing::info!("Enable encryption for user: {}", session.user_id); - config = config.with_enable_encrypt(enable_encrypt); - let encrypt_secret = config.encrypt_secret.clone(); - - // The encryption secret is generated when the user first enables encryption and will be - // used to validate the encryption secret is correct when the user logs in. - let encryption_sign = manager.generate_encryption_sign(session.user_id, &encrypt_secret)?; - let encryption_type = EncryptionType::SelfEncryption(encryption_sign); - manager - .set_encrypt_secret(session.user_id, encrypt_secret, encryption_type.clone()) - .await?; - - let params = - UpdateUserProfileParams::new(session.user_id).with_encryption_type(encryption_type); - manager.update_user_profile(params).await?; - } - } - save_cloud_config(session.user_id, &store_preferences, &config)?; let payload = CloudSettingPB { enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }; send_notification( @@ -494,7 +416,7 @@ pub async fn get_cloud_config_handler( enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }) } @@ -503,22 +425,44 @@ pub async fn get_all_workspace_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let user_workspaces = manager.get_all_user_workspaces(uid).await?; - data_result_ok(user_workspaces.into()) + let profile = manager.get_user_profile().await?; + let user_workspaces = manager + .get_all_user_workspaces(profile.uid, profile.auth_type) + .await?; + + data_result_ok(RepeatedUserWorkspacePB::from(( + profile.auth_type, + user_workspaces, + ))) } #[tracing::instrument(level = "info", skip(data, manager), err)] pub async fn open_workspace_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - manager.open_workspace(¶ms.workspace_id).await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + manager + .open_workspace(&workspace_id, AuthType::from(params.auth_type)) + .await?; Ok(()) } +#[tracing::instrument(level = "info", skip(data, manager), err)] +pub async fn get_user_workspace_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let uid = manager.user_id()?; + let user_workspace = manager.get_user_workspace_from_db(uid, &workspace_id)?; + data_result_ok(UserWorkspacePB::from(user_workspace)) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn update_network_state_handler( data: AFPluginData, @@ -526,12 +470,12 @@ pub async fn update_network_state_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let reachable = data.into_inner().ty.is_reachable(); - manager.cloud_services.set_network_reachable(reachable); + manager.cloud_service.set_network_reachable(reachable); manager .user_status_callback .read() .await - .did_update_network(reachable); + .on_network_status_changed(reachable); Ok(()) } @@ -596,24 +540,6 @@ pub async fn get_all_reminder_event_handler( data_result_ok(reminders.into()) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn reset_workspace_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let reset_pb = data.into_inner(); - if reset_pb.workspace_id.is_empty() { - return Err(FlowyError::new( - ErrorCode::WorkspaceInitializeError, - "The workspace id is empty", - )); - } - let _session = manager.get_session()?; - manager.reset_workspace(reset_pb).await?; - Ok(()) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn remove_reminder_event_handler( data: AFPluginData, @@ -645,8 +571,9 @@ pub async fn delete_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .remove_workspace_member(data.email, data.workspace_id) + .remove_workspace_member(data.email, workspace_id) .await?; Ok(()) } @@ -658,8 +585,9 @@ pub async fn get_workspace_members_handler( ) -> DataResult { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; let members = manager - .get_workspace_members(data.workspace_id) + .get_workspace_members(workspace_id) .await? .into_iter() .map(WorkspaceMemberPB::from) @@ -674,8 +602,9 @@ pub async fn update_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .update_workspace_member(data.email, data.workspace_id, data.role.into()) + .update_workspace_member(data.email, workspace_id, data.role.into()) .await?; Ok(()) } @@ -686,9 +615,10 @@ pub async fn create_workspace_handler( manager: AFPluginState>, ) -> DataResult { let data = data.try_into_inner()?; + let auth_type = AuthType::from(data.auth_type); let manager = upgrade_manager(manager)?; - let new_workspace = manager.add_workspace(&data.name).await?; - data_result_ok(new_workspace.into()) + let new_workspace = manager.create_workspace(&data.name, auth_type).await?; + data_result_ok(UserWorkspacePB::from((auth_type, new_workspace))) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -698,6 +628,7 @@ pub async fn delete_workspace_handler( ) -> Result<(), FlowyError> { let workspace_id = delete_workspace_param.try_into_inner()?.workspace_id; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&workspace_id)?; manager.delete_workspace(&workspace_id).await?; Ok(()) } @@ -709,9 +640,13 @@ pub async fn rename_workspace_handler( ) -> Result<(), FlowyError> { let params = rename_workspace_param.try_into_inner()?; let manager = upgrade_manager(manager)?; - manager - .patch_workspace(¶ms.workspace_id, Some(¶ms.new_name), None) - .await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let changeset = UserWorkspaceChangeset { + id: params.workspace_id, + name: Some(params.new_name), + icon: None, + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -722,9 +657,13 @@ pub async fn change_workspace_icon_handler( ) -> Result<(), FlowyError> { let params = change_workspace_icon_param.try_into_inner()?; let manager = upgrade_manager(manager)?; - manager - .patch_workspace(¶ms.workspace_id, None, Some(¶ms.new_icon)) - .await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let changeset = UserWorkspaceChangeset { + id: workspace_id.to_string(), + name: None, + icon: Some(params.new_icon), + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -735,8 +674,9 @@ pub async fn invite_workspace_member_handler( ) -> Result<(), FlowyError> { let param = param.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(¶m.workspace_id)?; manager - .invite_member_to_workspace(param.workspace_id, param.invitee_email, param.role.into()) + .invite_member_to_workspace(workspace_id, param.invitee_email, param.role.into()) .await?; Ok(()) } @@ -772,6 +712,7 @@ pub async fn leave_workspace_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let workspace_id = param.into_inner().workspace_id; + let workspace_id = Uuid::from_str(&workspace_id)?; let manager = upgrade_manager(manager)?; manager.leave_workspace(&workspace_id).await?; Ok(()) @@ -819,9 +760,9 @@ pub async fn get_workspace_usage_handler( param: AFPluginData, manager: AFPluginState>, ) -> DataResult { - let workspace_id = param.into_inner().workspace_id; + let workspace_id = Uuid::from_str(¶m.into_inner().workspace_id)?; let manager = upgrade_manager(manager)?; - let workspace_usage = manager.get_workspace_usage(workspace_id).await?; + let workspace_usage = manager.get_workspace_usage(&workspace_id).await?; data_result_ok(WorkspaceUsagePB::from(workspace_usage)) } @@ -839,11 +780,12 @@ pub async fn update_workspace_subscription_payment_period_handler( params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; let params = params.try_into_inner()?; let manager = upgrade_manager(manager)?; manager .update_workspace_subscription_payment_period( - params.workspace_id, + &workspace_id, params.plan.into(), params.recurring_interval.into(), ) @@ -870,12 +812,15 @@ pub async fn get_workspace_member_info( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let member = manager.get_workspace_member_info(param.uid).await?; + let workspace_id = manager.get_session()?.user_workspace.workspace_id()?; + let member = manager + .get_workspace_member_info(param.uid, &workspace_id) + .await?; data_result_ok(member.into()) } #[tracing::instrument(level = "info", skip_all, err)] -pub async fn update_workspace_setting( +pub async fn update_workspace_setting_handler( params: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { @@ -886,13 +831,14 @@ pub async fn update_workspace_setting( } #[tracing::instrument(level = "info", skip_all, err)] -pub async fn get_workspace_setting( +pub async fn get_workspace_setting_handler( params: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let params = params.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; let manager = upgrade_manager(manager)?; - let pb = manager.get_workspace_settings(¶ms.workspace_id).await?; + let pb = manager.get_workspace_settings(&workspace_id).await?; data_result_ok(pb) } diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 0807b46170..fbee0a96d9 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -1,13 +1,13 @@ use client_api::entity::billing_dto::SubscriptionPlan; -use std::sync::Weak; -use strum_macros::Display; - use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use flowy_error::FlowyResult; use flowy_user_pub::cloud::UserCloudConfig; use flowy_user_pub::entities::*; use lib_dispatch::prelude::*; use lib_infra::async_trait::async_trait; +use std::sync::Weak; +use strum_macros::Display; +use uuid::Uuid; use crate::event_handler::*; use crate::user_manager::UserManager; @@ -35,12 +35,11 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetUserSetting, get_user_setting) .event(UserEvent::SetCloudConfig, set_cloud_config_handler) .event(UserEvent::GetCloudConfig, get_cloud_config_handler) - .event(UserEvent::SetEncryptionSecret, set_encrypt_secret_handler) - .event(UserEvent::CheckEncryptionSign, check_encrypt_secret_handler) .event(UserEvent::OauthSignIn, oauth_sign_in_handler) .event(UserEvent::GenerateSignInURL, gen_sign_in_url_handler) .event(UserEvent::GetOauthURLWithProvider, sign_in_with_provider_handler) .event(UserEvent::OpenWorkspace, open_workspace_handler) + .event(UserEvent::GetUserWorkspace, get_user_workspace_handler) .event(UserEvent::UpdateNetworkState, update_network_state_handler) .event(UserEvent::OpenAnonUser, open_anon_user_handler) .event(UserEvent::GetAnonUser, get_anon_user_handler) @@ -49,7 +48,6 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetAllReminders, get_all_reminder_event_handler) .event(UserEvent::RemoveReminder, remove_reminder_event_handler) .event(UserEvent::UpdateReminder, update_reminder_event_handler) - .event(UserEvent::ResetWorkspace, reset_workspace_handler) .event(UserEvent::SetDateTimeSettings, set_date_time_settings) .event(UserEvent::GetDateTimeSettings, get_date_time_settings) .event(UserEvent::SetNotificationSettings, set_notification_settings) @@ -78,21 +76,21 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::UpdateWorkspaceSubscriptionPaymentPeriod, update_workspace_subscription_payment_period_handler) .event(UserEvent::GetSubscriptionPlanDetails, get_subscription_plan_details_handler) // Workspace Setting - .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting) - .event(UserEvent::GetWorkspaceSetting, get_workspace_setting) + .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting_handler) + .event(UserEvent::GetWorkspaceSetting, get_workspace_setting_handler) .event(UserEvent::NotifyDidSwitchPlan, notify_did_switch_plan_handler) - + .event(UserEvent::PasscodeSignIn, sign_in_with_passcode_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum UserEvent { - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Logging into an account using a register email and password - #[event(input = "SignInPayloadPB", output = "UserProfilePB")] + #[event(input = "SignInPayloadPB", output = "GotrueTokenResponsePB")] SignInWithEmailPassword = 0, - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Creating a new account #[event(input = "SignUpPayloadPB", output = "UserProfilePB")] SignUp = 1, @@ -130,7 +128,7 @@ pub enum UserEvent { OauthSignIn = 10, /// Get the OAuth callback url - /// Only use when the [Authenticator] is AFCloud + /// Only use when the [AuthType] is AFCloud #[event(input = "SignInUrlPayloadPB", output = "SignInUrlPB")] GenerateSignInURL = 11, @@ -143,19 +141,16 @@ pub enum UserEvent { #[event(output = "CloudSettingPB")] GetCloudConfig = 14, - #[event(input = "UserSecretPB")] - SetEncryptionSecret = 15, - - #[event(output = "UserEncryptionConfigurationPB")] - CheckEncryptionSign = 16, - /// Return the all the workspaces of the user #[event(output = "RepeatedUserWorkspacePB")] GetAllWorkspace = 17, - #[event(input = "UserWorkspaceIdPB")] + #[event(input = "OpenUserWorkspacePB")] OpenWorkspace = 21, + #[event(input = "UserWorkspaceIdPB", output = "UserWorkspacePB")] + GetUserWorkspace = 22, + #[event(input = "NetworkStatePB")] UpdateNetworkState = 24, @@ -166,7 +161,7 @@ pub enum UserEvent { OpenAnonUser = 26, /// Push a realtime event to the user. Currently, the realtime event - /// is only used when the auth type is: [Authenticator::Supabase]. + /// is only used when the auth type is: [AuthType::Supabase]. /// #[event(input = "RealtimePayloadPB")] PushRealtimeEvent = 27, @@ -183,9 +178,6 @@ pub enum UserEvent { #[event(input = "ReminderPB")] UpdateReminder = 31, - #[event(input = "ResetWorkspacePB")] - ResetWorkspace = 32, - /// Change the Date/Time formats globally #[event(input = "DateTimeSettingsPB")] SetDateTimeSettings = 33, @@ -261,7 +253,7 @@ pub enum UserEvent { #[event(input = "UpdateUserWorkspaceSettingPB")] UpdateWorkspaceSetting = 57, - #[event(input = "UserWorkspaceIdPB", output = "UseAISettingPB")] + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSettingsPB")] GetWorkspaceSetting = 58, #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")] @@ -278,62 +270,68 @@ pub enum UserEvent { #[event()] DeleteAccount = 64, + + #[event(input = "PasscodeSignInPB", output = "GotrueTokenResponsePB")] + PasscodeSignIn = 65, } #[async_trait] pub trait UserStatusCallback: Send + Sync + 'static { - /// When the [Authenticator] changed, this method will be called. Currently, the auth type + /// When the [AuthType] changed, this method will be called. Currently, the auth type /// will be changed when the user sign in or sign up. - fn authenticator_did_changed(&self, _authenticator: Authenticator) {} - /// This will be called after the application launches if the user is already signed in. - /// If the user is not signed in, this method will not be called - async fn did_init( + fn on_auth_type_changed(&self, _authenticator: AuthType) {} + /// Fires on app launch, but only if the user is already signed in. + async fn on_launch_if_authenticated( &self, _user_id: i64, - _user_authenticator: &Authenticator, _cloud_config: &Option, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } - /// Will be called after the user signed in. - async fn did_sign_in( + /// Fires right after the user successfully signs in. + async fn on_sign_in( &self, _user_id: i64, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } - /// Will be called after the user signed up. - async fn did_sign_up( + + /// Fires right after the user successfully signs up. + async fn on_sign_up( &self, _is_new_user: bool, _user_profile: &UserProfile, _user_workspace: &UserWorkspace, _device_id: &str, - _authenticator: &Authenticator, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } - async fn did_expired(&self, _token: &str, _user_id: i64) -> FlowyResult<()> { + /// Fires when an authentication token has expired. + async fn on_token_expired(&self, _token: &str, _user_id: i64) -> FlowyResult<()> { Ok(()) } - async fn open_workspace( + + /// Fires when a workspace is opened by the user. + async fn on_workspace_opened( &self, _user_id: i64, + _workspace_id: &Uuid, _user_workspace: &UserWorkspace, - _authenticator: &Authenticator, + _auth_type: &AuthType, ) -> FlowyResult<()> { Ok(()) } - fn did_update_network(&self, _reachable: bool) {} - fn did_update_plans(&self, _plans: Vec) {} - fn did_update_storage_limitation(&self, _can_write: bool) {} + fn on_network_status_changed(&self, _reachable: bool) {} + fn on_subscription_plans_updated(&self, _plans: Vec) {} + fn on_storage_permission_updated(&self, _can_write: bool) {} } /// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function diff --git a/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs new file mode 100644 index 0000000000..7c806d3aaf --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs @@ -0,0 +1,58 @@ +use diesel::SqliteConnection; +use semver::Version; +use std::sync::Arc; +use tracing::{info, instrument}; + +use collab_integrate::CollabKVDB; +use flowy_error::FlowyResult; +use flowy_user_pub::entities::AuthType; + +use crate::migrations::migration::UserDataMigration; +use flowy_user_pub::session::Session; +use flowy_user_pub::sql::{select_user_workspace, upsert_user_workspace}; + +pub struct AnonUserWorkspaceTableMigration; + +impl UserDataMigration for AnonUserWorkspaceTableMigration { + fn name(&self) -> &str { + "anon_user_workspace_table_migration" + } + + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version <= &Version::new(0, 8, 10), + } + } + + #[instrument(name = "AnonUserWorkspaceTableMigration", skip_all, err)] + fn run( + &self, + session: &Session, + _collab_db: &Arc, + auth_type: &AuthType, + db: &mut SqliteConnection, + ) -> FlowyResult<()> { + // For historical reason, anon user doesn't have a workspace in user_workspace_table. + // So we need to create a new entry for the anon user in the user_workspace_table. + if matches!(auth_type, AuthType::Local) { + let user_workspace = &session.user_workspace; + let result = select_user_workspace(&user_workspace.id, db); + if let Err(e) = result { + if e.is_record_not_found() { + info!( + "Anon user workspace not found in the database, creating a new entry for user_id: {}", + session.user_id + ); + upsert_user_workspace(session.user_id, *auth_type, user_workspace.clone(), db)?; + } + } + } + + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs index 3056f4d945..84acc0b56a 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use collab_plugins::local_storage::kv::doc::migrate_old_keys; use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; use semver::Version; use tracing::{instrument, trace}; use collab_integrate::CollabKVDB; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use flowy_user_pub::session::Session; @@ -39,7 +40,8 @@ impl UserDataMigration for CollabDocKeyWithWorkspaceIdMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { trace!( "migrate key with workspace id:{}", diff --git a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs index e557c22450..2e4581f7ec 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -6,12 +6,13 @@ use collab_document::document::Document; use collab_document::document_data::default_document_data; use collab_folder::{Folder, View}; use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; use semver::Version; use tracing::{event, instrument}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use flowy_error::{FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -41,12 +42,13 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { &self, session: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { // - The `empty document` struct has already undergone refactoring prior to the launch of the AppFlowy cloud version. // - Consequently, if a user is utilizing the AppFlowy cloud version, there is no need to perform any migration for the `empty document` struct. // - This migration step is only necessary for users who are transitioning from a local version of AppFlowy to the cloud version. - if !matches!(authenticator, Authenticator::Local) { + if !matches!(authenticator, AuthType::Local) { return Ok(()); } collab_db.with_write_txn(|write_txn| { diff --git a/frontend/rust-lib/flowy-user/src/migrations/migration.rs b/frontend/rust-lib/flowy-user/src/migrations/migration.rs index c604e47e8d..0f5c2c2624 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/migration.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/migration.rs @@ -7,7 +7,7 @@ use flowy_error::FlowyResult; use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::schema::user_data_migration_records; use flowy_sqlite::ConnectionPool; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use flowy_user_pub::session::Session; use semver::Version; use tracing::info; @@ -54,7 +54,7 @@ impl UserLocalDataMigration { pub fn run( self, migrations: Vec>, - authenticator: &Authenticator, + auth_type: &AuthType, app_version: &Version, ) -> FlowyResult> { let mut applied_migrations = vec![]; @@ -75,7 +75,7 @@ impl UserLocalDataMigration { let migration_name = migration.name().to_string(); if !duplicated_names.contains(&migration_name) { - migration.run(&self.session, &self.collab_db, authenticator)?; + migration.run(&self.session, &self.collab_db, auth_type, &mut conn)?; applied_migrations.push(migration.name().to_string()); save_migration_record(&mut conn, &migration_name); duplicated_names.push(migration_name); @@ -98,7 +98,8 @@ pub trait UserDataMigration { &self, user: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, + db: &mut SqliteConnection, ) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/src/migrations/mod.rs b/frontend/rust-lib/flowy-user/src/migrations/mod.rs index c8d04edf66..3d87dc595f 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/mod.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/mod.rs @@ -1,6 +1,7 @@ use flowy_user_pub::session::Session; use std::sync::Arc; +pub mod anon_user_workspace; pub mod doc_key_with_workspace; pub mod document_empty_content; pub mod migration; diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs index ec55b5fe29..5f14051e26 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -39,7 +40,8 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs index d631e32e78..b5eeead8c6 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -37,7 +38,8 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { if let Ok(collab) = load_collab( diff --git a/frontend/rust-lib/flowy-user/src/notification.rs b/frontend/rust-lib/flowy-user/src/notification.rs index a8bd91b55b..dd93593468 100644 --- a/frontend/rust-lib/flowy-user/src/notification.rs +++ b/frontend/rust-lib/flowy-user/src/notification.rs @@ -14,7 +14,7 @@ pub(crate) enum UserNotification { DidUpdateUserWorkspaces = 3, DidUpdateCloudConfig = 4, DidUpdateUserWorkspace = 5, - DidUpdateAISetting = 6, + DidUpdateWorkspaceSetting = 6, } impl std::convert::From for i32 { diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index 7d770e8123..84c1e9afe9 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -1,9 +1,9 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; use crate::services::db::UserDB; use crate::services::entities::{UserConfig, UserPaths}; -use crate::services::sqlite_sql::user_sql::vacuum_database; use collab_integrate::CollabKVDB; +use crate::user_manager::manager_history_user::ANON_USER; use arc_swap::ArcSwapOption; use collab_plugins::local_storage::kv::doc::CollabKVAction; use collab_plugins::local_storage::kv::KVTransactionDB; @@ -13,10 +13,10 @@ use flowy_sqlite::DBConnection; use flowy_user_pub::entities::UserWorkspace; use flowy_user_pub::session::Session; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::{error, info}; - -const SQLITE_VACUUM_042: &str = "sqlite_vacuum_042_version"; +use tracing::info; +use uuid::Uuid; pub struct AuthenticateUser { pub user_config: UserConfig, @@ -42,40 +42,36 @@ impl AuthenticateUser { } } - pub fn vacuum_database_if_need(&self) { - if !self - .store_preferences - .get_bool_or_default(SQLITE_VACUUM_042) - { - if let Ok(session) = self.get_session() { - let _ = self.store_preferences.set_bool(SQLITE_VACUUM_042, true); - if let Ok(conn) = self.database.get_connection(session.user_id) { - info!("vacuum database 042"); - if let Err(err) = vacuum_database(conn) { - error!("vacuum database error: {:?}", err); - } - } - } - } - } - pub fn user_id(&self) -> FlowyResult { let session = self.get_session()?; Ok(session.user_id) } + pub async fn is_local_mode(&self) -> FlowyResult { + let uid = self.user_id()?; + if let Ok(anon_user) = self.get_anon_user().await { + if anon_user == uid { + return Ok(true); + } + } + + Ok(false) + } + pub fn device_id(&self) -> FlowyResult { Ok(self.user_config.device_id.to_string()) } - pub fn workspace_id(&self) -> FlowyResult { + pub fn workspace_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.id.clone()) + let workspace_uuid = Uuid::from_str(&session.user_workspace.id)?; + Ok(workspace_uuid) } - pub fn workspace_database_object_id(&self) -> FlowyResult { + pub fn workspace_database_object_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.workspace_database_id.clone()) + let id = Uuid::from_str(&session.user_workspace.workspace_database_id)?; + Ok(id) } pub fn get_collab_db(&self, uid: i64) -> FlowyResult> { @@ -89,9 +85,9 @@ impl AuthenticateUser { self.database.get_connection(uid) } - pub fn get_index_path(&self) -> PathBuf { - let uid = self.user_id().unwrap_or(0); - PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes") + pub fn get_index_path(&self) -> FlowyResult { + let uid = self.user_id()?; + Ok(PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes")) } pub fn get_user_data_dir(&self) -> FlowyResult { @@ -166,4 +162,16 @@ impl AuthenticateUser { }, } } + + async fn get_anon_user(&self) -> FlowyResult { + let anon_session = self + .store_preferences + .get_object::(ANON_USER) + .ok_or(FlowyError::new( + ErrorCode::RecordNotFound, + "Anon user not found", + ))?; + + Ok(anon_session.user_id) + } } diff --git a/frontend/rust-lib/flowy-user/src/services/billing_check.rs b/frontend/rust-lib/flowy-user/src/services/billing_check.rs index b0b123fc41..ea5bc65b75 100644 --- a/frontend/rust-lib/flowy-user/src/services/billing_check.rs +++ b/frontend/rust-lib/flowy-user/src/services/billing_check.rs @@ -4,6 +4,7 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_user_pub::cloud::UserCloudServiceProvider; use std::sync::Weak; use std::time::Duration; +use uuid::Uuid; /// `PeriodicallyCheckBillingState` is designed to periodically verify the subscription /// plan of a given workspace. It utilizes a cloud service provider to fetch the current @@ -13,7 +14,7 @@ use std::time::Duration; /// at specified intervals until the expected plan is found or the maximum number of /// attempts is reached. pub struct PeriodicallyCheckBillingState { - workspace_id: String, + workspace_id: Uuid, cloud_service: Weak, expected_plan: Option, user: Weak, @@ -21,7 +22,7 @@ pub struct PeriodicallyCheckBillingState { impl PeriodicallyCheckBillingState { pub fn new( - workspace_id: String, + workspace_id: Uuid, expected_plan: Option, cloud_service: Weak, user: Weak, @@ -46,7 +47,7 @@ impl PeriodicallyCheckBillingState { while attempts < max_attempts { let plans = cloud_service .get_user_service()? - .get_workspace_plan(self.workspace_id.clone()) + .get_workspace_plan(self.workspace_id) .await?; // If the expected plan is not set, return the plans immediately. Otherwise, diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 129b605281..a4c7d3bd1d 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -3,7 +3,6 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; use crate::services::data_import::importer::load_collab_by_object_ids; use crate::services::db::UserDBPath; use crate::services::entities::UserPaths; -use crate::services::sqlite_sql::user_sql::select_user_profile; use crate::user_manager::run_collab_data_migration; use anyhow::anyhow; use collab::core::collab::DataSource; @@ -30,19 +29,22 @@ use flowy_folder_pub::entities::{ }; use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; -use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; use flowy_user_pub::session::Session; use rayon::prelude::*; use std::collections::{HashMap, HashSet}; use collab_document::blocks::TextDelta; use collab_document::document::Document; +use flowy_user_pub::sql::select_user_profile; use semver::Version; use serde_json::json; use std::ops::{Deref, DerefMut}; use std::path::Path; use std::sync::{Arc, Weak}; use tracing::{error, event, info, instrument, warn}; +use uuid::Uuid; + pub(crate) struct ImportedFolder { pub imported_session: Session, pub imported_collab_db: Arc, @@ -101,7 +103,7 @@ pub(crate) fn prepare_import( ); let imported_user = select_user_profile( imported_session.user_id, - imported_sqlite_db.get_connection()?, + &mut *imported_sqlite_db.get_connection()?, )?; run_collab_data_migration( @@ -1172,8 +1174,8 @@ impl DerefMut for OldToNewIdMap { pub async fn upload_collab_objects_data( uid: i64, user_collab_db: Weak, - workspace_id: &str, - user_authenticator: &Authenticator, + workspace_id: &Uuid, + user_authenticator: &AuthType, collab_data: ImportedCollabData, user_cloud_service: Arc, ) -> Result<(), FlowyError> { @@ -1248,7 +1250,7 @@ pub async fn upload_collab_objects_data( objects.push(UserCollabParams { object_id: oid, encoded_collab, - collab_type: collab_type.clone(), + collab_type, }); size_counter += obj_size; } @@ -1275,7 +1277,7 @@ pub async fn upload_collab_objects_data( async fn batch_create( uid: i64, - workspace_id: &str, + workspace_id: &Uuid, user_cloud_service: &Arc, size_counter: &usize, objects: Vec, diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index e324c2820f..3280370c88 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -7,21 +7,13 @@ use collab_plugins::local_storage::kv::KVTransactionDB; use dashmap::mapref::entry::Entry; use dashmap::DashMap; use flowy_error::FlowyError; -use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::ConnectionPool; -use flowy_sqlite::{ - query_dsl::*, - schema::{user_table, user_table::dsl}, - DBConnection, Database, ExpressionMethods, -}; -use flowy_user_pub::entities::{UserProfile, UserWorkspace}; - +use flowy_sqlite::{DBConnection, Database}; +use flowy_user_pub::entities::UserProfile; +use flowy_user_pub::sql::select_user_profile; use lib_infra::file_util::{unzip_and_replace, zip_folder}; use tracing::{error, event, info, instrument}; -use crate::services::sqlite_sql::user_sql::UserTable; -use crate::services::sqlite_sql::workspace_sql::UserWorkspaceTable; - pub trait UserDBPath: Send + Sync + 'static { fn sqlite_db_path(&self, uid: i64) -> PathBuf; fn collab_db_path(&self, uid: i64) -> PathBuf; @@ -134,25 +126,9 @@ impl UserDB { pool: &Arc, uid: i64, ) -> Result { - let uid = uid.to_string(); let mut conn = pool.get()?; - let user = dsl::user_table - .filter(user_table::id.eq(&uid)) - .first::(&mut *conn)?; - - Ok(user.into()) - } - - pub fn get_user_workspace( - &self, - pool: &Arc, - uid: i64, - ) -> Result, FlowyError> { - let mut conn = pool.get()?; - let row = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(uid)) - .first::(&mut *conn)?; - Ok(Some(UserWorkspace::from(row))) + let profile = select_user_profile(uid, &mut conn)?; + Ok(profile) } /// Open a collab db for the user. If the db is already opened, return the opened db. diff --git a/frontend/rust-lib/flowy-user/src/services/mod.rs b/frontend/rust-lib/flowy-user/src/services/mod.rs index 66316fa01a..ab4b3bea37 100644 --- a/frontend/rust-lib/flowy-user/src/services/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/mod.rs @@ -5,4 +5,3 @@ pub mod collab_interact; pub mod data_import; pub mod db; pub mod entities; -pub mod sqlite_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs deleted file mode 100644 index 93e642f72e..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod member_sql; -pub(crate) mod user_sql; -pub(crate) mod workspace_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs deleted file mode 100644 index 8d5c1e8dc7..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ /dev/null @@ -1,131 +0,0 @@ -use chrono::{TimeZone, Utc}; -use diesel::{RunQueryDsl, SqliteConnection}; -use flowy_error::FlowyError; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::DBConnection; -use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_pub::entities::UserWorkspace; -use std::convert::TryFrom; - -#[derive(Clone, Default, Queryable, Identifiable, Insertable)] -#[diesel(table_name = user_workspace_table)] -pub struct UserWorkspaceTable { - pub id: String, - pub name: String, - pub uid: i64, - pub created_at: i64, - pub database_storage_id: String, - pub icon: String, - pub member_count: i64, - pub role: Option, -} - -pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option { - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(workspace_id)) - .first::(&mut *conn) - .ok() - .map(UserWorkspace::from) -} - -pub fn get_all_user_workspace_op( - user_id: i64, - mut conn: DBConnection, -) -> Result, FlowyError> { - let rows = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(user_id)) - .load::(&mut *conn)?; - Ok(rows.into_iter().map(UserWorkspace::from).collect()) -} - -/// Remove all existing workspaces for given user and insert the new ones. -/// -#[allow(dead_code)] -pub fn save_user_workspaces_op( - uid: i64, - mut conn: DBConnection, - user_workspaces: &[UserWorkspace], -) -> Result<(), FlowyError> { - conn.immediate_transaction(|conn| { - delete_existing_workspaces(uid, conn)?; - insert_or_update_workspaces_op(uid, user_workspaces, conn)?; - Ok(()) - }) -} - -#[allow(dead_code)] -fn delete_existing_workspaces(uid: i64, conn: &mut SqliteConnection) -> Result<(), FlowyError> { - diesel::delete( - user_workspace_table::dsl::user_workspace_table.filter(user_workspace_table::uid.eq(uid)), - ) - .execute(conn)?; - Ok(()) -} - -pub fn insert_or_update_workspaces_op( - uid: i64, - user_workspaces: &[UserWorkspace], - conn: &mut SqliteConnection, -) -> Result<(), FlowyError> { - for user_workspace in user_workspaces { - let new_record = UserWorkspaceTable::try_from((uid, user_workspace))?; - - diesel::insert_into(user_workspace_table::table) - .values(new_record.clone()) - .on_conflict(user_workspace_table::id) - .do_update() - .set(( - user_workspace_table::name.eq(new_record.name), - user_workspace_table::uid.eq(new_record.uid), - user_workspace_table::created_at.eq(new_record.created_at), - user_workspace_table::database_storage_id.eq(new_record.database_storage_id), - user_workspace_table::icon.eq(new_record.icon), - user_workspace_table::member_count.eq(new_record.member_count), - user_workspace_table::role.eq(new_record.role), - )) - .execute(conn)?; - } - - Ok(()) -} - -impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable { - type Error = FlowyError; - - fn try_from(value: (i64, &UserWorkspace)) -> Result { - if value.1.id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The id is empty")); - } - if value.1.workspace_database_id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); - } - - Ok(Self { - id: value.1.id.clone(), - name: value.1.name.clone(), - uid: value.0, - created_at: value.1.created_at.timestamp(), - database_storage_id: value.1.workspace_database_id.clone(), - icon: value.1.icon.clone(), - member_count: value.1.member_count, - role: value.1.role.clone().map(|v| v as i32), - }) - } -} - -impl From for UserWorkspace { - fn from(value: UserWorkspaceTable) -> Self { - Self { - id: value.id, - name: value.name, - created_at: Utc - .timestamp_opt(value.created_at, 0) - .single() - .unwrap_or_default(), - workspace_database_id: value.database_storage_id, - icon: value.icon, - member_count: value.member_count, - role: value.role.map(|v| v.into()), - } - } -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index f04c988f5c..3b568f976e 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -1,6 +1,7 @@ +use client_api::entity::GotrueTokenResponse; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; -use flowy_error::{internal_error, ErrorCode, FlowyResult}; +use flowy_error::FlowyResult; use arc_swap::ArcSwapOption; use collab::lock::RwLock; @@ -14,16 +15,15 @@ use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::cloud::{UserCloudServiceProvider, UserUpdate}; use flowy_user_pub::entities::*; use flowy_user_pub::workspace_service::UserWorkspaceService; +use lib_infra::box_any::BoxAny; use semver::Version; use serde_json::Value; use std::string::ToString; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Weak}; -use tokio::sync::Mutex; use tokio_stream::StreamExt; use tracing::{debug, error, event, info, instrument, warn}; - -use lib_infra::box_any::BoxAny; +use uuid::Uuid; use crate::entities::{AuthStateChangedPB, AuthStatePB, UserProfilePB, UserSettingPB}; use crate::event_map::{DefaultUserStatusCallback, UserStatusCallback}; @@ -38,27 +38,23 @@ use crate::services::authenticate_user::AuthenticateUser; use crate::services::cloud_config::get_cloud_config; use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; -use super::manager_user_workspace::save_user_workspace; +use crate::migrations::anon_user_workspace::AnonUserWorkspaceTableMigration; use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; -use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; -use crate::user_manager::manager_user_encryption::validate_encryption_sign; -use crate::user_manager::manager_user_workspace::save_all_user_workspaces; -use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; pub struct UserManager { - pub(crate) cloud_services: Arc, + pub(crate) cloud_service: Arc, pub(crate) store_preferences: Arc, pub(crate) user_awareness: Arc>>, pub(crate) user_status_callback: RwLock>, pub(crate) collab_builder: Weak, pub(crate) collab_interact: RwLock>, pub(crate) user_workspace_service: Arc, - auth_process: Mutex>, pub(crate) authenticate_user: Arc, refresh_user_profile_since: AtomicI64, - pub(crate) is_loading_awareness: Arc>, + pub(crate) is_loading_awareness: Arc>, } impl UserManager { @@ -74,13 +70,12 @@ impl UserManager { let refresh_user_profile_since = AtomicI64::new(0); let user_manager = Arc::new(Self { - cloud_services, + cloud_service: cloud_services, store_preferences, user_awareness: Default::default(), user_status_callback, collab_builder, collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)), - auth_process: Default::default(), authenticate_user, refresh_user_profile_since, user_workspace_service, @@ -88,7 +83,7 @@ impl UserManager { }); let weak_user_manager = Arc::downgrade(&user_manager); - if let Ok(user_service) = user_manager.cloud_services.get_user_service() { + if let Ok(user_service) = user_manager.cloud_service.get_user_service() { if let Some(mut rx) = user_service.subscribe_user_update() { tokio::spawn(async move { while let Some(update) = rx.recv().await { @@ -134,18 +129,19 @@ impl UserManager { if let Ok(session) = self.get_session() { let user = self.get_user_profile_from_disk(session.user_id).await?; + self.cloud_service.set_server_auth_type(&user.auth_type); // Get the current authenticator from the environment variable - let current_authenticator = current_authenticator(); + let env_auth_type = current_authenticator(); // If the current authenticator is different from the authenticator in the session and it's // not a local authenticator, we need to sign out the user. - if user.authenticator != Authenticator::Local && user.authenticator != current_authenticator { + if user.auth_type != AuthType::Local && user.auth_type != env_auth_type { event!( tracing::Level::INFO, - "Authenticator changed from {:?} to {:?}", - user.authenticator, - current_authenticator + "Auth type changed from {:?} to {:?}", + user.auth_type, + env_auth_type ); self.sign_out().await?; return Ok(()); @@ -153,10 +149,10 @@ impl UserManager { event!( tracing::Level::INFO, - "init user session: {}:{}, authenticator: {:?}", + "init user session: {}:{}, auth type: {:?}", user.uid, user.email, - user.authenticator, + user.auth_type, ); self.prepare_user(&session).await; @@ -165,21 +161,17 @@ impl UserManager { // Set the token if the current cloud service using token to authenticate // Currently, only the AppFlowy cloud using token to init the client api. // TODO(nathan): using trait to separate the init process for different cloud service - if user.authenticator.is_appflowy_cloud() { - if let Err(err) = self.cloud_services.set_token(&user.token) { + if user.auth_type.is_appflowy_cloud() { + if let Err(err) = self.cloud_service.set_token(&user.token) { error!("Set token failed: {}", err); } - if let Err(err) = self.cloud_services.set_ai_model(&user.ai_model) { - error!("Set ai model failed: {}", err); - } - // Subscribe the token state - let weak_cloud_services = Arc::downgrade(&self.cloud_services); + let weak_cloud_services = Arc::downgrade(&self.cloud_service); let weak_authenticate_user = Arc::downgrade(&self.authenticate_user); let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); let cloned_session = session.clone(); - if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { + if let Some(mut token_state_rx) = self.cloud_service.subscribe_token_state() { event!(tracing::Level::DEBUG, "Listen token state change"); let user_uid = user.uid; let local_token = user.token.clone(); @@ -267,24 +259,20 @@ impl UserManager { }, _ => error!("Failed to get collab db or sqlite pool"), } - self.authenticate_user.vacuum_database_if_need(); // migrations should run before set the first time installed version self.set_first_time_installed_version(); let cloud_config = get_cloud_config(session.user_id, &self.store_preferences); // Init the user awareness. here we ignore the error - let _ = self - .initial_user_awareness(&session, &user.authenticator) - .await; + let _ = self.initial_user_awareness(&session, &user.auth_type).await; user_status_callback - .did_init( + .on_launch_if_authenticated( user.uid, - &user.authenticator, &cloud_config, &session.user_workspace, &self.authenticate_user.user_config.device_id, - &user.authenticator, + &user.auth_type, ) .await?; } else { @@ -349,12 +337,12 @@ impl UserManager { pub async fn sign_in( &self, params: SignInParams, - authenticator: Authenticator, + auth_type: AuthType, ) -> Result { - self.cloud_services.set_user_authenticator(&authenticator); + self.cloud_service.set_server_auth_type(&auth_type); let response: AuthResponse = self - .cloud_services + .cloud_service .get_user_service()? .sign_in(BoxAny::new(params)) .await?; @@ -362,23 +350,21 @@ impl UserManager { self.prepare_user(&session).await; let latest_workspace = response.latest_workspace.clone(); - let user_profile = UserProfile::from((&response, &authenticator)); - self - .save_auth_data(&response, &authenticator, &session) - .await?; + let user_profile = UserProfile::from((&response, &auth_type)); + self.save_auth_data(&response, auth_type, &session).await?; let _ = self - .initial_user_awareness(&session, &user_profile.authenticator) + .initial_user_awareness(&session, &user_profile.auth_type) .await; self .user_status_callback .read() .await - .did_sign_in( + .on_sign_in( user_profile.uid, &latest_workspace, &self.authenticate_user.user_config.device_id, - &authenticator, + &auth_type, ) .await?; send_auth_state_notification(AuthStateChangedPB { @@ -398,51 +384,20 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self, params))] pub async fn sign_up( &self, - authenticator: Authenticator, + auth_type: AuthType, params: BoxAny, ) -> Result { + self.cloud_service.set_server_auth_type(&auth_type); + // sign out the current user if there is one - let migration_user = self.get_migration_user(&authenticator).await; - - self.cloud_services.set_user_authenticator(&authenticator); - let auth_service = self.cloud_services.get_user_service()?; + let migration_user = self.get_migration_user(&auth_type).await; + let auth_service = self.cloud_service.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; - let new_user_profile = UserProfile::from((&response, &authenticator)); - if new_user_profile.encryption_type.require_encrypt_secret() { - self.auth_process.lock().await.replace(UserAuthProcess { - user_profile: new_user_profile.clone(), - migration_user, - response, - authenticator, - }); - } else { - self - .continue_sign_up(&new_user_profile, migration_user, response, &authenticator) - .await?; - } - Ok(new_user_profile) - } - - #[tracing::instrument(level = "info", skip(self))] - pub async fn resume_sign_up(&self) -> Result<(), FlowyError> { - let UserAuthProcess { - user_profile, - migration_user, - response, - authenticator, - } = self - .auth_process - .lock() - .await - .clone() - .ok_or(FlowyError::new( - ErrorCode::Internal, - "No resumable sign up data", - ))?; + let new_user_profile = UserProfile::from((&response, &auth_type)); self - .continue_sign_up(&user_profile, migration_user, response, &authenticator) + .continue_sign_up(&new_user_profile, migration_user, response, &auth_type) .await?; - Ok(()) + Ok(new_user_profile) } #[tracing::instrument(level = "info", skip_all, err)] @@ -451,26 +406,24 @@ impl UserManager { new_user_profile: &UserProfile, migration_user: Option, response: AuthResponse, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { let new_session = Session::from(&response); self.prepare_user(&new_session).await; self - .save_auth_data(&response, authenticator, &new_session) + .save_auth_data(&response, *auth_type, &new_session) .await?; - let _ = self - .initial_user_awareness(&new_session, &new_user_profile.authenticator) - .await; + let _ = self.initial_user_awareness(&new_session, auth_type).await; self .user_status_callback .read() .await - .did_sign_up( + .on_sign_up( response.is_new_user, new_user_profile, &new_session.user_workspace, &self.authenticate_user.user_config.device_id, - authenticator, + auth_type, ) .await?; @@ -494,7 +447,7 @@ impl UserManager { new_user_profile.uid ); self - .migrate_anon_user_data_to_cloud(&old_user, &new_session, authenticator) + .migrate_anon_user_data_to_cloud(&old_user, &new_session, auth_type) .await?; self.remove_anon_user(); let _ = self @@ -515,7 +468,7 @@ impl UserManager { pub async fn sign_out(&self) -> Result<(), FlowyError> { if let Ok(session) = self.get_session() { sign_out( - &self.cloud_services, + &self.cloud_service, &session, &self.authenticate_user, self.db_connection(session.user_id)?, @@ -528,7 +481,7 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self))] pub async fn delete_account(&self) -> Result<(), FlowyError> { self - .cloud_services + .cloud_service .get_user_service()? .delete_account() .await?; @@ -553,11 +506,12 @@ impl UserManager { self.db_connection(session.user_id)?, changeset, )?; - - let profile = self.get_user_profile_from_disk(session.user_id).await?; self - .update_user(session.user_id, profile.token, params) + .cloud_service + .get_user_service()? + .update_user(params) .await?; + Ok(()) } @@ -581,15 +535,22 @@ impl UserManager { .backup(session.user_id, &session.user_workspace.id); } + pub async fn get_user_profile(&self) -> FlowyResult { + let uid = self.get_session()?.user_id; + let profile = self.get_user_profile_from_disk(uid).await?; + Ok(profile) + } + /// Fetches the user profile for the given user ID. pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result { - select_user_profile(uid, self.db_connection(uid)?) + let mut conn = self.db_connection(uid)?; + select_user_profile(uid, &mut conn) } #[tracing::instrument(level = "info", skip_all, err)] pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> { // If the user is a local user, no need to refresh the user profile - if old_user_profile.authenticator.is_local() { + if old_user_profile.auth_type.is_local() { return Ok(()); } @@ -602,16 +563,15 @@ impl UserManager { let uid = old_user_profile.uid; let result: Result = self - .cloud_services + .cloud_service .get_user_service()? - .get_user_profile(UserCredentials::from_uid(uid)) + .get_user_profile(uid) .await; match result { Ok(new_user_profile) => { // If the user profile is updated, save the new user profile if new_user_profile.updated_at > old_user_profile.updated_at { - validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); // Save the new user profile let changeset = UserTableChangeset::from_user_profile(new_user_profile); let _ = upsert_user_profile_change( @@ -670,79 +630,84 @@ impl UserManager { Ok(None) } - async fn update_user( - &self, - uid: i64, - token: String, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { - let server = self.cloud_services.get_user_service()?; - tokio::spawn(async move { - let credentials = UserCredentials::new(Some(token), Some(uid), None); - server.update_user(credentials, params).await - }) - .await - .map_err(internal_error)??; - Ok(()) - } - async fn save_user(&self, uid: i64, user: UserTable) -> Result<(), FlowyError> { - let mut conn = self.db_connection(uid)?; - conn.immediate_transaction(|conn| { - // delete old user if exists - diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) - .execute(conn)?; - - let _ = diesel::insert_into(user_table::table) - .values(user) - .execute(conn)?; - Ok::<(), FlowyError>(()) - })?; - + let conn = self.db_connection(uid)?; + upsert_user(user, conn)?; Ok(()) } pub async fn receive_realtime_event(&self, json: Value) { - if let Ok(user_service) = self.cloud_services.get_user_service() { + if let Ok(user_service) = self.cloud_service.get_user_service() { user_service.receive_realtime_event(json) } } + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_sign_in_url_with_email( &self, - authenticator: &Authenticator, + authenticator: &AuthType, email: &str, ) -> Result { - self.cloud_services.set_user_authenticator(authenticator); + self.cloud_service.set_server_auth_type(authenticator); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service.generate_sign_in_url_with_email(email).await?; Ok(url) } + #[instrument(level = "info", skip_all)] + pub(crate) async fn sign_in_with_password( + &self, + email: &str, + password: &str, + ) -> Result { + self + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; + let response = auth_service.sign_in_with_password(email, password).await?; + Ok(response) + } + + #[instrument(level = "info", skip_all)] pub(crate) async fn sign_in_with_magic_link( &self, email: &str, redirect_to: &str, ) -> Result<(), FlowyError> { self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; auth_service .sign_in_with_magic_link(email, redirect_to) .await?; Ok(()) } + #[instrument(level = "info", skip_all)] + pub(crate) async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + self + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; + let response = auth_service.sign_in_with_passcode(email, passcode).await?; + Ok(response) + } + + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_oauth_url( &self, oauth_provider: &str, ) -> Result { self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) .await?; @@ -753,27 +718,33 @@ impl UserManager { async fn save_auth_data( &self, response: &impl UserAuthResponse, - authenticator: &Authenticator, + auth_type: AuthType, session: &Session, ) -> Result<(), FlowyError> { - let user_profile = UserProfile::from((response, authenticator)); + let user_profile = UserProfile::from((response, &auth_type)); let uid = user_profile.uid; - if authenticator.is_local() { + + if auth_type.is_local() { event!(tracing::Level::DEBUG, "Save new anon user: {:?}", uid); self.set_anon_user(session); } - save_all_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; + delete_all_then_insert_user_workspaces( + uid, + self.db_connection(uid)?, + auth_type, + response.user_workspaces(), + )?; info!( "Save new user profile to disk, authenticator: {:?}", - authenticator + auth_type ); self .authenticate_user .set_session(Some(session.clone().into()))?; self - .save_user(uid, (user_profile, authenticator.clone()).into()) + .save_user(uid, (user_profile, auth_type).into()) .await?; Ok(()) } @@ -782,11 +753,6 @@ impl UserManager { let session = self.get_session()?; if session.user_id == user_update.uid { debug!("Receive user update: {:?}", user_update); - let user_profile = self.get_user_profile_from_disk(user_update.uid).await?; - if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) { - return Ok(()); - } - // Save the user profile change upsert_user_profile_change( user_update.uid, @@ -802,36 +768,38 @@ impl UserManager { &self, old_user: &AnonUser, _new_user_session: &Session, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> Result<(), FlowyError> { let old_collab_db = self .authenticate_user .database .get_collab_db(old_user.session.user_id)?; - if authenticator == &Authenticator::AppFlowyCloud { + if auth_type == &AuthType::AppFlowyCloud { self .migration_anon_user_on_appflowy_cloud_sign_up(old_user, &old_collab_db) .await?; } // Save the old user workspace setting. - save_user_workspace( + let mut conn = self + .authenticate_user + .database + .get_connection(old_user.session.user_id)?; + upsert_user_workspace( old_user.session.user_id, - self - .authenticate_user - .database - .get_connection(old_user.session.user_id)?, - &old_user.session.user_workspace.clone(), + *auth_type, + old_user.session.user_workspace.clone(), + &mut conn, )?; Ok(()) } } -fn current_authenticator() -> Authenticator { +fn current_authenticator() -> AuthType { match AuthenticatorType::from_env() { - AuthenticatorType::Local => Authenticator::Local, - AuthenticatorType::AppFlowyCloud => Authenticator::AppFlowyCloud, + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, } } @@ -845,7 +813,7 @@ pub fn upsert_user_profile_change( "Update user profile with changeset: {:?}", changeset ); - diesel_update_table!(user_table, changeset, &mut *conn); + update_user_profile(&mut conn, changeset)?; let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) .first::(&mut *conn)? @@ -879,6 +847,7 @@ fn collab_migration_list() -> Vec> { Box::new(FavoriteV1AndWorkspaceArrayMigration), Box::new(WorkspaceTrashMapToSectionMigration), Box::new(CollabDocKeyWithWorkspaceIdMigration), + Box::new(AnonUserWorkspaceTableMigration), ] } @@ -902,7 +871,7 @@ pub(crate) fn run_collab_data_migration( let migrations = collab_migration_list(); match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool, kv).run( migrations, - &user.authenticator, + &user.auth_type, app_version, ) { Ok(applied_migrations) => { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs index 8d20bae427..7c5330149e 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs @@ -4,18 +4,15 @@ use tracing::instrument; use crate::entities::UserProfilePB; use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::AnonUser; use flowy_user_pub::session::Session; -const ANON_USER: &str = "anon_user"; +pub const ANON_USER: &str = "anon_user"; impl UserManager { #[instrument(skip_all)] - pub async fn get_migration_user( - &self, - current_authenticator: &Authenticator, - ) -> Option { + pub async fn get_migration_user(&self, current_authenticator: &AuthType) -> Option { // No need to migrate if the user is already local if current_authenticator.is_local() { return None; @@ -27,7 +24,7 @@ impl UserManager { .await .ok()?; - if user_profile.authenticator.is_local() { + if user_profile.auth_type.is_local() { Some(AnonUser { session }) } else { None diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index d8c749ac0c..afdfd218ef 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -8,13 +8,13 @@ use collab_entity::CollabType; use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, }; +use collab_integrate::CollabKVDB; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; use dashmap::try_result::TryResult; -use tracing::{error, info, instrument, trace}; - -use collab_integrate::CollabKVDB; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; use crate::entities::ReminderPB; use crate::user_manager::UserManager; @@ -119,11 +119,9 @@ impl UserManager { pub(crate) async fn initial_user_awareness( &self, session: &Session, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { - let authenticator = authenticator.clone(); - let object_id = - user_awareness_object_id(&session.user_uuid, &session.user_workspace.id).to_string(); + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); // Try to acquire mutable access to `is_loading_awareness`. // Thread-safety is ensured by DashMap @@ -156,23 +154,21 @@ impl UserManager { let is_exist_on_disk = self .authenticate_user - .is_collab_on_disk(session.user_id, &object_id)?; - if authenticator.is_local() || is_exist_on_disk { + .is_collab_on_disk(session.user_id, &object_id.to_string())?; + if auth_type.is_local() || is_exist_on_disk { trace!( "Initializing new user awareness from disk:{}, {:?}", object_id, - authenticator + auth_type ); let collab_db = self.get_collab_db(session.user_id)?; - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - session.user_workspace.id.clone(), - ) - .into_data_source(); + let workspace_id = session.user_workspace.workspace_id()?; + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); let awareness = Self::collab_for_user_awareness( &self.collab_builder.clone(), - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -188,9 +184,9 @@ impl UserManager { } else { info!( "Initializing new user awareness from server:{}, {:?}", - object_id, authenticator + object_id, auth_type ); - self.load_awareness_from_server(session, object_id, authenticator.clone())?; + self.load_awareness_from_server(session, object_id, *auth_type)?; } } else { return Err(FlowyError::new( @@ -211,15 +207,15 @@ impl UserManager { fn load_awareness_from_server( &self, session: &Session, - object_id: String, - authenticator: Authenticator, + object_id: Uuid, + authenticator: AuthType, ) -> FlowyResult<()> { // Clone necessary data let session = session.clone(); let collab_db = self.get_collab_db(session.user_id)?; let weak_builder = self.collab_builder.clone(); let user_awareness = Arc::downgrade(&self.user_awareness); - let cloud_services = self.cloud_services.clone(); + let cloud_services = self.cloud_service.clone(); let authenticate_user = self.authenticate_user.clone(); let is_loading_awareness = self.is_loading_awareness.clone(); @@ -231,16 +227,14 @@ impl UserManager { } }; + let workspace_id = session.user_workspace.workspace_id()?; let create_awareness = if authenticator.is_local() { - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - session.user_workspace.id.clone(), - ) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -251,7 +245,7 @@ impl UserManager { } else { let result = cloud_services .get_user_service()? - .get_user_awareness_doc_state(session.user_id, &session.user_workspace.id, &object_id) + .get_user_awareness_doc_state(session.user_id, &workspace_id, &object_id) .await; match result { @@ -259,7 +253,7 @@ impl UserManager { trace!("Fetched user awareness collab from remote: {}", data.len()); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -271,15 +265,12 @@ impl UserManager { Err(err) => { if err.is_record_not_found() { info!("User awareness not found, creating new"); - let doc_state = CollabPersistenceImpl::new( - collab_db.clone(), - session.user_id, - session.user_workspace.id.clone(), - ) - .into_data_source(); + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); Self::collab_for_user_awareness( &weak_builder, - &session.user_workspace.id, + &workspace_id, session.user_id, &object_id, collab_db, @@ -329,9 +320,9 @@ impl UserManager { /// user awareness. async fn collab_for_user_awareness( collab_builder: &Weak, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_db: Weak, doc_state: DataSource, notifier: Option, @@ -375,8 +366,7 @@ impl UserManager { info!("User awareness is not loaded when trying to access it"); let session = self.get_session()?; - let object_id = - user_awareness_object_id(&session.user_uuid, &session.user_workspace.id).to_string(); + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); let is_loading = self .is_loading_awareness .get(&object_id) @@ -386,7 +376,7 @@ impl UserManager { if !is_loading { let user_profile = self.get_user_profile_from_disk(session.user_id).await?; self - .initial_user_awareness(&session, &user_profile.authenticator) + .initial_user_awareness(&session, &user_profile.auth_type) .await?; } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs index 2bfba3422e..1462d1f019 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs @@ -1,31 +1,9 @@ -use crate::entities::{AuthStateChangedPB, AuthStatePB}; -use crate::notification::send_auth_state_notification; use crate::services::cloud_config::get_encrypt_secret; use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::{ - EncryptionType, UpdateUserProfileParams, UserCredentials, UserProfile, -}; use lib_infra::encryption::{decrypt_text, encrypt_text}; impl UserManager { - pub async fn set_encrypt_secret( - &self, - uid: i64, - secret: String, - encryption_type: EncryptionType, - ) -> FlowyResult<()> { - let params = UpdateUserProfileParams::new(uid).with_encryption_type(encryption_type); - self - .cloud_services - .get_user_service()? - .update_user(UserCredentials::from_uid(uid), params.clone()) - .await?; - self.cloud_services.set_encrypt_secret(secret); - - Ok(()) - } - pub fn generate_encryption_sign(&self, uid: i64, encrypt_secret: &str) -> FlowyResult { let encrypt_sign = encrypt_text(uid.to_string(), encrypt_secret)?; Ok(encrypt_sign) @@ -63,16 +41,3 @@ impl UserManager { } } } - -pub(crate) fn validate_encryption_sign(user_profile: &UserProfile, encryption_sign: &str) -> bool { - // If the local user profile's encryption sign is not equal to the user update's encryption sign, - // which means the user enable encryption in another device, we should logout the current user. - let is_valid = user_profile.encryption_type.sign() == encryption_sign; - if !is_valid { - send_auth_state_notification(AuthStateChangedPB { - state: AuthStatePB::InvalidAuth, - message: "Encryption configuration was changed".to_string(), - }); - } - is_valid -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 31ad71ce74..f74cf45ef7 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -2,25 +2,12 @@ use chrono::{Duration, NaiveDateTime, Utc}; use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlanDetail}; use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; -use std::convert::TryFrom; +use std::str::FromStr; use std::sync::Arc; -use collab_entity::{CollabObject, CollabType}; -use collab_integrate::CollabKVDB; -use tracing::{error, info, instrument, trace, warn}; - -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; -use flowy_user_pub::entities::{ - Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, - WorkspaceMember, -}; - use crate::entities::{ - RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, - UpdateUserWorkspaceSettingPB, UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB, + RepeatedUserWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, + UpdateUserWorkspaceSettingPB, UserWorkspacePB, WorkspaceSettingsPB, WorkspaceSubscriptionInfoPB, }; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; @@ -28,16 +15,20 @@ use crate::services::billing_check::PeriodicallyCheckBillingState; use crate::services::data_import::{ generate_import_data, upload_collab_objects_data, ImportedFolder, ImportedSource, }; -use crate::services::sqlite_sql::member_sql::{ - select_workspace_member, upsert_workspace_member, WorkspaceMemberTable, + +use crate::user_manager::UserManager; +use collab_integrate::CollabKVDB; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; +use flowy_sqlite::ConnectionPool; +use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; +use flowy_user_pub::entities::{ + AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; -use crate::services::sqlite_sql::user_sql::UserTableChangeset; -use crate::services::sqlite_sql::workspace_sql::{ - get_all_user_workspace_op, get_user_workspace_op, insert_or_update_workspaces_op, - UserWorkspaceTable, -}; -use crate::user_manager::{upsert_user_profile_change, UserManager}; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; impl UserManager { /// Import appflowy data from the given path. @@ -119,12 +110,12 @@ impl UserManager { let user_id = current_session.user_id; let weak_user_collab_db = Arc::downgrade(&user_collab_db); - let weak_user_cloud_service = self.cloud_services.get_user_service()?; + let weak_user_cloud_service = self.cloud_service.get_user_service()?; match upload_collab_objects_data( user_id, weak_user_collab_db, - ¤t_session.user_workspace.id, - &user.authenticator, + ¤t_session.user_workspace.workspace_id()?, + &user.auth_type, collab_data, weak_user_cloud_service, ) @@ -161,13 +152,38 @@ impl UserManager { } #[instrument(skip(self), err)] - pub async fn open_workspace(&self, workspace_id: &str) -> FlowyResult<()> { - info!("open workspace: {}", workspace_id); - let user_workspace = self - .cloud_services - .get_user_service()? - .open_workspace(workspace_id) - .await?; + pub async fn open_workspace(&self, workspace_id: &Uuid, auth_type: AuthType) -> FlowyResult<()> { + info!("open workspace: {}, auth_type:{}", workspace_id, auth_type); + self.cloud_service.set_server_auth_type(&auth_type); + + let uid = self.user_id()?; + let mut conn = self.db_connection(self.user_id()?)?; + let user_workspace = match select_user_workspace(&workspace_id.to_string(), &mut conn) { + Err(err) => { + if err.is_record_not_found() { + sync_workspace( + workspace_id, + self.cloud_service.get_user_service()?, + uid, + auth_type, + self.db_pool(uid)?, + ) + .await? + } else { + return Err(err); + } + }, + Ok(row) => { + let user_workspace = UserWorkspace::from(row); + let workspace_id = *workspace_id; + let user_service = self.cloud_service.get_user_service()?; + let pool = self.db_pool(uid)?; + tokio::spawn(async move { + let _ = sync_workspace(&workspace_id, user_service, uid, auth_type, pool).await; + }); + user_workspace + }, + }; self .authenticate_user @@ -175,9 +191,18 @@ impl UserManager { let uid = self.user_id()?; let user_profile = self.get_user_profile_from_disk(uid).await?; + if let Err(err) = self + .user_status_callback + .read() + .await + .on_workspace_opened(uid, workspace_id, &user_workspace, &user_profile.auth_type) + .await + { + error!("Open workspace failed: {:?}", err); + } if let Err(err) = self - .initial_user_awareness(self.get_session()?.as_ref(), &user_profile.authenticator) + .initial_user_awareness(self.get_session()?.as_ref(), &user_profile.auth_type) .await { error!( @@ -186,74 +211,59 @@ impl UserManager { ); } - if let Err(err) = self - .user_status_callback - .read() - .await - .open_workspace(uid, &user_workspace, &user_profile.authenticator) - .await - { - error!("Open workspace failed: {:?}", err); - } - Ok(()) } #[instrument(level = "info", skip(self), err)] - pub async fn add_workspace(&self, workspace_name: &str) -> FlowyResult { - let new_workspace = self - .cloud_services - .get_user_service()? - .create_workspace(workspace_name) - .await?; + pub async fn create_workspace( + &self, + workspace_name: &str, + auth_type: AuthType, + ) -> FlowyResult { + let new_workspace = match auth_type { + AuthType::Local => { + let workspace_id = Uuid::new_v4(); + UserWorkspace::new_local(workspace_id.to_string(), workspace_name) + }, + AuthType::AppFlowyCloud => { + self + .cloud_service + .get_user_service()? + .create_workspace(workspace_name) + .await? + }, + }; info!( - "new workspace: {}, name:{}", - new_workspace.id, new_workspace.name + "create workspace: {}, name:{}, auth_type: {}", + new_workspace.id, new_workspace.name, auth_type ); // save the workspace to sqlite db let uid = self.user_id()?; let mut conn = self.db_connection(uid)?; - insert_or_update_workspaces_op(uid, &[new_workspace.clone()], &mut conn)?; + upsert_user_workspace(uid, auth_type, new_workspace.clone(), &mut conn)?; Ok(new_workspace) } pub async fn patch_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + workspace_id: &Uuid, + changeset: UserWorkspaceChangeset, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? - .patch_workspace(workspace_id, new_workspace_name, new_workspace_icon) + .patch_workspace(workspace_id, changeset.name.clone(), changeset.icon.clone()) .await?; // save the icon and name to sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - let mut user_workspace = match self.get_user_workspace(uid, workspace_id) { - Some(user_workspace) => user_workspace, - None => { - return Err(FlowyError::record_not_found().with_context(format!( - "Expected to find user workspace with id: {}, but not found", - workspace_id - ))); - }, - }; + update_user_workspace(conn, changeset)?; - if let Some(new_workspace_name) = new_workspace_name { - user_workspace.name = new_workspace_name.to_string(); - } - if let Some(new_workspace_icon) = new_workspace_icon { - user_workspace.icon = new_workspace_icon.to_string(); - } - - let _ = save_user_workspace(uid, conn, &user_workspace); - - let payload: UserWorkspacePB = user_workspace.clone().into(); + let row = self.get_user_workspace_from_db(uid, workspace_id)?; + let payload = UserWorkspacePB::from(row); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) .payload(payload) .send(); @@ -262,10 +272,10 @@ impl UserManager { } #[instrument(level = "info", skip(self), err)] - pub async fn leave_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn leave_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("leave workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .leave_workspace(workspace_id) .await?; @@ -273,40 +283,42 @@ impl UserManager { // delete workspace from local sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id)?; + delete_user_workspace(conn, workspace_id.to_string().as_str())?; self .user_workspace_service - .did_delete_workspace(workspace_id.to_string()) + .did_delete_workspace(workspace_id) + .await } #[instrument(level = "info", skip(self), err)] - pub async fn delete_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("delete workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .delete_workspace(workspace_id) .await?; let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id)?; + delete_user_workspace(conn, workspace_id.to_string().as_str())?; self .user_workspace_service - .did_delete_workspace(workspace_id.to_string())?; + .did_delete_workspace(workspace_id) + .await?; Ok(()) } pub async fn invite_member_to_workspace( &self, - workspace_id: String, + workspace_id: Uuid, invitee_email: String, role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .invite_workspace_member(invitee_email, workspace_id, role) .await?; @@ -316,7 +328,7 @@ impl UserManager { pub async fn list_pending_workspace_invitations(&self) -> FlowyResult> { let status = Some(WorkspaceInvitationStatus::Pending); let invitations = self - .cloud_services + .cloud_service .get_user_service()? .list_workspace_invitations(status) .await?; @@ -325,7 +337,7 @@ impl UserManager { pub async fn accept_workspace_invitation(&self, invite_id: String) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .accept_workspace_invitations(invite_id) .await?; @@ -335,10 +347,10 @@ impl UserManager { pub async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .remove_workspace_member(user_email, workspace_id) .await?; @@ -347,10 +359,10 @@ impl UserManager { pub async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult> { let members = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_members(workspace_id) .await?; @@ -359,13 +371,13 @@ impl UserManager { pub async fn get_workspace_member( &self, - workspace_id: String, + workspace_id: Uuid, uid: i64, ) -> FlowyResult { let member = self - .cloud_services + .cloud_service .get_user_service()? - .get_workspace_member(workspace_id, uid) + .get_workspace_member(&workspace_id, uid) .await?; Ok(member) } @@ -373,33 +385,43 @@ impl UserManager { pub async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .update_workspace_member(user_email, workspace_id, role) .await?; Ok(()) } - pub fn get_user_workspace(&self, uid: i64, workspace_id: &str) -> Option { - let conn = self.db_connection(uid).ok()?; - get_user_workspace_op(workspace_id, conn) + pub fn get_user_workspace_from_db( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> FlowyResult { + let mut conn = self.db_connection(uid)?; + select_user_workspace(workspace_id.to_string().as_str(), &mut conn) } - pub async fn get_all_user_workspaces(&self, uid: i64) -> FlowyResult> { + pub async fn get_all_user_workspaces( + &self, + uid: i64, + auth_type: AuthType, + ) -> FlowyResult> { let conn = self.db_connection(uid)?; - let workspaces = get_all_user_workspace_op(uid, conn)?; + let workspaces = select_all_user_workspace(uid, conn)?; - if let Ok(service) = self.cloud_services.get_user_service() { + if let Ok(service) = self.cloud_service.get_user_service() { if let Ok(pool) = self.db_pool(uid) { tokio::spawn(async move { if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { if let Ok(conn) = pool.get() { - let _ = save_all_user_workspaces(uid, conn, &new_user_workspaces); - let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); + let _ = + delete_all_then_insert_user_workspaces(uid, conn, auth_type, &new_user_workspaces); + let repeated_workspace_pbs = + RepeatedUserWorkspacePB::from((auth_type, new_user_workspaces)); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) .payload(repeated_workspace_pbs) .send(); @@ -411,34 +433,17 @@ impl UserManager { Ok(workspaces) } - /// Reset the remote workspace using local workspace data. This is useful when a user wishes to - /// open a workspace on a new device that hasn't fully synchronized with the server. - pub async fn reset_workspace(&self, reset: ResetWorkspacePB) -> FlowyResult<()> { - let collab_object = CollabObject::new( - reset.uid, - reset.workspace_id.clone(), - CollabType::Folder, - reset.workspace_id.clone(), - self.authenticate_user.user_config.device_id.clone(), - ); - self - .cloud_services - .get_user_service()? - .reset_workspace(collab_object) - .await?; - Ok(()) - } - #[instrument(level = "info", skip(self), err)] pub async fn subscribe_workspace( &self, workspace_subscription: SubscribeWorkspacePB, ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_subscription.workspace_id)?; let payment_link = self - .cloud_services + .cloud_service .get_user_service()? .subscribe_workspace( - workspace_subscription.workspace_id, + workspace_id, workspace_subscription.recurring_interval.into(), workspace_subscription.workspace_subscription_plan.into(), workspace_subscription.success_url, @@ -453,10 +458,11 @@ impl UserManager { &self, workspace_id: String, ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_id)?; let subscriptions = self - .cloud_services + .cloud_service .get_user_service()? - .get_workspace_subscription_one(workspace_id.clone()) + .get_workspace_subscription_one(&workspace_id) .await?; Ok(WorkspaceSubscriptionInfoPB::from(subscriptions)) @@ -470,7 +476,7 @@ impl UserManager { reason: Option, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .cancel_workspace_subscription(workspace_id, plan, reason) .await?; @@ -480,12 +486,12 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn update_workspace_subscription_payment_period( &self, - workspace_id: String, + workspace_id: &Uuid, plan: SubscriptionPlan, recurring_interval: RecurringInterval, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .update_workspace_subscription_payment_period(workspace_id, plan, recurring_interval) .await?; @@ -495,7 +501,7 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_subscription_plan_details(&self) -> FlowyResult> { let plan_details = self - .cloud_services + .cloud_service .get_user_service()? .get_subscription_plan_details() .await?; @@ -505,10 +511,10 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_workspace_usage( &self, - workspace_id: String, + workspace_id: &Uuid, ) -> FlowyResult { let workspace_usage = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_usage(workspace_id) .await?; @@ -526,7 +532,7 @@ impl UserManager { .user_status_callback .read() .await - .did_update_storage_limitation(can_write); + .on_storage_permission_updated(can_write); Ok(workspace_usage) } @@ -534,7 +540,7 @@ impl UserManager { #[instrument(level = "info", skip(self), err)] pub async fn get_billing_portal_url(&self) -> FlowyResult { let url = self - .cloud_services + .cloud_service .get_user_service()? .get_billing_portal_url() .await?; @@ -545,49 +551,83 @@ impl UserManager { &self, updated_settings: UpdateUserWorkspaceSettingPB, ) -> FlowyResult<()> { - let ai_model = updated_settings.ai_model.clone(); - let workspace_id = updated_settings.workspace_id.clone(); - let cloud_service = self.cloud_services.get_user_service()?; + let workspace_id = Uuid::from_str(&updated_settings.workspace_id)?; + let cloud_service = self.cloud_service.get_user_service()?; let settings = cloud_service - .update_workspace_setting(&workspace_id, updated_settings.into()) + .update_workspace_setting(&workspace_id, updated_settings.clone().into()) .await?; - let pb = UseAISettingPB::from(settings); + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: updated_settings.disable_search_indexing, + ai_model: updated_settings.ai_model.clone(), + }; + let uid = self.user_id()?; - send_notification(&uid.to_string(), UserNotification::DidUpdateAISetting) - .payload(pb) - .send(); + let mut conn = self.db_connection(uid)?; + update_workspace_setting(&mut conn, changeset)?; - if let Some(ai_model) = &ai_model { - if let Err(err) = self.cloud_services.set_ai_model(ai_model.to_str()) { - error!("Set ai model failed: {}", err); - } - - let conn = self.db_connection(uid)?; - let params = UpdateUserProfileParams::new(uid).with_ai_model(ai_model.to_str()); - upsert_user_profile_change(uid, conn, UserTableChangeset::new(params))?; - } + let pb = WorkspaceSettingsPB::from(&settings); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(pb) + .send(); Ok(()) } - pub async fn get_workspace_settings(&self, workspace_id: &str) -> FlowyResult { - let cloud_service = self.cloud_services.get_user_service()?; - let settings = cloud_service.get_workspace_setting(workspace_id).await?; + pub async fn get_workspace_settings( + &self, + workspace_id: &Uuid, + ) -> FlowyResult { let uid = self.user_id()?; - let conn = self.db_connection(uid)?; - let params = UpdateUserProfileParams::new(uid).with_ai_model(&settings.ai_model); - upsert_user_profile_change(uid, conn, UserTableChangeset::new(params))?; - Ok(UseAISettingPB::from(settings)) + let mut conn = self.db_connection(uid)?; + match select_workspace_setting(&mut conn, &workspace_id.to_string()) { + Ok(workspace_settings) => { + trace!("workspace settings found in local db"); + let pb = WorkspaceSettingsPB::from(workspace_settings); + let old_pb = pb.clone(); + let workspace_id = *workspace_id; + + // Spawn a task to sync remote settings using the helper + let pool = self.db_pool(uid)?; + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + let _ = sync_workspace_settings(cloud_service, workspace_id, old_pb, uid, pool).await; + }); + Ok(pb) + }, + Err(err) => { + if err.is_record_not_found() { + trace!("No workspace settings found, fetch from remote"); + let service = self.cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(workspace_id).await?; + let pb = WorkspaceSettingsPB::from(&settings); + let mut conn = self.db_connection(uid)?; + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(workspace_id, &settings), + )?; + Ok(pb) + } else { + Err(err) + } + }, + } } - pub async fn get_workspace_member_info(&self, uid: i64) -> FlowyResult { - let workspace_id = self.get_session()?.user_workspace.id.clone(); + pub async fn get_workspace_member_info( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> FlowyResult { let db = self.authenticate_user.get_sqlite_connection(uid)?; // Can opt in using memory cache - if let Ok(member_record) = select_workspace_member(db, &workspace_id, uid) { + if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { if is_older_than_n_minutes(member_record.updated_at, 10) { self - .get_workspace_member_info_from_remote(&workspace_id, uid) + .get_workspace_member_info_from_remote(workspace_id, uid) .await?; } @@ -600,7 +640,7 @@ impl UserManager { } let member = self - .get_workspace_member_info_from_remote(&workspace_id, uid) + .get_workspace_member_info_from_remote(workspace_id, uid) .await?; Ok(member) @@ -608,14 +648,14 @@ impl UserManager { async fn get_workspace_member_info_from_remote( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, ) -> FlowyResult { trace!("get workspace member info from remote: {}", workspace_id); let member = self - .cloud_services + .cloud_service .get_user_service()? - .get_workspace_member_info(workspace_id, uid) + .get_workspace_member(workspace_id, uid) .await?; let record = WorkspaceMemberTable { @@ -628,8 +668,8 @@ impl UserManager { updated_at: Utc::now().naive_utc(), }; - let db = self.authenticate_user.get_sqlite_connection(uid)?; - upsert_workspace_member(db, record)?; + let mut db = self.authenticate_user.get_sqlite_connection(uid)?; + upsert_workspace_member(&mut db, record)?; Ok(member) } @@ -638,10 +678,11 @@ impl UserManager { success: SuccessWorkspaceSubscriptionPB, ) -> FlowyResult<()> { // periodically check the billing state + let workspace_id = Uuid::from_str(&success.workspace_id)?; let plans = PeriodicallyCheckBillingState::new( - success.workspace_id, + workspace_id, success.plan.map(SubscriptionPlan::from), - Arc::downgrade(&self.cloud_services), + Arc::downgrade(&self.cloud_service), Arc::downgrade(&self.authenticate_user), ) .start() @@ -652,122 +693,11 @@ impl UserManager { .user_status_callback .read() .await - .did_update_plans(plans); + .on_subscription_plans_updated(plans); Ok(()) } } -/// This method is used to save one user workspace to the SQLite database -/// -/// If the workspace is already persisted in the database, it will be overridden. -/// -/// Consider using [save_all_user_workspaces] if you need to override all workspaces of the user. -/// -pub fn save_user_workspace( - uid: i64, - mut conn: DBConnection, - user_workspace: &UserWorkspace, -) -> FlowyResult<()> { - conn.immediate_transaction(|conn| { - let user_workspace = UserWorkspaceTable::try_from((uid, user_workspace))?; - let affected_rows = diesel::update( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(&user_workspace.id)), - ) - .set(( - user_workspace_table::name.eq(&user_workspace.name), - user_workspace_table::created_at.eq(&user_workspace.created_at), - user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), - user_workspace_table::icon.eq(&user_workspace.icon), - user_workspace_table::member_count.eq(&user_workspace.member_count), - )) - .execute(conn)?; - - if affected_rows == 0 { - diesel::insert_into(user_workspace_table::table) - .values(user_workspace) - .execute(conn)?; - } - - Ok::<(), FlowyError>(()) - }) -} - -/// This method is used to save the user workspaces (plural) to the SQLite database -/// -/// The workspaces provided in [user_workspaces] will override the existing workspaces in the database. -/// -/// Consider using [save_user_workspace] if you only need to save a single workspace. -/// -pub fn save_all_user_workspaces( - uid: i64, - mut conn: DBConnection, - user_workspaces: &[UserWorkspace], -) -> FlowyResult<()> { - let user_workspaces = user_workspaces - .iter() - .map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace))) - .collect::, _>>()?; - - conn.immediate_transaction(|conn| { - let existing_ids = user_workspace_table::dsl::user_workspace_table - .select(user_workspace_table::id) - .load::(conn)?; - let new_ids: Vec = user_workspaces.iter().map(|w| w.id.clone()).collect(); - let ids_to_delete: Vec = existing_ids - .into_iter() - .filter(|id| !new_ids.contains(id)) - .collect(); - - // insert or update the user workspaces - for user_workspace in &user_workspaces { - let affected_rows = diesel::update( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(&user_workspace.id)), - ) - .set(( - user_workspace_table::name.eq(&user_workspace.name), - user_workspace_table::created_at.eq(&user_workspace.created_at), - user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), - user_workspace_table::icon.eq(&user_workspace.icon), - user_workspace_table::member_count.eq(&user_workspace.member_count), - user_workspace_table::role.eq(&user_workspace.role), - )) - .execute(conn)?; - - if affected_rows == 0 { - diesel::insert_into(user_workspace_table::table) - .values(user_workspace) - .execute(conn)?; - } - } - - // delete the user workspaces that are not in the new list - if !ids_to_delete.is_empty() { - diesel::delete( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq_any(ids_to_delete)), - ) - .execute(conn)?; - } - - Ok::<(), FlowyError>(()) - }) -} - -pub fn delete_user_workspaces(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { - let n = conn.immediate_transaction(|conn| { - let rows_affected: usize = - diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) - .execute(conn)?; - Ok::(rows_affected) - })?; - if n != 1 { - warn!("expected to delete 1 row, but deleted {} rows", n); - } - Ok(()) -} - fn is_older_than_n_minutes(updated_at: NaiveDateTime, minutes: i64) -> bool { let current_time: NaiveDateTime = Utc::now().naive_utc(); match current_time.checked_sub_signed(Duration::minutes(minutes)) { @@ -775,3 +705,45 @@ fn is_older_than_n_minutes(updated_at: NaiveDateTime, minutes: i64) -> bool { None => false, } } + +async fn sync_workspace_settings( + cloud_service: Arc, + workspace_id: Uuid, + old_pb: WorkspaceSettingsPB, + uid: i64, + pool: Arc, +) -> FlowyResult<()> { + let service = cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(&workspace_id).await?; + let new_pb = WorkspaceSettingsPB::from(&settings); + if new_pb != old_pb { + trace!("workspace settings updated"); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(new_pb) + .send(); + if let Ok(mut conn) = pool.get() { + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + )?; + } + } + Ok(()) +} + +async fn sync_workspace( + workspace_id: &Uuid, + user_service: Arc, + uid: i64, + auth_type: AuthType, + pool: Arc, +) -> FlowyResult { + let user_workspace = user_service.open_workspace(workspace_id).await?; + if let Ok(mut conn) = pool.get() { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), &mut conn)?; + } + Ok(user_workspace) +} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs index 3ce66227c5..23c050c1f2 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs @@ -3,6 +3,5 @@ pub(crate) mod manager_history_user; pub(crate) mod manager_user_awareness; pub(crate) mod manager_user_encryption; pub(crate) mod manager_user_workspace; -mod user_login_state; pub use manager::*; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs b/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs deleted file mode 100644 index 906002ad10..0000000000 --- a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::migrations::AnonUser; -use flowy_user_pub::entities::{AuthResponse, Authenticator, UserProfile}; - -/// recording the intermediate state of the sign-in/sign-up process -#[derive(Clone)] -pub struct UserAuthProcess { - pub user_profile: UserProfile, - pub response: AuthResponse, - pub authenticator: Authenticator, - pub migration_user: Option, -} diff --git a/frontend/rust-lib/lib-infra/Cargo.toml b/frontend/rust-lib/lib-infra/Cargo.toml index 12c805862b..a07a3413f4 100644 --- a/frontend/rust-lib/lib-infra/Cargo.toml +++ b/frontend/rust-lib/lib-infra/Cargo.toml @@ -22,7 +22,7 @@ validator = { workspace = true, features = ["derive"] } tracing.workspace = true atomic_refcell = "0.1" allo-isolate = { version = "^0.1", features = ["catch-unwind"], optional = true } -futures = "0.3.30" +futures = "0.3.31" cfg-if = "1.0.0" futures-util = "0.3.30" @@ -36,7 +36,7 @@ base64 = { version = "0.22.1" } [dev-dependencies] rand = "0.8.5" -futures = "0.3.30" +futures = "0.3.31" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] zip = { version = "2.2.0", features = ["deflate"] } diff --git a/frontend/rust-lib/lib-infra/src/isolate_stream.rs b/frontend/rust-lib/lib-infra/src/isolate_stream.rs index 358214e985..cebc2b7d10 100644 --- a/frontend/rust-lib/lib-infra/src/isolate_stream.rs +++ b/frontend/rust-lib/lib-infra/src/isolate_stream.rs @@ -7,6 +7,7 @@ use std::pin::Pin; use std::task::{Context, Poll}; #[pin_project] +#[derive(Clone, Debug)] pub struct IsolateSink { isolate: Isolate, } diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.sh b/frontend/scripts/code_generation/freezed/generate_freezed.sh index 45de7573d7..216a01b232 100755 --- a/frontend/scripts/code_generation/freezed/generate_freezed.sh +++ b/frontend/scripts/code_generation/freezed/generate_freezed.sh @@ -66,9 +66,9 @@ if [ "$exclude_packages" = false ]; then fi fi if [ "$verbose" = true ]; then - dart run build_runner build + dart run build_runner build --delete-conflicting-outputs else - dart run build_runner build >/dev/null 2>&1 + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 fi echo "🧊 Done generating freezed files ($d)." fi @@ -108,9 +108,9 @@ fi # Start the build_runner in the background if [ "$verbose" = true ]; then - dart run build_runner build -d & + dart run build_runner build --delete-conflicting-outputs & else - dart run build_runner build -d >/dev/null 2>&1 & + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 & fi # Get the PID of the background process diff --git a/frontend/scripts/code_generation/generate.sh b/frontend/scripts/code_generation/generate.sh index e08bc873fd..fd2edab785 100755 --- a/frontend/scripts/code_generation/generate.sh +++ b/frontend/scripts/code_generation/generate.sh @@ -64,7 +64,7 @@ cd .. cd freezed # Allow execution permissions on CI chmod +x ./generate_freezed.sh -./generate_freezed.sh "${args[@]}" --show-loading +./generate_freezed.sh "${args[@]}" --show-loading --verbose # Return to the original directory cd "$original_dir" diff --git a/frontend/scripts/docker-buildfiles/Dockerfile b/frontend/scripts/docker-buildfiles/Dockerfile index 960c1ba625..9e422f80ca 100644 --- a/frontend/scripts/docker-buildfiles/Dockerfile +++ b/frontend/scripts/docker-buildfiles/Dockerfile @@ -29,7 +29,7 @@ RUN source ~/.cargo/env && \ RUN sudo pacman -S --noconfirm git tar gtk3 RUN curl -sSfL \ --output flutter.tar.xz \ - https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.22.0-stable.tar.xz && \ + https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.27.4-stable.tar.xz && \ tar -xf flutter.tar.xz && \ rm flutter.tar.xz RUN flutter config --enable-linux-desktop diff --git a/frontend/scripts/install_dev_env/install_ios.sh b/frontend/scripts/install_dev_env/install_ios.sh index 004fa6fc0b..1c31696f39 100644 --- a/frontend/scripts/install_dev_env/install_ios.sh +++ b/frontend/scripts/install_dev_env/install_ios.sh @@ -44,9 +44,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.22.0 -if [ "$FLUTTER_VERSION" = "3.22.0" ]; then - echo "Flutter version is already 3.22.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -55,12 +55,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.22.0 of Flutter - git checkout 3.22.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.22.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop @@ -85,12 +85,16 @@ cd frontend || exit 1 # Install cargo make printMessage "Installing cargo-make." -cargo install --force cargo-make +cargo install --force --locked cargo-make # Install duckscript printMessage "Installing duckscript." cargo install --force --locked duckscript_cli +# Install cargo-lipo +printMessage "Installing cargo-lipo." +cargo install --force --locked cargo-lipo + # Check prerequisites printMessage "Checking prerequisites." cargo make appflowy-flutter-deps-tools diff --git a/frontend/scripts/install_dev_env/install_linux.sh b/frontend/scripts/install_dev_env/install_linux.sh index 1028605da9..57db01a73d 100755 --- a/frontend/scripts/install_dev_env/install_linux.sh +++ b/frontend/scripts/install_dev_env/install_linux.sh @@ -38,9 +38,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.22.0 -if [ "$FLUTTER_VERSION" = "3.22.0" ]; then - echo "Flutter version is already 3.22.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -49,12 +49,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.22.0 of Flutter - git checkout 3.22.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.22.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop diff --git a/frontend/scripts/install_dev_env/install_macos.sh b/frontend/scripts/install_dev_env/install_macos.sh index 27ac7ad9d1..463071e2af 100755 --- a/frontend/scripts/install_dev_env/install_macos.sh +++ b/frontend/scripts/install_dev_env/install_macos.sh @@ -41,9 +41,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.22.0 -if [ "$FLUTTER_VERSION" = "3.22.0" ]; then - echo "Flutter version is already 3.22.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -52,12 +52,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.22.0 of Flutter - git checkout 3.22.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.22.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop diff --git a/frontend/scripts/install_dev_env/install_windows.sh b/frontend/scripts/install_dev_env/install_windows.sh index 9ff586668c..3cceec5bb0 100644 --- a/frontend/scripts/install_dev_env/install_windows.sh +++ b/frontend/scripts/install_dev_env/install_windows.sh @@ -48,9 +48,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.22.0 -if [ "$FLUTTER_VERSION" = "3.22.0" ]; then - echo "Flutter version is already 3.22.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -59,12 +59,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.22.0 of Flutter - git checkout 3.22.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.22.0" + echo "Switched to Flutter version 3.27.4" fi # Add pub cache and cargo to PATH diff --git a/frontend/scripts/tool/update_local_ai_rev.sh b/frontend/scripts/tool/update_local_ai_rev.sh index 83c5e67d80..af24e0ba9f 100755 --- a/frontend/scripts/tool/update_local_ai_rev.sh +++ b/frontend/scripts/tool/update_local_ai_rev.sh @@ -15,7 +15,7 @@ for dir in "${directories[@]}"; do pushd "$dir" > /dev/null # Define the crates to update - crates=("appflowy-local-ai" "appflowy-plugin") + crates=("af-local-ai" "af-plugin" "af-mcp") for crate in "${crates[@]}"; do sed -i.bak "/^${crate}[[:alnum:]-]*[[:space:]]*=/s/rev = \"[a-fA-F0-9]\{6,40\}\"/rev = \"$NEW_REV\"/g" Cargo.toml diff --git a/frontend/scripts/white_label/code_white_label.sh b/frontend/scripts/white_label/code_white_label.sh new file mode 100644 index 0000000000..1123a394ee --- /dev/null +++ b/frontend/scripts/white_label/code_white_label.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +CODE_FILE="appflowy_flutter/lib/workspace/application/notification/notification_service.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -f "$CODE_FILE" ]; then + echo "Error: Code file not found at $CODE_FILE" + exit 1 +fi + +echo "Replacing '_localNotifierAppName' value with '$CUSTOM_COMPANY_NAME' in code file..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing code file..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace the _localNotifierAppName value with the custom company name + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$ESCAPED_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +else + # For Unix-like systems + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$CUSTOM_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/font_white_label.sh b/frontend/scripts/white_label/font_white_label.sh new file mode 100644 index 0000000000..412ee6b062 --- /dev/null +++ b/frontend/scripts/white_label/font_white_label.sh @@ -0,0 +1,198 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --font-path \"/path/to/fonts\" --font-family \"CustomFont\"" +} + +FONT_PATH="" +FONT_FAMILY="" +TARGET_FONT_DIR="appflowy_flutter/assets/fonts/" +PUBSPEC_FILE="appflowy_flutter/pubspec.yaml" +BASE_APPEARANCE_FILE="appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$FONT_PATH" ]; then + echo "Error: Font path is required" + show_usage + exit 1 +fi + +if [ -z "$FONT_FAMILY" ]; then + echo "Error: Font family name is required" + show_usage + exit 1 +fi + +# Check if source directory exists +if [ ! -d "$FONT_PATH" ]; then + echo "Error: Font directory not found at $FONT_PATH" + exit 1 +fi + +# Create target directory if it doesn't exist +mkdir -p "$TARGET_FONT_DIR" + +# Clean existing fonts in target directory +echo "Cleaning existing fonts in $TARGET_FONT_DIR..." +rm -rf "$TARGET_FONT_DIR"/* + +# Copy font files to target directory +echo "Copying font files from $FONT_PATH to $TARGET_FONT_DIR..." +found_fonts=false +for ext in ttf otf; do + if ls "$FONT_PATH"/*."$ext" >/dev/null 2>&1; then + cp "$FONT_PATH"/*."$ext" "$TARGET_FONT_DIR"/ 2>/dev/null && found_fonts=true + fi +done + +if [ "$found_fonts" = false ]; then + echo "Error: No font files (.ttf or .otf) found in source directory" + exit 1 +fi + +# Generate font configuration for pubspec.yaml +echo "Generating font configuration..." + +# Create temporary file for font configuration +TEMP_FILE=$(mktemp) + +{ + echo " # BEGIN: WHITE_LABEL_FONT" + echo " - family: $FONT_FAMILY" + echo " fonts:" + + # Generate entries for each font file + for font_file in "$TARGET_FONT_DIR"/*; do + filename=$(basename "$font_file") + echo " - asset: assets/fonts/$filename" + + # Try to detect font weight from filename + if [[ $filename =~ (Thin|ExtraLight|Light|Regular|Medium|SemiBold|Bold|ExtraBold|Black) ]]; then + case ${BASH_REMATCH[1]} in + "Thin") echo " weight: 100";; + "ExtraLight") echo " weight: 200";; + "Light") echo " weight: 300";; + "Regular") echo " weight: 400";; + "Medium") echo " weight: 500";; + "SemiBold") echo " weight: 600";; + "Bold") echo " weight: 700";; + "ExtraBold") echo " weight: 800";; + "Black") echo " weight: 900";; + esac + fi + + # Try to detect italic style from filename + if [[ $filename =~ Italic ]]; then + echo " style: italic" + fi + done + echo " # END: WHITE_LABEL_FONT" +} > "$TEMP_FILE" + +# Update pubspec.yaml +echo "Updating pubspec.yaml..." +if [ -f "$PUBSPEC_FILE" ]; then + # Create a backup of the original file + cp "$PUBSPEC_FILE" "${PUBSPEC_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + # First, remove existing white label font configuration + awk '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/{ next } /# White-label font configuration will be added here/{ print; system("cat '"$TEMP_FILE"'"); next } 1' "$PUBSPEC_FILE" > "${PUBSPEC_FILE}.tmp" + + if [ $? -eq 0 ]; then + mv "${PUBSPEC_FILE}.tmp" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.bak" + else + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.tmp" + rm -f "$TEMP_FILE" + exit 1 + fi + else + # Unix-like systems handling + if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" + else + SED_INPLACE="-i ''" + fi + + # Remove existing white label font configuration + sed $SED_INPLACE '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/d' "$PUBSPEC_FILE" + + # Add new font configuration + sed $SED_INPLACE "/# White-label font configuration will be added here/r $TEMP_FILE" "$PUBSPEC_FILE" + + if [ $? -ne 0 ]; then + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 + fi + rm -f "${PUBSPEC_FILE}.bak" + fi +else + echo "Error: pubspec.yaml not found at $PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 +fi + +# Update base_appearance.dart +echo "Updating base_appearance.dart..." +if [ -f "$BASE_APPEARANCE_FILE" ]; then + # Create a backup of the original file + cp "$BASE_APPEARANCE_FILE" "${BASE_APPEARANCE_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + sed -i "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + else + # Unix-like systems handling + sed -i '' "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + fi + + if [ $? -ne 0 ]; then + echo "Error: Failed to update base_appearance.dart" + mv "${BASE_APPEARANCE_FILE}.bak" "$BASE_APPEARANCE_FILE" + exit 1 + fi + rm -f "${BASE_APPEARANCE_FILE}.bak" +else + echo "Error: base_appearance.dart not found at $BASE_APPEARANCE_FILE" + exit 1 +fi + +# Cleanup +rm -f "$TEMP_FILE" + +echo "Font white labeling completed successfully!" diff --git a/frontend/scripts/white_label/i18n_white_label.sh b/frontend/scripts/white_label/i18n_white_label.sh new file mode 100644 index 0000000000..60152d1630 --- /dev/null +++ b/frontend/scripts/white_label/i18n_white_label.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +I18N_DIR="resources/translations" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -d "$I18N_DIR" ]; then + echo "Error: Translation directory not found at $I18N_DIR" + exit 1 +fi + +echo "Replacing 'AppFlowy' with '$CUSTOM_COMPANY_NAME' in translation files..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing translation files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Check if directory exists and has JSON files + if [ ! -d "$I18N_DIR" ] || [ -z "$(ls -A "$I18N_DIR"/*.json 2>/dev/null)" ]; then + echo "Error: No JSON files found in $I18N_DIR directory" + exit 1 + fi + + # Process each JSON file in the directory + for file in "$I18N_DIR"/*.json; do + echo "Updating $(basename "$file")" + # Use jq to replace AppFlowy with custom company name in values only + if command -v jq >/dev/null 2>&1; then + # Create a temporary file for the transformation + jq --arg company "$CUSTOM_COMPANY_NAME" 'walk(if type == "string" then gsub("AppFlowy"; $company) else . end)' "$file" > "${file}.tmp" + # Check if transformation was successful + if [ $? -eq 0 ]; then + mv "${file}.tmp" "$file" + else + echo "Error: Failed to process $file with jq" + rm -f "${file}.tmp" + exit 1 + fi + else + # Fallback to sed if jq is not available + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace AppFlowy with the custom company name in JSON values + sed $SED_INPLACE 's/\(".*"\): *"\(.*\)AppFlowy\(.*\)"/\1: "\2'"$ESCAPED_COMPANY_NAME"'\3"/g' "$file" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $file with sed" + exit 1 + fi + fi + done +else + for file in $(find "$I18N_DIR" -name "*.json" -type f); do + echo "Updating $(basename "$file")" + # Use jq to only replace values, not keys + if command -v jq >/dev/null 2>&1; then + jq 'walk(if type == "string" then gsub("AppFlowy"; "'"$CUSTOM_COMPANY_NAME"'") else . end)' "$file" > "$file.tmp" && mv "$file.tmp" "$file" + else + # Fallback to sed with a more specific pattern that targets values but not keys + sed $SED_INPLACE 's/: *"[^"]*AppFlowy[^"]*"/: "&"/g; s/: *"&"/: "'"$CUSTOM_COMPANY_NAME"'"/g' "$file" + # Fix any double colons that might have been introduced + sed $SED_INPLACE 's/: *: */: /g' "$file" + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/icon_white_label.sh b/frontend/scripts/white_label/icon_white_label.sh new file mode 100644 index 0000000000..ca70bc1661 --- /dev/null +++ b/frontend/scripts/white_label/icon_white_label.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --icon-path Set the path to the folder containing application icons (.svg files)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --icon-path \"/path/to/icons_folder\"" +} + +NEW_ICON_PATH="" +ICON_DIR="resources/flowy_icons" +ICON_NAME_NEED_REPLACE=("app_logo.svg" "ai_chat_logo.svg" "app_logo_with_text_light.svg" "app_logo_with_text_dark.svg") + +while [[ $# -gt 0 ]]; do + case $1 in + --icon-path) + NEW_ICON_PATH="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$NEW_ICON_PATH" ]; then + echo "Error: Icon path is required" + show_usage + exit 1 +fi + +if [ ! -d "$NEW_ICON_PATH" ]; then + echo "Error: New icon directory not found at $NEW_ICON_PATH" + exit 1 +fi + +if [ ! -d "$ICON_DIR" ]; then + echo "Error: Icon directory not found at $ICON_DIR" + exit 1 +fi + +echo "Replacing icons..." + +echo "Processing icon files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + for subdir in "${ICON_DIR}"/*/; do + if [ -d "$subdir" ]; then + echo "Checking subdirectory: $(basename "$subdir")" + for file in "${subdir}"*.svg; do + if [ -f "$file" ] && [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$subdir")/$(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") in $(basename "$subdir") with new icon" + else + echo "Error: Failed to replace $(basename "$file") in $(basename "$subdir")" + exit 1 + fi + else + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" + fi + fi + done + fi + done +else + for file in $(find "$ICON_DIR" -name "*.svg" -type f); do + if [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") with new icon" + else + echo "Error: Failed to replace $(basename "$file")" + exit 1 + fi + else + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" + fi + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/resources/my_company_logo.ico b/frontend/scripts/white_label/resources/my_company_logo.ico new file mode 100644 index 0000000000..c922a6b36d Binary files /dev/null and b/frontend/scripts/white_label/resources/my_company_logo.ico differ diff --git a/frontend/scripts/white_label/resources/my_company_logo.png b/frontend/scripts/white_label/resources/my_company_logo.png new file mode 100644 index 0000000000..8f50872743 Binary files /dev/null and b/frontend/scripts/white_label/resources/my_company_logo.png differ diff --git a/frontend/scripts/white_label/resources/my_company_logo.svg b/frontend/scripts/white_label/resources/my_company_logo.svg new file mode 100644 index 0000000000..c06bf17cb4 --- /dev/null +++ b/frontend/scripts/white_label/resources/my_company_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/white_label/white_label.sh b/frontend/scripts/white_label/white_label.sh new file mode 100644 index 0000000000..8ecd187210 --- /dev/null +++ b/frontend/scripts/white_label/white_label.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# Default values +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" +WINDOWS_ICON_PATH="" +FONT_PATH="" +FONT_FAMILY="" +PLATFORMS=("windows" "linux" "macos" "ios" "android") + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.svg)" + echo " --windows-icon-path Set the path to the windows application icon (.ico)" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" + echo " --platforms Comma-separated list of platforms to white label (windows,linux,macos,ios,android)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --platforms \"windows,linux,macos\" \\" + echo " --windows-icon-path \"./assets/icons/mycompany.ico\" \\" + echo " --icon-path \"./assets/icons/\" \\" + echo " --font-path \"./assets/fonts/\" --font-family \"CustomFont\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --windows-icon-path) + WINDOWS_ICON_PATH="$2" + shift 2 + ;; + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; + --platforms) + IFS=',' read -ra PLATFORMS <<< "$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ] || [ -z "$APP_IDENTIFIER" ] || [ -z "$COMPANY_NAME" ] || [ -z "$COPYRIGHT" ] || [ -z "$ICON_PATH" ]; then + echo "Error: All parameters are required" + show_usage + exit 1 +fi + +if [ ! -d "$ICON_PATH" ]; then + echo "Error: Icon directory not found at $ICON_PATH" + exit 1 +fi + +if [ ! -f "$WINDOWS_ICON_PATH" ]; then + echo "Error: Windows icon file not found at $WINDOWS_ICON_PATH" + exit 1 +fi + +run_platform_script() { + local platform=$1 + local script_path="scripts/white_label/${platform}_white_label.sh" + + if [ ! -f "$script_path" ]; then + echo -e "\033[31mWarning: White label script not found for platform: $platform\033[0m" + return + fi + + echo -e "\033[32mRunning white label script for $platform...\033[0m" + bash "$script_path" \ + --app-name "$APP_NAME" \ + --app-identifier "$APP_IDENTIFIER" \ + --company-name "$COMPANY_NAME" \ + --copyright "$COPYRIGHT" \ + --icon-path "$WINDOWS_ICON_PATH" +} + +echo -e "\033[32mRunning i18n white label script...\033[0m" +bash "scripts/white_label/i18n_white_label.sh" --company-name "$COMPANY_NAME" + +echo -e "\033[32mRunning icon white label script...\033[0m" +bash "scripts/white_label/icon_white_label.sh" --icon-path "$ICON_PATH" + +echo -e "\033[32mRunning code white label script...\033[0m" +bash "scripts/white_label/code_white_label.sh" --company-name "$COMPANY_NAME" + +# Run font white label script if font parameters are provided +if [ ! -z "$FONT_PATH" ] && [ ! -z "$FONT_FAMILY" ]; then + echo -e "\033[32mRunning font white label script...\033[0m" + bash "scripts/white_label/font_white_label.sh" \ + --font-path "$FONT_PATH" \ + --font-family "$FONT_FAMILY" +fi + +for platform in "${PLATFORMS[@]}"; do + run_platform_script "$platform" +done + +echo -e "\033[32mWhite labeling process completed successfully!\033[0m" diff --git a/frontend/scripts/white_label/windows_white_label.sh b/frontend/scripts/white_label/windows_white_label.sh new file mode 100644 index 0000000000..58801424ff --- /dev/null +++ b/frontend/scripts/white_label/windows_white_label.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.ico file)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --icon-path \"./assets/icons/company.ico\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ]; then + echo -e "\033[31mError: Application name is required\033[0m" + exit 1 +fi + +if [ -z "$APP_IDENTIFIER" ]; then + echo -e "\033[31mError: Application identifier is required\033[0m" + exit 1 +fi + +if [ -z "$COMPANY_NAME" ]; then + echo -e "\033[31mError: Company name is required\033[0m" + exit 1 +fi + +if [ -z "$COPYRIGHT" ]; then + echo -e "\033[31mError: Copyright information is required\033[0m" + exit 1 +fi + +if [ -z "$ICON_PATH" ]; then + echo -e "\033[31mError: Icon path is required\033[0m" + exit 1 +fi + +echo "Starting Windows application customization..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +update_runner_files() { + runner_dir="appflowy_flutter/windows/runner" + + if [ -f "$runner_dir/Runner.rc" ]; then + sed $SED_INPLACE "s/VALUE \"CompanyName\", .*$/VALUE \"CompanyName\", \"$COMPANY_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"FileDescription\", .*$/VALUE \"FileDescription\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"InternalName\", .*$/VALUE \"InternalName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"OriginalFilename\", .*$/VALUE \"OriginalFilename\", \"$APP_NAME.exe\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"LegalCopyright\", .*$/VALUE \"LegalCopyright\", \"$COPYRIGHT\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"ProductName\", .*$/VALUE \"ProductName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + echo -e "Runner.rc updated successfully" + else + echo -e "\033[31mRunner.rc file not found\033[0m" + fi +} + +update_icon() { + if [ ! -z "$ICON_PATH" ] && [ -f "$ICON_PATH" ]; then + app_icon_path="appflowy_flutter/windows/runner/resources/app_icon.ico" + cp "$ICON_PATH" "$app_icon_path" + echo -e "Application icon updated successfully" + else + echo -e "\033[31mApplication icon file not found\033[0m" + fi +} + +update_cmake_lists() { + cmake_file="appflowy_flutter/windows/CMakeLists.txt" + if [ -f "$cmake_file" ]; then + sed $SED_INPLACE "s/set(BINARY_NAME .*)$/set(BINARY_NAME \"$APP_NAME\")/" "$cmake_file" + echo -e "CMake configuration updated successfully" + else + echo -e "\033[31mCMake configuration file not found\033[0m" + fi +} + +update_main_cpp() { + main_cpp_file="appflowy_flutter/windows/runner/main.cpp" + if [ -f "$main_cpp_file" ]; then + sed $SED_INPLACE "s/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"AppFlowyMutex\");/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"${APP_NAME}Mutex\");/" "$main_cpp_file" + sed $SED_INPLACE "s/HWND handle = FindWindowA(NULL, \"AppFlowy\");/HWND handle = FindWindowA(NULL, \"$APP_NAME\");/" "$main_cpp_file" + sed $SED_INPLACE "s/if (window.SendAppLinkToInstance(L\"AppFlowy\")) {/if (window.SendAppLinkToInstance(L\"$APP_NAME\")) {/" "$main_cpp_file" + sed $SED_INPLACE "s/if (!window.Create(L\"AppFlowy\", origin, size)) {/if (!window.Create(L\"$APP_NAME\", origin, size)) {/" "$main_cpp_file" + echo -e "main.cpp updated successfully" + else + echo -e "\033[31mMain.cpp file not found\033[0m" + fi +} + +echo "Applying customizations..." +update_runner_files +update_icon +update_cmake_lists +update_main_cpp + +echo "Windows application customization completed successfully!" diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 394d19473d..1341b40643 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -13,6 +13,7 @@ "fa", "fr-CA", "fr-FR", + "ga-IE", "he", "hu-HU", "id-ID",