diff --git a/.github/actions/flutter_build/action.yml b/.github/actions/flutter_build/action.yml
index 81b2845949..66bfce44a5 100644
--- a/.github/actions/flutter_build/action.yml
+++ b/.github/actions/flutter_build/action.yml
@@ -63,7 +63,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
- sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
+ sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libmpv-dev mpv
elif [ "$RUNNER_OS" == "Windows" ]; then
vcpkg integrate install
elif [ "$RUNNER_OS" == "macOS" ]; then
diff --git a/.github/actions/flutter_integration_test/action.yml b/.github/actions/flutter_integration_test/action.yml
index 6df3ec005d..63066e0f38 100644
--- a/.github/actions/flutter_integration_test/action.yml
+++ b/.github/actions/flutter_integration_test/action.yml
@@ -52,7 +52,7 @@ runs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
- sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager
+ sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager libmpv-dev mpv
shell: bash
- name: Enable Flutter Desktop
diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml
index b944187ec4..c1cdd7bc17 100644
--- a/.github/workflows/flutter_ci.yaml
+++ b/.github/workflows/flutter_ci.yaml
@@ -89,6 +89,7 @@ jobs:
with:
os: ${{ matrix.os }}
flutter_version: ${{ env.FLUTTER_VERSION }}
+ DISABLE_CI_TEST_LOG: "true"
rust_toolchain: ${{ env.RUST_TOOLCHAIN }}
cargo_make_version: ${{ env.CARGO_MAKE_VERSION }}
rust_target: ${{ matrix.target }}
@@ -172,7 +173,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
- sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
+ sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libmpv-dev mpv
fi
shell: bash
@@ -202,6 +203,7 @@ jobs:
- name: Run Flutter unit tests
env:
DISABLE_EVENT_LOG: true
+ DISABLE_CI_TEST_LOG: "true"
working-directory: frontend
run: |
if [ "$RUNNER_OS" == "macOS" ]; then
@@ -272,7 +274,7 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
- sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
+ sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libmpv-dev mpv
shell: bash
- name: Enable Flutter Desktop
@@ -319,6 +321,12 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
+ - name: Install video dependency
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libmpv-dev mpv
+ shell: bash
+
- name: Flutter Integration Test 1
uses: ./.github/actions/flutter_integration_test
with:
@@ -343,6 +351,12 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
+ - name: Install video dependency
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libmpv-dev mpv
+ shell: bash
+
- name: Flutter Integration Test 2
uses: ./.github/actions/flutter_integration_test
with:
@@ -367,6 +381,12 @@ jobs:
- name: Checkout source code
uses: actions/checkout@v4
+ - name: Install video dependency
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libmpv-dev mpv
+ shell: bash
+
- name: Flutter Integration Test 3
uses: ./.github/actions/flutter_integration_test
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e91d95f969..3c589b2611 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -367,8 +367,8 @@ jobs:
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo apt-get update
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
- sudo apt-get install keybinder-3.0 libnotify-dev
- sudo apt-get -y install alien
+ sudo apt-get install keybinder-3.0
+ sudo apt-get install -y alien libnotify-dev libmpv-dev mpv
source $HOME/.cargo/env
cargo install --force cargo-make
cargo install --force duckscript_cli
diff --git a/.github/workflows/web2_ci.yaml b/.github/workflows/web2_ci.yaml
index 68df8d805f..c52f71dd84 100644
--- a/.github/workflows/web2_ci.yaml
+++ b/.github/workflows/web2_ci.yaml
@@ -52,11 +52,11 @@ jobs:
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- - name: test and lint
+ - name: Run lint check
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- pnpm run test:unit
+
- name: build and analyze
working-directory: frontend/appflowy_web_app
run: |
diff --git a/.github/workflows/web_cypress_ci.yaml b/.github/workflows/web_coverage.yaml
similarity index 80%
rename from .github/workflows/web_cypress_ci.yaml
rename to .github/workflows/web_coverage.yaml
index 15e52d3e8d..258119664a 100644
--- a/.github/workflows/web_cypress_ci.yaml
+++ b/.github/workflows/web_coverage.yaml
@@ -1,4 +1,4 @@
-name: Cypress Tests
+name: Web Code Coverage
on:
pull_request:
@@ -13,7 +13,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
- cypress-run:
+ test:
if: github.event.pull_request.draft != true
runs-on: ubuntu-22.04
steps:
@@ -45,4 +45,15 @@ jobs:
component: true
build: pnpm run build
start: pnpm run start
- browser: chrome
\ No newline at end of file
+ browser: chrome
+
+ - name: Jest run
+ working-directory: frontend/appflowy_web_app
+ run: |
+ pnpm run test:unit
+
+ - name: Generate and post coverage summary
+ working-directory: frontend/appflowy_web_app
+ run: |
+ pnpm run merge-coverage
+
diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml
index 5a962f3cbf..a71ffec1fc 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.5.8"
+APPFLOWY_VERSION = "0.5.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
diff --git a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
index 351994354d..279b17320c 100644
--- a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
+++ b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
@@ -58,4 +58,11 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart
index 46550aa81a..958910dd80 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart
@@ -8,8 +8,8 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
- group('board add row test', () {
- testWidgets('Add card from header', (tester) async {
+ group('notification test', () {
+ testWidgets('enable notification', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -17,7 +17,7 @@ void main() {
await tester.openSettingsPage(SettingsPage.notifications);
await tester.pumpAndSettle();
- final switchFinder = find.byType(Switch);
+ final switchFinder = find.byType(Switch).first;
// Defaults to enabled
Switch switchWidget = tester.widget(switchFinder);
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 1782c0d0ba..4659c98b55 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
@@ -18,6 +18,9 @@ void main() {
// 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,
@@ -46,6 +49,9 @@ void main() {
// 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,
diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart
index 158a8db882..006d7ff0b6 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart
@@ -39,6 +39,9 @@ void main() {
await tester.tapAnonymousSignInButton();
for (final layout in ViewLayoutPB.values) {
+ if (layout == ViewLayoutPB.Chat) {
+ continue;
+ }
// create a new page
final name = 'AppFlowy_$layout';
await tester.createNewPageWithNameUnderParent(
@@ -66,6 +69,8 @@ void main() {
case ViewLayoutPB.Calendar:
expect(find.byType(CalendarPage), findsOneWidget);
break;
+ case ViewLayoutPB.Chat:
+ break;
}
await tester.openPage(gettingStarted);
diff --git a/frontend/appflowy_flutter/integration_test/shared/base.dart b/frontend/appflowy_flutter/integration_test/shared/base.dart
index ab72247c24..bd2577b96c 100644
--- a/frontend/appflowy_flutter/integration_test/shared/base.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/base.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
@@ -36,6 +37,8 @@ extension AppFlowyTestBase on WidgetTester {
AuthenticatorType? cloudType,
String? email,
}) async {
+ VideoBlockKit.ensureInitialized();
+
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Set the window size
await binding.setSurfaceSize(windowSize);
diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock
index 93a8eb77e1..6f75b60ade 100644
--- a/frontend/appflowy_flutter/ios/Podfile.lock
+++ b/frontend/appflowy_flutter/ios/Podfile.lock
@@ -58,6 +58,12 @@ PODS:
- Flutter
- keyboard_height_plugin (0.0.1):
- Flutter
+ - media_kit_libs_ios_video (1.0.4):
+ - Flutter
+ - media_kit_native_event_loop (1.0.0):
+ - Flutter
+ - media_kit_video (0.0.1):
+ - Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -66,6 +72,8 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.0.0)
+ - screen_brightness_ios (0.1.0):
+ - Flutter
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
@@ -83,6 +91,10 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
+ - volume_controller (0.0.1):
+ - Flutter
+ - wakelock_plus (0.0.1):
+ - Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -98,14 +110,20 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`)
+ - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
+ - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
+ - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- 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`)
+ - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/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`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+ - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
+ - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
@@ -143,12 +161,20 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/irondash_engine_context/ios"
keyboard_height_plugin:
:path: ".symlinks/plugins/keyboard_height_plugin/ios"
+ media_kit_libs_ios_video:
+ :path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
+ media_kit_native_event_loop:
+ :path: ".symlinks/plugins/media_kit_native_event_loop/ios"
+ media_kit_video:
+ :path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
+ screen_brightness_ios:
+ :path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -159,6 +185,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
+ volume_controller:
+ :path: ".symlinks/plugins/volume_controller/ios"
+ wakelock_plus:
+ :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
@@ -170,16 +200,20 @@ SPEC CHECKSUMS:
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
- fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
+ fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
+ media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
+ media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
+ media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
+ screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
@@ -188,7 +222,9 @@ SPEC CHECKSUMS:
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
+ volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
+ wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
-COCOAPODS: 1.15.2
+COCOAPODS: 1.11.3
diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist
index 8c605b9d3a..5ec528b05e 100644
--- a/frontend/appflowy_flutter/ios/Runner/Info.plist
+++ b/frontend/appflowy_flutter/ios/Runner/Info.plist
@@ -66,5 +66,10 @@
UIViewControllerBasedStatusBarAppearance
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
\ No newline at end of file
diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
index ff97b61241..00e79153e4 100644
--- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
+++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
@@ -65,6 +65,11 @@ class KVKeys {
/// {'feature_flag_1': true, 'feature_flag_2': false}
static const String featureFlag = 'featureFlag';
+ /// The key for saving show notification icon option
+ ///
+ /// The value is a boolean string
+ static const String showNotificationIcon = 'showNotificationIcon';
+
/// The key for saving the last opened workspace id
///
/// The workspace id is a string.
@@ -75,4 +80,15 @@ class KVKeys {
///
/// The value is a double string.
static const String scaleFactor = 'scaleFactor';
+
+ /// The key for saving the last opened space
+ ///
+ /// The value is a int string.
+ static const String lastOpenedSpace = 'lastOpenedSpace';
+
+ /// The key for saving the space order
+ ///
+ /// The value is a json string with the following format:
+ /// [0, 1, 2]
+ static const String spaceOrder = 'spaceOrder';
}
diff --git a/frontend/appflowy_flutter/lib/core/frameless_window.dart b/frontend/appflowy_flutter/lib/core/frameless_window.dart
index 7877615a98..fcd955fc93 100644
--- a/frontend/appflowy_flutter/lib/core/frameless_window.dart
+++ b/frontend/appflowy_flutter/lib/core/frameless_window.dart
@@ -3,12 +3,13 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/window_title_bar.dart';
+import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
-import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CocoaWindowChannel {
@@ -102,13 +103,25 @@ class MoveWindowDetectorState extends State {
return const SizedBox.shrink();
}
+ final color = Theme.of(context).isLightMode ? Colors.white : Colors.black;
+ final textSpan = TextSpan(
+ children: [
+ TextSpan(
+ text: '${LocaleKeys.sideBar_openSidebar.tr()}\n',
+ style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: color),
+ ),
+ TextSpan(
+ text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
+ style: Theme.of(context)
+ .textTheme
+ .bodyMedium!
+ .copyWith(color: Theme.of(context).hintColor),
+ ),
+ ],
+ );
+
return FlowyTooltip(
- richMessage: TextSpan(
- children: [
- TextSpan(text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n'),
- const TextSpan(text: 'Ctrl+\\'),
- ],
- ),
+ richMessage: textSpan,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => context
diff --git a/frontend/appflowy_flutter/lib/main.dart b/frontend/appflowy_flutter/lib/main.dart
index 9f140489c4..bee524b574 100644
--- a/frontend/appflowy_flutter/lib/main.dart
+++ b/frontend/appflowy_flutter/lib/main.dart
@@ -1,9 +1,12 @@
import 'package:scaled_app/scaled_app.dart';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
+
import 'startup/startup.dart';
Future main() async {
ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.0);
+ VideoBlockKit.ensureInitialized();
await runAppFlowy();
}
diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart
index 600dace29f..8b9f1e70ff 100644
--- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart
+++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart
@@ -1,5 +1,7 @@
+import 'dart:async';
import 'dart:convert';
+import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:flutter/material.dart';
@@ -14,15 +16,15 @@ import 'package:go_router/go_router.dart';
extension MobileRouter on BuildContext {
Future pushView(ViewPB view, [Map? arguments]) async {
- await push(
- Uri(
- path: view.routeName,
- queryParameters: view.queryParameters(arguments),
- ).toString(),
- ).then((value) {
- getIt().latestOpenView = view;
- getIt().updateRecentViews([view.id], true);
- });
+ // set the current view before pushing the new view
+ getIt().latestOpenView = view;
+ unawaited(getIt().updateRecentViews([view.id], true));
+
+ final uri = Uri(
+ path: view.routeName,
+ queryParameters: view.queryParameters(arguments),
+ ).toString();
+ await push(uri);
}
}
@@ -37,6 +39,9 @@ extension on ViewPB {
return MobileCalendarScreen.routeName;
case ViewLayoutPB.Board:
return MobileBoardScreen.routeName;
+ case ViewLayoutPB.Chat:
+ return MobileChatScreen.routeName;
+
default:
throw UnimplementedError('routeName for $this is not implemented');
}
@@ -65,6 +70,11 @@ extension on ViewPB {
MobileBoardScreen.viewId: id,
MobileBoardScreen.viewTitle: name,
};
+ case ViewLayoutPB.Chat:
+ return {
+ MobileChatScreen.viewId: id,
+ MobileChatScreen.viewTitle: name,
+ };
default:
throw UnimplementedError(
'queryParameters for $this is not implemented',
diff --git a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart
index 410bc68c4e..547c81f00b 100644
--- a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart
+++ b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart
@@ -57,7 +57,19 @@ class RecentViewBloc extends Bloc {
}
},
);
+
+ // only document supports the cover
+ if (view.layout != ViewLayoutPB.Document) {
+ emit(
+ state.copyWith(
+ name: view.name,
+ icon: view.icon.value,
+ ),
+ );
+ }
+
final cover = getCoverV2();
+
if (cover != null) {
emit(
state.copyWith(
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 22b46ced57..591f708546 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
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/shared/sync_indicator.dart';
import 'package:appflowy/shared/feature_flags.dart';
+import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
@@ -154,7 +155,10 @@ class _MobileViewPageState extends State {
(view) {
final plugin = view.plugin(arguments: widget.arguments ?? const {})
..init();
- return plugin.widgetBuilder.buildWidget(shrinkWrap: false);
+ return plugin.widgetBuilder.buildWidget(
+ shrinkWrap: false,
+ context: PluginContext(userProfile: state.userProfilePB),
+ );
},
(error) {
return FlowyMobileStateContainer.error(
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart
index 37a5cb0221..18c5c3a6df 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart
@@ -23,7 +23,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.document_menuName.tr(),
leftIcon: const FlowySvg(
FlowySvgs.document_s,
- size: Size.square(20),
+ size: Size.square(18),
),
showTopBorder: false,
onTap: () => onAction(ViewLayoutPB.Document),
@@ -32,7 +32,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.grid_menuName.tr(),
leftIcon: const FlowySvg(
FlowySvgs.grid_s,
- size: Size.square(20),
+ size: Size.square(18),
),
showTopBorder: false,
onTap: () => onAction(ViewLayoutPB.Grid),
@@ -41,7 +41,7 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
text: LocaleKeys.board_menuName.tr(),
leftIcon: const FlowySvg(
FlowySvgs.board_s,
- size: Size.square(20),
+ size: Size.square(18),
),
showTopBorder: false,
onTap: () => onAction(ViewLayoutPB.Board),
@@ -49,8 +49,8 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
FlowyOptionTile.text(
text: LocaleKeys.calendar_menuName.tr(),
leftIcon: const FlowySvg(
- FlowySvgs.date_s,
- size: Size.square(20),
+ FlowySvgs.calendar_s,
+ size: Size.square(18),
),
showTopBorder: false,
onTap: () => onAction(ViewLayoutPB.Calendar),
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 c1e2560e48..75b0151a3a 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
@@ -1,9 +1,17 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
+import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
+import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:fluttertoast/fluttertoast.dart';
enum MobileBottomSheetType {
view,
@@ -14,11 +22,13 @@ class MobileViewItemBottomSheet extends StatefulWidget {
const MobileViewItemBottomSheet({
super.key,
required this.view,
+ required this.actions,
this.defaultType = MobileBottomSheetType.view,
});
final ViewPB view;
final MobileBottomSheetType defaultType;
+ final List actions;
@override
State createState() =>
@@ -27,12 +37,14 @@ class MobileViewItemBottomSheet extends StatefulWidget {
class _MobileViewItemBottomSheetState extends State {
MobileBottomSheetType type = MobileBottomSheetType.view;
+ final fToast = FToast();
@override
void initState() {
super.initState();
type = widget.defaultType;
+ fToast.init(AppGlobals.context);
}
@override
@@ -40,6 +52,7 @@ class _MobileViewItemBottomSheetState extends State {
switch (type) {
case MobileBottomSheetType.view:
return MobileViewItemBottomSheetBody(
+ actions: widget.actions,
isFavorite: widget.view.isFavorite,
onAction: (action) {
switch (action) {
@@ -59,7 +72,6 @@ class _MobileViewItemBottomSheetState extends State {
case MobileViewItemBottomSheetBodyAction.delete:
Navigator.pop(context);
context.read().add(const ViewEvent.delete());
-
break;
case MobileViewItemBottomSheetBodyAction.addToFavorites:
case MobileViewItemBottomSheetBodyAction.removeFromFavorites:
@@ -68,6 +80,11 @@ class _MobileViewItemBottomSheetState extends State {
.read()
.add(FavoriteEvent.toggle(widget.view));
break;
+ case MobileViewItemBottomSheetBodyAction.removeFromRecent:
+ _removeFromRecent(context);
+ break;
+ case MobileViewItemBottomSheetBodyAction.divider:
+ break;
}
},
);
@@ -83,4 +100,74 @@ class _MobileViewItemBottomSheetState extends State {
);
}
}
+
+ Future _removeFromRecent(BuildContext context) async {
+ final viewId = context.read().view.id;
+ final recentViewsBloc = context.read();
+ Navigator.pop(context);
+
+ await _showConfirmDialog(
+ onDelete: () {
+ recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId]));
+
+ fToast.showToast(
+ child: const _RemoveToast(),
+ positionedToastBuilder: (context, child) {
+ return Positioned.fill(
+ top: 450,
+ child: child,
+ );
+ },
+ );
+ },
+ );
+ }
+
+ Future _showConfirmDialog({required VoidCallback onDelete}) async {
+ await showFlowyCupertinoConfirmDialog(
+ title: LocaleKeys.sideBar_removePageFromRecent.tr(),
+ leftButton: FlowyText.regular(
+ LocaleKeys.button_cancel.tr(),
+ color: const Color(0xFF1456F0),
+ ),
+ rightButton: FlowyText.medium(
+ LocaleKeys.button_delete.tr(),
+ color: const Color(0xFFFE0220),
+ ),
+ onRightButtonPressed: (context) {
+ onDelete();
+ Navigator.pop(context);
+ },
+ );
+ }
+}
+
+class _RemoveToast extends StatelessWidget {
+ const _RemoveToast();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12.0),
+ color: const Color(0xE5171717),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const FlowySvg(
+ FlowySvgs.success_s,
+ blendMode: null,
+ ),
+ const HSpace(8.0),
+ FlowyText.regular(
+ LocaleKeys.sideBar_removeSuccess.tr(),
+ fontSize: 16.0,
+ color: Colors.white,
+ ),
+ ],
+ ),
+ );
+ }
}
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 624ae33b9f..d9c6382288 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,6 +1,6 @@
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/mobile/presentation/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -11,6 +11,8 @@ enum MobileViewItemBottomSheetBodyAction {
delete,
addToFavorites,
removeFromFavorites,
+ divider,
+ removeFromRecent,
}
class MobileViewItemBottomSheetBody extends StatelessWidget {
@@ -18,63 +20,124 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
super.key,
this.isFavorite = false,
required this.onAction,
+ required this.actions,
});
final bool isFavorite;
final void Function(MobileViewItemBottomSheetBodyAction action) onAction;
+ final List actions;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- MobileQuickActionButton(
- text: LocaleKeys.button_rename.tr(),
- icon: FlowySvgs.m_rename_s,
- onTap: () => onAction(
- MobileViewItemBottomSheetBodyAction.rename,
- ),
- ),
- _divider(),
- MobileQuickActionButton(
- text: isFavorite
- ? LocaleKeys.button_removeFromFavorites.tr()
- : LocaleKeys.button_addToFavorites.tr(),
- icon: isFavorite
- ? FlowySvgs.m_favorite_selected_lg
- : FlowySvgs.m_favorite_unselected_lg,
- iconColor: isFavorite ? Colors.yellow : null,
- onTap: () => onAction(
- isFavorite
- ? MobileViewItemBottomSheetBodyAction.removeFromFavorites
- : MobileViewItemBottomSheetBodyAction.addToFavorites,
- ),
- ),
- _divider(),
- MobileQuickActionButton(
- text: LocaleKeys.button_duplicate.tr(),
- icon: FlowySvgs.m_duplicate_s,
- onTap: () => onAction(
- MobileViewItemBottomSheetBodyAction.duplicate,
- ),
- ),
- _divider(),
- MobileQuickActionButton(
- text: LocaleKeys.button_delete.tr(),
- textColor: Theme.of(context).colorScheme.error,
- icon: FlowySvgs.m_delete_s,
- iconColor: Theme.of(context).colorScheme.error,
- onTap: () => onAction(
- MobileViewItemBottomSheetBodyAction.delete,
- ),
- ),
- _divider(),
- ],
+ children:
+ actions.map((action) => _buildActionButton(context, action)).toList(),
);
}
- Widget _divider() => const Divider(
- height: 8.5,
- thickness: 0.5,
- );
+ Widget _buildActionButton(
+ BuildContext context,
+ MobileViewItemBottomSheetBodyAction action,
+ ) {
+ switch (action) {
+ case MobileViewItemBottomSheetBodyAction.rename:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_rename.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.view_item_rename_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.rename,
+ ),
+ );
+ case MobileViewItemBottomSheetBodyAction.duplicate:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_duplicate.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.duplicate_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.duplicate,
+ ),
+ );
+
+ case MobileViewItemBottomSheetBodyAction.share:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_share.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.share_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.share,
+ ),
+ );
+ case MobileViewItemBottomSheetBodyAction.delete:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_delete.tr(),
+ textColor: Theme.of(context).colorScheme.error,
+ leftIcon: FlowySvg(
+ FlowySvgs.delete_s,
+ size: const Size.square(18),
+ color: Theme.of(context).colorScheme.error,
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.delete,
+ ),
+ );
+ case MobileViewItemBottomSheetBodyAction.addToFavorites:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_addToFavorites.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.favorite_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.addToFavorites,
+ ),
+ );
+ case MobileViewItemBottomSheetBodyAction.removeFromFavorites:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_removeFromFavorites.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.favorite_section_remove_from_favorite_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.removeFromFavorites,
+ ),
+ );
+ case MobileViewItemBottomSheetBodyAction.removeFromRecent:
+ return FlowyOptionTile.text(
+ text: LocaleKeys.button_removeFromRecent.tr(),
+ leftIcon: const FlowySvg(
+ FlowySvgs.remove_from_recent_s,
+ size: Size.square(18),
+ ),
+ showTopBorder: false,
+ showBottomBorder: false,
+ onTap: () => onAction(
+ MobileViewItemBottomSheetBodyAction.removeFromRecent,
+ ),
+ );
+
+ case MobileViewItemBottomSheetBodyAction.divider:
+ return const Divider(height: 0.5);
+ }
+ }
}
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 80330de3c7..327db627c4 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
@@ -65,8 +65,21 @@ enum MobilePaneActionType {
],
child: BlocBuilder(
builder: (context, state) {
+ final isFavorite = state.view.isFavorite;
return MobileViewItemBottomSheet(
view: viewBloc.state.view,
+ actions: [
+ isFavorite
+ ? MobileViewItemBottomSheetBodyAction
+ .removeFromFavorites
+ : MobileViewItemBottomSheetBodyAction
+ .addToFavorites,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.rename,
+ MobileViewItemBottomSheetBodyAction.duplicate,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.delete,
+ ],
);
},
),
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart
new file mode 100644
index 0000000000..31fcbdcdfd
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart
@@ -0,0 +1,28 @@
+import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:flutter/material.dart';
+
+class MobileChatScreen extends StatelessWidget {
+ const MobileChatScreen({
+ super.key,
+ required this.id,
+ this.title,
+ });
+
+ /// view id
+ final String id;
+ final String? title;
+
+ static const routeName = '/chat';
+ static const viewId = 'id';
+ static const viewTitle = 'title';
+
+ @override
+ Widget build(BuildContext context) {
+ return MobileViewPage(
+ id: id,
+ title: title,
+ viewLayout: ViewLayoutPB.Chat,
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart
index f9fcba5754..ca012891f6 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.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/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart';
@@ -10,6 +8,7 @@ import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@@ -17,14 +16,15 @@ class MobileFavoritePageFolder extends StatelessWidget {
const MobileFavoritePageFolder({
super.key,
required this.userProfile,
- required this.workspaceId,
});
final UserProfilePB userProfile;
- final String workspaceId;
@override
Widget build(BuildContext context) {
+ final workspaceId =
+ context.read().state.currentWorkspace?.workspaceId ??
+ '';
return MultiBlocProvider(
providers: [
BlocProvider(
@@ -67,7 +67,8 @@ class MobileFavoritePageFolder extends StatelessWidget {
MobileFavoriteFolder(
showHeader: false,
forceExpanded: true,
- views: favoriteState.views,
+ views:
+ favoriteState.views.map((e) => e.item).toList(),
),
const VSpace(100.0),
],
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 7afc740b45..e6d2d895b1 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
@@ -64,8 +64,6 @@ class MobileFavoriteScreen extends StatelessWidget {
builder: (context, state) {
return MobileFavoritePage(
userProfile: userProfile,
- workspaceId: state.currentWorkspace?.workspaceId ??
- workspaceSetting.workspaceId,
);
},
),
@@ -81,11 +79,9 @@ class MobileFavoritePage extends StatelessWidget {
const MobileFavoritePage({
super.key,
required this.userProfile,
- required this.workspaceId,
});
final UserProfilePB userProfile;
- final String workspaceId;
@override
Widget build(BuildContext context) {
@@ -108,7 +104,6 @@ class MobileFavoritePage extends StatelessWidget {
Expanded(
child: MobileFavoritePageFolder(
userProfile: userProfile,
- workspaceId: workspaceId,
),
),
],
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart
new file mode 100644
index 0000000000..684442a74a
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart
@@ -0,0 +1,126 @@
+import 'package:appflowy/mobile/application/mobile_router.dart';
+import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart';
+import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
+import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
+import 'package:appflowy/workspace/application/user/prelude.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MobileFavoriteSpace extends StatefulWidget {
+ const MobileFavoriteSpace({
+ super.key,
+ required this.userProfile,
+ });
+
+ final UserProfilePB userProfile;
+
+ @override
+ State createState() => _MobileFavoriteSpaceState();
+}
+
+class _MobileFavoriteSpaceState extends State
+ with AutomaticKeepAliveClientMixin {
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ final workspaceId =
+ context.read().state.currentWorkspace?.workspaceId ??
+ '';
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider(
+ create: (_) => SidebarSectionsBloc()
+ ..add(
+ SidebarSectionsEvent.initial(widget.userProfile, workspaceId),
+ ),
+ ),
+ BlocProvider(
+ create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
+ ),
+ ],
+ child: BlocListener(
+ listener: (context, state) =>
+ context.read().add(const FavoriteEvent.initial()),
+ child: MultiBlocListener(
+ listeners: [
+ BlocListener(
+ listenWhen: (p, c) =>
+ p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
+ listener: (context, state) =>
+ context.pushView(state.lastCreatedRootView!),
+ ),
+ ],
+ child: Builder(
+ builder: (context) {
+ final favoriteState = context.watch().state;
+
+ if (favoriteState.isLoading) {
+ return const SizedBox.shrink();
+ }
+
+ if (favoriteState.views.isEmpty) {
+ return const EmptySpacePlaceholder(
+ type: MobileViewCardType.favorite,
+ );
+ }
+
+ return _FavoriteViews(
+ favoriteViews: favoriteState.views.reversed.toList(),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _FavoriteViews extends StatelessWidget {
+ const _FavoriteViews({
+ required this.favoriteViews,
+ });
+
+ final List favoriteViews;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scrollbar(
+ child: ListView.separated(
+ key: const PageStorageKey('favorite_views_page_storage_key'),
+ padding: const EdgeInsets.symmetric(
+ horizontal: HomeSpaceViewSizes.mHorizontalPadding,
+ ),
+ itemBuilder: (context, index) {
+ final view = favoriteViews[index];
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 24.0),
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(
+ color: Theme.of(context).dividerColor,
+ width: 0.5,
+ ),
+ ),
+ ),
+ child: MobileViewCard(
+ key: ValueKey(view.item.id),
+ view: view.item,
+ timestamp: view.timestamp,
+ type: MobileViewCardType.favorite,
+ ),
+ );
+ },
+ separatorBuilder: (context, index) => const HSpace(8),
+ itemCount: favoriteViews.length,
+ ),
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart
new file mode 100644
index 0000000000..6ba27a6766
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart
@@ -0,0 +1,44 @@
+import 'package:appflowy/mobile/presentation/home/mobile_folders.dart';
+import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MobileHomeSpace extends StatefulWidget {
+ const MobileHomeSpace({super.key, required this.userProfile});
+
+ final UserProfilePB userProfile;
+
+ @override
+ State createState() => _MobileHomeSpaceState();
+}
+
+class _MobileHomeSpaceState extends State
+ with AutomaticKeepAliveClientMixin {
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ final workspaceId =
+ context.read().state.currentWorkspace?.workspaceId ??
+ '';
+ return Scrollbar(
+ child: SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: HomeSpaceViewSizes.mHorizontalPadding,
+ vertical: HomeSpaceViewSizes.mVerticalPadding,
+ ),
+ child: MobileFolders(
+ user: widget.userProfile,
+ workspaceId: workspaceId,
+ showFavorite: false,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart
index 6e695ea0e4..100649701d 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart
@@ -1,5 +1,7 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
+import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
@@ -11,6 +13,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
+import 'package:go_router/go_router.dart';
// Contains Public And Private Sections
class MobileFolders extends StatelessWidget {
@@ -87,7 +90,8 @@ class MobileFolders extends StatelessWidget {
views: state.section.publicViews,
),
],
- const VSpace(8.0),
+ const VSpace(4.0),
+ const _TrashButton(),
],
),
);
@@ -97,3 +101,28 @@ class MobileFolders extends StatelessWidget {
);
}
}
+
+class _TrashButton extends StatelessWidget {
+ const _TrashButton();
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ height: 52,
+ child: FlowyButton(
+ expand: true,
+ margin: const EdgeInsets.symmetric(vertical: 8),
+ leftIcon: const FlowySvg(
+ FlowySvgs.m_delete_s,
+ ),
+ leftIconSize: const Size.square(18),
+ iconPadding: 10.0,
+ text: FlowyText.regular(
+ LocaleKeys.trash_text.tr(),
+ fontSize: 16.0,
+ ),
+ onTap: () => context.push(MobileHomeTrashPage.routeName),
+ ),
+ );
+ }
+}
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 69759fc508..d01fada530 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart
@@ -1,23 +1,22 @@
import 'dart:io';
-import 'package:appflowy/generated/flowy_svgs.g.dart';
-import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/mobile/presentation/home/home.dart';
-import 'package:appflowy/mobile/presentation/home/mobile_folders.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart';
-import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart';
+import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
+import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
+import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package: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:provider/provider.dart';
class MobileHomeScreen extends StatelessWidget {
@@ -72,7 +71,7 @@ class MobileHomeScreen extends StatelessWidget {
}
}
-class MobileHomePage extends StatelessWidget {
+class MobileHomePage extends StatefulWidget {
const MobileHomePage({
super.key,
required this.userProfile,
@@ -82,68 +81,67 @@ class MobileHomePage extends StatelessWidget {
final UserProfilePB userProfile;
final WorkspaceSettingPB workspaceSetting;
+ @override
+ State createState() => _MobileHomePageState();
+}
+
+class _MobileHomePageState extends State {
+ @override
+ void initState() {
+ super.initState();
+
+ getIt().addLatestViewListener(_onLatestViewChange);
+ }
+
+ @override
+ void dispose() {
+ getIt().removeLatestViewListener(_onLatestViewChange);
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
- return BlocProvider(
- create: (_) => UserWorkspaceBloc(userProfile: userProfile)
- ..add(
- const UserWorkspaceEvent.initial(),
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider(
+ create: (_) => UserWorkspaceBloc(userProfile: widget.userProfile)
+ ..add(const UserWorkspaceEvent.initial()),
),
- child: BlocBuilder(
+ BlocProvider(
+ create: (context) =>
+ FavoriteBloc()..add(const FavoriteEvent.initial()),
+ ),
+ ],
+ child: BlocConsumer(
buildWhen: (previous, current) =>
previous.currentWorkspace?.workspaceId !=
current.currentWorkspace?.workspaceId,
+ listener: (context, state) => getIt().reset(),
builder: (context, state) {
if (state.currentWorkspace == null) {
return const SizedBox.shrink();
}
+
return Column(
children: [
// Header
Padding(
padding: EdgeInsets.only(
- left: 16,
- right: 16,
+ left: HomeSpaceViewSizes.mHorizontalPadding,
+ right: 8.0,
top: Platform.isAndroid ? 8.0 : 0.0,
),
child: MobileHomePageHeader(
- userProfile: userProfile,
+ userProfile: widget.userProfile,
),
),
- const Divider(),
- // Folder
Expanded(
- child: Scrollbar(
- child: SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 8.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- // Recent files
- const MobileRecentFolder(),
-
- // Folders
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24),
- child: MobileFolders(
- user: userProfile,
- workspaceId:
- state.currentWorkspace?.workspaceId ??
- workspaceSetting.workspaceId,
- showFavorite: false,
- ),
- ),
- const SizedBox(height: 8),
- const Padding(
- padding: EdgeInsets.symmetric(horizontal: 24),
- child: _TrashButton(),
- ),
- ],
- ),
- ),
+ child: BlocProvider(
+ create: (context) =>
+ SpaceOrderBloc()..add(const SpaceOrderEvent.initial()),
+ child: MobileSpaceTab(
+ userProfile: widget.userProfile,
),
),
),
@@ -153,26 +151,12 @@ class MobileHomePage extends StatelessWidget {
),
);
}
-}
-class _TrashButton extends StatelessWidget {
- const _TrashButton();
-
- @override
- Widget build(BuildContext context) {
- return FlowyButton(
- expand: true,
- margin: const EdgeInsets.symmetric(vertical: 8),
- leftIcon: FlowySvg(
- FlowySvgs.m_delete_m,
- color: Theme.of(context).colorScheme.onSurface,
- ),
- leftIconSize: const Size.square(24),
- text: FlowyText.medium(
- LocaleKeys.trash_text.tr(),
- fontSize: 18.0,
- ),
- onTap: () => context.push(MobileHomeTrashPage.routeName),
- );
+ void _onLatestViewChange() async {
+ final id = getIt().latestOpenView?.id;
+ if (id == null) {
+ return;
+ }
+ await FolderEventSetLatestView(ViewIdPB(value: id)).send();
}
}
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 be47ef1b32..5ae2ff1430 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
@@ -35,7 +35,7 @@ class MobileHomePageHeader extends StatelessWidget {
final isCollaborativeWorkspace =
context.read().state.isCollabWorkspaceOn;
return ConstrainedBox(
- constraints: const BoxConstraints(minHeight: 52),
+ constraints: const BoxConstraints(minHeight: 56),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -44,11 +44,14 @@ class MobileHomePageHeader extends StatelessWidget {
? _MobileWorkspace(userProfile: userProfile)
: _MobileUser(userProfile: userProfile),
),
- IconButton(
- onPressed: () => context.push(
+ GestureDetector(
+ onTap: () => context.push(
MobileHomeSettingPage.routeName,
),
- icon: const FlowySvg(FlowySvgs.m_setting_m),
+ child: const Padding(
+ padding: EdgeInsets.all(8.0),
+ child: FlowySvg(FlowySvgs.m_setting_m),
+ ),
),
],
),
@@ -119,7 +122,6 @@ class _MobileWorkspace extends StatelessWidget {
},
child: Row(
children: [
- const HSpace(2.0),
SizedBox.square(
dimension: 34.0,
child: WorkspaceIcon(
@@ -142,7 +144,7 @@ class _MobileWorkspace extends StatelessWidget {
children: [
Row(
children: [
- FlowyText.medium(
+ FlowyText.semibold(
currentWorkspace.name,
fontSize: 16.0,
overflow: TextOverflow.ellipsis,
@@ -151,7 +153,7 @@ class _MobileWorkspace extends StatelessWidget {
const FlowySvg(FlowySvgs.list_dropdown_s),
],
),
- FlowyText.medium(
+ FlowyText.regular(
userProfile.email.isNotEmpty
? userProfile.email
: userProfile.name,
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart
index fa585903f3..a2b9ae52c7 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart
@@ -38,7 +38,8 @@ class _MobileRecentFolderState extends State {
builder: (context, state) {
final ids = {};
- List recentViews = state.views.reversed.toList();
+ List recentViews =
+ state.views.reversed.map((e) => e.item).toList();
recentViews.retainWhere((element) => ids.add(element.id));
// only keep the first 20 items.
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart
new file mode 100644
index 0000000000..3cab831c23
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart
@@ -0,0 +1,96 @@
+import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart';
+import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
+import 'package:appflowy/workspace/application/recent/prelude.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MobileRecentSpace extends StatefulWidget {
+ const MobileRecentSpace({super.key});
+
+ @override
+ State createState() => _MobileRecentSpaceState();
+}
+
+class _MobileRecentSpaceState extends State
+ with AutomaticKeepAliveClientMixin {
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ return BlocProvider(
+ create: (context) =>
+ RecentViewsBloc()..add(const RecentViewsEvent.initial()),
+ child: BlocBuilder(
+ builder: (context, state) {
+ if (state.isLoading) {
+ return const SizedBox.shrink();
+ }
+
+ final recentViews = _filterRecentViews(state.views);
+
+ if (recentViews.isEmpty) {
+ return const Center(
+ child: EmptySpacePlaceholder(type: MobileViewCardType.recent),
+ );
+ }
+
+ return _RecentViews(recentViews: recentViews);
+ },
+ ),
+ );
+ }
+
+ List _filterRecentViews(List recentViews) {
+ final ids = {};
+ final filteredRecentViews = recentViews.reversed.toList();
+ filteredRecentViews.retainWhere((e) => ids.add(e.item.id));
+ return filteredRecentViews;
+ }
+}
+
+class _RecentViews extends StatelessWidget {
+ const _RecentViews({
+ required this.recentViews,
+ });
+
+ final List recentViews;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scrollbar(
+ child: ListView.separated(
+ key: const PageStorageKey('recent_views_page_storage_key'),
+ padding: const EdgeInsets.symmetric(
+ horizontal: HomeSpaceViewSizes.mHorizontalPadding,
+ ),
+ itemBuilder: (context, index) {
+ final sectionView = recentViews[index];
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 24.0),
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(
+ color: Theme.of(context).dividerColor,
+ width: 0.5,
+ ),
+ ),
+ ),
+ child: MobileViewCard(
+ key: ValueKey(sectionView.item.id),
+ view: sectionView.item,
+ timestamp: sectionView.timestamp,
+ type: MobileViewCardType.recent,
+ ),
+ );
+ },
+ separatorBuilder: (context, index) => const HSpace(8),
+ itemCount: recentViews.length,
+ ),
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart
index 4d9d109d3f..7a14a4d48d 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart
@@ -1,15 +1,12 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
-import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
-import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
-import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -36,29 +33,28 @@ class MobileSectionFolder extends StatelessWidget {
builder: (context, state) {
return Column(
children: [
- MobileSectionFolderHeader(
- title: title,
- isExpanded: context.read().state.isExpanded,
- onPressed: () => context
- .read()
- .add(const FolderEvent.expandOrUnExpand()),
- onAdded: () {
- context.read().add(
- SidebarSectionsEvent.createRootViewInSection(
- name:
- LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
- index: 0,
- viewSection: spaceType.toViewSectionPB,
- ),
- );
- context.read().add(
- const FolderEvent.expandOrUnExpand(isExpanded: true),
- );
- },
- ),
- const VSpace(8.0),
- const Divider(
- height: 1,
+ SizedBox(
+ height: HomeSpaceViewSizes.mViewHeight,
+ child: MobileSectionFolderHeader(
+ title: title,
+ isExpanded: context.read().state.isExpanded,
+ onPressed: () => context
+ .read()
+ .add(const FolderEvent.expandOrUnExpand()),
+ onAdded: () {
+ context.read().add(
+ SidebarSectionsEvent.createRootViewInSection(
+ name: LocaleKeys.menuAppHeader_defaultNewPageName
+ .tr(),
+ index: 0,
+ viewSection: spaceType.toViewSectionPB,
+ ),
+ );
+ context.read().add(
+ const FolderEvent.expandOrUnExpand(isExpanded: true),
+ );
+ },
+ ),
),
if (state.isExpanded)
...views.map(
@@ -73,16 +69,6 @@ class MobileSectionFolder extends StatelessWidget {
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
- endActionPane: (context) {
- final view = context.read().state.view;
- return buildEndActionPane(context, [
- MobilePaneActionType.delete,
- view.isFavorite
- ? MobilePaneActionType.removeFromFavorites
- : MobilePaneActionType.addToFavorites,
- MobilePaneActionType.more,
- ]);
- },
),
),
],
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart
index 3ba15df25d..49a9829d8a 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart
@@ -1,4 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -29,24 +30,23 @@ class _MobileSectionFolderHeaderState extends State {
@override
Widget build(BuildContext context) {
- const iconSize = 32.0;
return Row(
children: [
Expanded(
child: FlowyButton(
- text: FlowyText.semibold(
+ text: FlowyText.medium(
widget.title,
- fontSize: 20.0,
+ fontSize: 16.0,
),
margin: const EdgeInsets.symmetric(vertical: 8),
expandText: false,
+ iconPadding: 2,
mainAxisAlignment: MainAxisAlignment.start,
rightIcon: AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: _turns,
- child: const Icon(
- Icons.keyboard_arrow_down_rounded,
- color: Colors.grey,
+ child: const FlowySvg(
+ FlowySvgs.m_spaces_expand_s,
),
),
onTap: () {
@@ -60,12 +60,10 @@ class _MobileSectionFolderHeaderState extends State {
FlowyIconButton(
key: mobileCreateNewPageButtonKey,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
- iconPadding: const EdgeInsets.all(2),
- height: iconSize,
- width: iconSize,
+ height: HomeSpaceViewSizes.mViewButtonDimension,
+ width: HomeSpaceViewSizes.mViewButtonDimension,
icon: const FlowySvg(
- FlowySvgs.add_s,
- size: Size.square(iconSize),
+ FlowySvgs.m_space_add_s,
),
onPressed: widget.onAdded,
),
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart
new file mode 100644
index 0000000000..e59fa87538
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart
@@ -0,0 +1,55 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+class EmptySpacePlaceholder extends StatelessWidget {
+ const EmptySpacePlaceholder({super.key, required this.type});
+
+ final MobileViewCardType type;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 48.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const FlowySvg(
+ FlowySvgs.m_empty_page_xl,
+ ),
+ const VSpace(16.0),
+ FlowyText.medium(
+ _emptyPageText,
+ fontSize: 18.0,
+ textAlign: TextAlign.center,
+ ),
+ const VSpace(8.0),
+ FlowyText.regular(
+ _emptyPageSubText,
+ fontSize: 17.0,
+ maxLines: 10,
+ textAlign: TextAlign.center,
+ lineHeight: 1.3,
+ color: Theme.of(context).hintColor,
+ ),
+ ],
+ ),
+ );
+ }
+
+ String get _emptyPageText => switch (type) {
+ MobileViewCardType.recent => LocaleKeys.sideBar_emptyRecent.tr(),
+ MobileViewCardType.favorite => LocaleKeys.sideBar_emptyFavorite.tr(),
+ };
+
+ String get _emptyPageSubText => switch (type) {
+ MobileViewCardType.recent =>
+ LocaleKeys.sideBar_emptyRecentDescription.tr(),
+ MobileViewCardType.favorite =>
+ LocaleKeys.sideBar_emptyFavoriteDescription.tr(),
+ };
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart
new file mode 100644
index 0000000000..c4498a27cd
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart
@@ -0,0 +1,399 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/mobile/application/mobile_router.dart';
+import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
+import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
+import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
+import 'package:appflowy/shared/appflowy_network_image.dart';
+import 'package:appflowy/shared/flowy_gradient_colors.dart';
+import 'package:appflowy/util/string_extension.dart';
+import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
+import 'package:appflowy/workspace/application/view/view_bloc.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:fixnum/fixnum.dart';
+import 'package:flowy_infra/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:google_fonts/google_fonts.dart';
+import 'package:provider/provider.dart';
+import 'package:string_validator/string_validator.dart';
+
+enum MobileViewCardType {
+ recent,
+ favorite;
+
+ String get lastOperationHintText => switch (this) {
+ MobileViewCardType.recent => LocaleKeys.sideBar_lastViewed.tr(),
+ MobileViewCardType.favorite => LocaleKeys.sideBar_favoriteAt.tr(),
+ };
+}
+
+class MobileViewCard extends StatelessWidget {
+ const MobileViewCard({
+ super.key,
+ required this.view,
+ this.timestamp,
+ required this.type,
+ });
+
+ final ViewPB view;
+ final Int64? timestamp;
+ final MobileViewCardType type;
+
+ @override
+ Widget build(BuildContext context) {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider(
+ create: (context) => ViewBloc(view: view, shouldLoadChildViews: false)
+ ..add(const ViewEvent.initial()),
+ ),
+ BlocProvider(
+ create: (context) =>
+ RecentViewBloc(view: view)..add(const RecentViewEvent.initial()),
+ ),
+ ],
+ child: BlocBuilder(
+ builder: (context, state) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapUp: (_) => context.pushView(view),
+ onLongPressUp: () => _showActionSheet(context),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Expanded(child: _buildDescription(context, state)),
+ const HSpace(20.0),
+ SizedBox(
+ width: 84,
+ height: 60,
+ child: _buildCover(context, state),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildDescription(BuildContext context, RecentViewState state) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ // page icon & page title
+ _buildTitle(context, state),
+ const VSpace(12.0),
+ // author & last viewed
+ _buildNameAndLastViewed(context, state),
+ ],
+ );
+ }
+
+ Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) {
+ final supportAvatar = isURL(state.icon);
+ if (!supportAvatar) {
+ return _buildLastViewed(context);
+ }
+ return Row(
+ children: [
+ _buildAvatar(context, state),
+ Flexible(child: _buildAuthor(context, state)),
+ const Padding(
+ padding: EdgeInsets.symmetric(horizontal: 3.0),
+ child: FlowySvg(FlowySvgs.dot_s),
+ ),
+ _buildLastViewed(context),
+ ],
+ );
+ }
+
+ Widget _buildAvatar(BuildContext context, RecentViewState state) {
+ final userProfile = Provider.of(context);
+ final iconUrl = userProfile?.iconUrl;
+ if (iconUrl == null ||
+ iconUrl.isEmpty ||
+ view.createdBy != userProfile?.id) {
+ return const SizedBox.shrink();
+ }
+
+ return Padding(
+ padding: const EdgeInsets.only(top: 2, bottom: 2, right: 8),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8.0),
+ child: SizedBox.square(
+ dimension: 16.0,
+ child: FlowyNetworkImage(
+ url: iconUrl,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildCover(BuildContext context, RecentViewState state) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: _ViewCover(
+ coverTypeV1: state.coverTypeV1,
+ coverTypeV2: state.coverTypeV2,
+ value: state.coverValue,
+ ),
+ );
+ }
+
+ Widget _buildTitle(BuildContext context, RecentViewState state) {
+ final name = state.name;
+ final icon = state.icon;
+ final fontFamily = Platform.isAndroid || Platform.isLinux
+ ? GoogleFonts.notoColorEmoji().fontFamily
+ : null;
+ return RichText(
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ text: TextSpan(
+ children: [
+ TextSpan(
+ text: icon,
+ style: Theme.of(context).textTheme.bodyMedium!.copyWith(
+ fontSize: 17.0,
+ fontWeight: FontWeight.w600,
+ fontFamily: fontFamily,
+ ),
+ ),
+ if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
+ TextSpan(
+ text: name,
+ style: Theme.of(context).textTheme.bodyMedium!.copyWith(
+ fontSize: 16.0,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildAuthor(BuildContext context, RecentViewState state) {
+ return FlowyText.regular(
+ // view.createdBy.toString(),
+ 'Lucas',
+ fontSize: 12.0,
+ color: Theme.of(context).hintColor,
+ overflow: TextOverflow.ellipsis,
+ );
+ }
+
+ Widget _buildLastViewed(BuildContext context) {
+ if (timestamp == null) {
+ return const SizedBox.shrink();
+ }
+ final date = _formatTimestamp(
+ timestamp!.toInt() * 1000,
+ );
+ return FlowyText.regular(
+ date,
+ fontSize: 12.0,
+ color: Theme.of(context).hintColor,
+ );
+ }
+
+ String _formatTimestamp(int timestamp) {
+ final now = DateTime.now();
+ final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
+ final difference = now.difference(dateTime);
+ final String date;
+
+ if (difference.inMinutes < 1) {
+ date = LocaleKeys.sideBar_justNow.tr();
+ } else if (difference.inHours < 1) {
+ // Less than 1 hour
+ date = LocaleKeys.sideBar_minutesAgo
+ .tr(namedArgs: {'count': difference.inMinutes.toString()});
+ } else if (difference.inHours >= 1 && difference.inHours < 24) {
+ // Between 1 hour and 24 hours
+ date = DateFormat('h:mm a').format(dateTime);
+ } else if (difference.inDays >= 1 && dateTime.year == now.year) {
+ // More than 24 hours but within the current year
+ date = DateFormat('M/d, h:mm a').format(dateTime);
+ } else {
+ // Other cases (previous years)
+ date = DateFormat('M/d/yyyy, h:mm a').format(dateTime);
+ }
+
+ if (difference.inHours >= 1) {
+ return '${type.lastOperationHintText} $date';
+ }
+
+ return date;
+ }
+
+ Future _showActionSheet(BuildContext context) async {
+ final viewBloc = context.read();
+ final favoriteBloc = context.read();
+ final recentViewsBloc = context.read();
+ await showMobileBottomSheet(
+ context,
+ showDragHandle: true,
+ showDivider: false,
+ backgroundColor: AFThemeExtension.of(context).background,
+ useRootNavigator: true,
+ builder: (context) {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: viewBloc),
+ BlocProvider.value(value: favoriteBloc),
+ if (recentViewsBloc != null)
+ BlocProvider.value(value: recentViewsBloc),
+ ],
+ child: BlocBuilder(
+ builder: (context, state) {
+ final isFavorite = state.view.isFavorite;
+ return MobileViewItemBottomSheet(
+ view: viewBloc.state.view,
+ actions: _buildActions(isFavorite),
+ );
+ },
+ ),
+ );
+ },
+ );
+ }
+
+ List _buildActions(bool isFavorite) {
+ switch (type) {
+ case MobileViewCardType.recent:
+ return [
+ isFavorite
+ ? MobileViewItemBottomSheetBodyAction.removeFromFavorites
+ : MobileViewItemBottomSheetBodyAction.addToFavorites,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.duplicate,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.removeFromRecent,
+ ];
+ case MobileViewCardType.favorite:
+ return [
+ MobileViewItemBottomSheetBodyAction.removeFromFavorites,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.duplicate,
+ ];
+ }
+ }
+}
+
+class _ViewCover extends StatelessWidget {
+ const _ViewCover({
+ required this.coverTypeV1,
+ this.coverTypeV2,
+ this.value,
+ });
+
+ final CoverType coverTypeV1;
+ final PageStyleCoverImageType? coverTypeV2;
+ final String? value;
+
+ @override
+ Widget build(BuildContext context) {
+ final placeholder = Container(
+ color: const Color(0xFFE1FBFF),
+ );
+ final value = this.value;
+ if (value == null) {
+ return placeholder;
+ }
+ if (coverTypeV2 != null) {
+ return _buildCoverV2(context, value, placeholder);
+ }
+ return _buildCoverV1(context, value, placeholder);
+ }
+
+ Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) {
+ final type = coverTypeV2;
+ if (type == null) {
+ return placeholder;
+ }
+ if (type == PageStyleCoverImageType.customImage ||
+ type == PageStyleCoverImageType.unsplashImage) {
+ final userProfilePB = Provider.of(context);
+ return FlowyNetworkImage(
+ url: value,
+ userProfilePB: userProfilePB,
+ );
+ }
+
+ if (type == PageStyleCoverImageType.builtInImage) {
+ return Image.asset(
+ PageStyleCoverImageType.builtInImagePath(value),
+ fit: BoxFit.cover,
+ );
+ }
+
+ if (type == PageStyleCoverImageType.pureColor) {
+ final color = value.coverColor(context);
+ if (color != null) {
+ return ColoredBox(
+ color: color,
+ );
+ }
+ }
+
+ if (type == PageStyleCoverImageType.gradientColor) {
+ return Container(
+ decoration: BoxDecoration(
+ gradient: FlowyGradientColor.fromId(value).linear,
+ ),
+ );
+ }
+
+ if (type == PageStyleCoverImageType.localImage) {
+ return Image.file(
+ File(value),
+ fit: BoxFit.cover,
+ );
+ }
+
+ return placeholder;
+ }
+
+ Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) {
+ switch (coverTypeV1) {
+ case CoverType.file:
+ if (isURL(value)) {
+ final userProfilePB = Provider.of(context);
+ return FlowyNetworkImage(
+ url: value,
+ userProfilePB: userProfilePB,
+ );
+ }
+ final imageFile = File(value);
+ if (!imageFile.existsSync()) {
+ return placeholder;
+ }
+ return Image.file(
+ imageFile,
+ );
+ case CoverType.asset:
+ return Image.asset(
+ value,
+ fit: BoxFit.cover,
+ );
+ case CoverType.color:
+ final color = value.tryToColor() ?? Colors.white;
+ return Container(
+ color: color,
+ );
+ case CoverType.none:
+ return placeholder;
+ }
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart
new file mode 100644
index 0000000000..1a3eb121f3
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart
@@ -0,0 +1,101 @@
+import 'package:flutter/material.dart';
+
+class RoundUnderlineTabIndicator extends Decoration {
+ const RoundUnderlineTabIndicator({
+ this.borderRadius,
+ this.borderSide = const BorderSide(width: 2.0, color: Colors.white),
+ this.insets = EdgeInsets.zero,
+ required this.width,
+ });
+
+ final BorderRadius? borderRadius;
+ final BorderSide borderSide;
+ final EdgeInsetsGeometry insets;
+ final double width;
+
+ @override
+ Decoration? lerpFrom(Decoration? a, double t) {
+ if (a is UnderlineTabIndicator) {
+ return UnderlineTabIndicator(
+ borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
+ insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!,
+ );
+ }
+ return super.lerpFrom(a, t);
+ }
+
+ @override
+ Decoration? lerpTo(Decoration? b, double t) {
+ if (b is UnderlineTabIndicator) {
+ return UnderlineTabIndicator(
+ borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
+ insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!,
+ );
+ }
+ return super.lerpTo(b, t);
+ }
+
+ @override
+ BoxPainter createBoxPainter([VoidCallback? onChanged]) {
+ return _UnderlinePainter(this, borderRadius, onChanged);
+ }
+
+ Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {
+ final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
+ final center = indicator.center.dx;
+ return Rect.fromLTWH(
+ center - width / 2.0,
+ indicator.bottom - borderSide.width,
+ width,
+ borderSide.width,
+ );
+ }
+
+ @override
+ Path getClipPath(Rect rect, TextDirection textDirection) {
+ if (borderRadius != null) {
+ return Path()
+ ..addRRect(
+ borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)),
+ );
+ }
+ return Path()..addRect(_indicatorRectFor(rect, textDirection));
+ }
+}
+
+class _UnderlinePainter extends BoxPainter {
+ _UnderlinePainter(
+ this.decoration,
+ this.borderRadius,
+ super.onChanged,
+ );
+
+ final RoundUnderlineTabIndicator decoration;
+ final BorderRadius? borderRadius;
+
+ @override
+ void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
+ assert(configuration.size != null);
+ final Rect rect = offset & configuration.size!;
+ final TextDirection textDirection = configuration.textDirection!;
+ final Paint paint;
+ if (borderRadius != null) {
+ paint = Paint()..color = decoration.borderSide.color;
+ final Rect indicator = decoration._indicatorRectFor(rect, textDirection);
+ final RRect rrect = RRect.fromRectAndCorners(
+ indicator,
+ topLeft: borderRadius!.topLeft,
+ topRight: borderRadius!.topRight,
+ bottomRight: borderRadius!.bottomRight,
+ bottomLeft: borderRadius!.bottomLeft,
+ );
+ canvas.drawRRect(rrect, paint);
+ } else {
+ paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.round;
+ final Rect indicator = decoration
+ ._indicatorRectFor(rect, textDirection)
+ .deflate(decoration.borderSide.width / 2.0);
+ canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint);
+ }
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart
new file mode 100644
index 0000000000..adf13ed667
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart
@@ -0,0 +1,57 @@
+import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
+import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
+import 'package:flutter/material.dart';
+import 'package:reorderable_tabbar/reorderable_tabbar.dart';
+
+class MobileSpaceTabBar extends StatelessWidget {
+ const MobileSpaceTabBar({
+ super.key,
+ this.height = 38.0,
+ required this.tabController,
+ required this.tabs,
+ required this.onReorder,
+ });
+
+ final double height;
+ final List tabs;
+ final TabController tabController;
+ final OnReorder onReorder;
+
+ @override
+ Widget build(BuildContext context) {
+ final baseStyle = Theme.of(context).textTheme.bodyMedium;
+ final labelStyle = baseStyle?.copyWith(
+ fontWeight: FontWeight.w500,
+ fontSize: 15.0,
+ );
+ final unselectedLabelStyle = baseStyle?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 15.0,
+ );
+
+ return Container(
+ height: height,
+ padding: const EdgeInsets.only(left: 8.0),
+ child: ReorderableTabBar(
+ controller: tabController,
+ tabs: tabs.map((e) => Tab(text: e.tr)).toList(),
+ indicatorSize: TabBarIndicatorSize.label,
+ indicatorColor: Theme.of(context).primaryColor,
+ isScrollable: true,
+ labelStyle: labelStyle,
+ labelColor: baseStyle?.color,
+ labelPadding: const EdgeInsets.symmetric(horizontal: 12.0),
+ unselectedLabelStyle: unselectedLabelStyle,
+ overlayColor: WidgetStateProperty.all(Colors.transparent),
+ indicator: RoundUnderlineTabIndicator(
+ width: 28.0,
+ borderSide: BorderSide(
+ color: Theme.of(context).primaryColor,
+ width: 3,
+ ),
+ ),
+ onReorder: onReorder,
+ ),
+ );
+ }
+}
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
new file mode 100644
index 0000000000..097bd22910
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart
@@ -0,0 +1,107 @@
+import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart';
+import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart';
+import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart';
+import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart';
+import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:provider/provider.dart';
+
+class MobileSpaceTab extends StatefulWidget {
+ const MobileSpaceTab({super.key, required this.userProfile});
+
+ final UserProfilePB userProfile;
+
+ @override
+ State createState() => _MobileSpaceTabState();
+}
+
+class _MobileSpaceTabState extends State
+ with SingleTickerProviderStateMixin {
+ TabController? tabController;
+
+ @override
+ void dispose() {
+ tabController?.removeListener(_onTabChange);
+ tabController?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Provider.value(
+ value: widget.userProfile,
+ child: BlocBuilder(
+ builder: (context, state) {
+ if (state.isLoading) {
+ return const SizedBox.shrink();
+ }
+
+ _initTabController(state);
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ MobileSpaceTabBar(
+ tabController: tabController!,
+ tabs: state.tabsOrder,
+ onReorder: (from, to) {
+ context.read().add(
+ SpaceOrderEvent.reorder(from, to),
+ );
+ },
+ ),
+ const HSpace(12.0),
+ Expanded(
+ child: TabBarView(
+ controller: tabController,
+ children: _buildTabs(state),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ void _initTabController(SpaceOrderState state) {
+ if (tabController != null) {
+ return;
+ }
+ tabController = TabController(
+ length: state.tabsOrder.length,
+ vsync: this,
+ initialIndex: state.tabsOrder.indexOf(state.defaultTab),
+ );
+ tabController?.addListener(_onTabChange);
+ }
+
+ void _onTabChange() {
+ if (tabController == null) {
+ return;
+ }
+ context.read().add(
+ SpaceOrderEvent.open(
+ tabController!.index,
+ ),
+ );
+ }
+
+ List _buildTabs(SpaceOrderState state) {
+ return state.tabsOrder.map((tab) {
+ switch (tab) {
+ case MobileSpaceTabType.recent:
+ return const MobileRecentSpace();
+ case MobileSpaceTabType.spaces:
+ return MobileHomeSpace(userProfile: widget.userProfile);
+ case MobileSpaceTabType.favorites:
+ return MobileFavoriteSpace(userProfile: widget.userProfile);
+ default:
+ throw Exception('Unknown tab type: $tab');
+ }
+ }).toList();
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart
new file mode 100644
index 0000000000..e3c1439dd4
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart
@@ -0,0 +1,127 @@
+import 'dart:convert';
+
+import 'package:appflowy/core/config/kv.dart';
+import 'package:appflowy/core/config/kv_keys.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:bloc/bloc.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'space_order_bloc.freezed.dart';
+
+enum MobileSpaceTabType {
+ // DO NOT CHANGE THE ORDER
+ spaces,
+ recent,
+ favorites;
+
+ String get tr {
+ switch (this) {
+ case MobileSpaceTabType.recent:
+ return LocaleKeys.sideBar_RecentSpace.tr();
+ case MobileSpaceTabType.spaces:
+ return LocaleKeys.sideBar_Spaces.tr();
+ case MobileSpaceTabType.favorites:
+ return LocaleKeys.sideBar_favoriteSpace.tr();
+ }
+ }
+}
+
+class SpaceOrderBloc extends Bloc {
+ SpaceOrderBloc() : super(const SpaceOrderState()) {
+ on(
+ (event, emit) async {
+ await event.when(
+ initial: () async {
+ final tabsOrder = await _getTabsOrder();
+ final defaultTab = await _getDefaultTab();
+ emit(
+ state.copyWith(
+ tabsOrder: tabsOrder,
+ defaultTab: defaultTab,
+ isLoading: false,
+ ),
+ );
+ },
+ open: (index) async {
+ final tab = state.tabsOrder[index];
+ await _setDefaultTab(tab);
+ },
+ reorder: (from, to) async {
+ final tabsOrder = List.of(state.tabsOrder);
+ tabsOrder.insert(to, tabsOrder.removeAt(from));
+ await _setTabsOrder(tabsOrder);
+ emit(state.copyWith(tabsOrder: tabsOrder));
+ },
+ );
+ },
+ );
+ }
+
+ final _storage = getIt();
+
+ Future _getDefaultTab() async {
+ try {
+ return await _storage.getWithFormat(
+ KVKeys.lastOpenedSpace, (value) {
+ return MobileSpaceTabType.values[int.parse(value)];
+ }) ??
+ MobileSpaceTabType.spaces;
+ } catch (e) {
+ return MobileSpaceTabType.spaces;
+ }
+ }
+
+ Future _setDefaultTab(MobileSpaceTabType tab) async {
+ await _storage.set(
+ KVKeys.lastOpenedSpace,
+ tab.index.toString(),
+ );
+ }
+
+ Future> _getTabsOrder() async {
+ try {
+ return await _storage.getWithFormat>(
+ KVKeys.spaceOrder, (value) {
+ final order = jsonDecode(value).cast();
+ if (order.isEmpty) {
+ return MobileSpaceTabType.values;
+ }
+ return order
+ .map((e) => MobileSpaceTabType.values[e])
+ .cast()
+ .toList();
+ }) ??
+ MobileSpaceTabType.values;
+ } catch (e) {
+ return MobileSpaceTabType.values;
+ }
+ }
+
+ Future _setTabsOrder(List tabsOrder) async {
+ await _storage.set(
+ KVKeys.spaceOrder,
+ jsonEncode(tabsOrder.map((e) => e.index).toList()),
+ );
+ }
+}
+
+@freezed
+class SpaceOrderEvent with _$SpaceOrderEvent {
+ const factory SpaceOrderEvent.initial() = Initial;
+ const factory SpaceOrderEvent.open(int index) = Open;
+ const factory SpaceOrderEvent.reorder(int from, int to) = Reorder;
+}
+
+@freezed
+class SpaceOrderState with _$SpaceOrderState {
+ const factory SpaceOrderState({
+ @Default(MobileSpaceTabType.spaces) MobileSpaceTabType defaultTab,
+ @Default(MobileSpaceTabType.values) List tabsOrder,
+ @Default(true) bool isLoading,
+ }) = _SpaceOrderState;
+
+ factory SpaceOrderState.initial() => const SpaceOrderState();
+}
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 101a546294..411623cb87 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart
@@ -1,7 +1,37 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/mobile/application/mobile_router.dart';
+import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
+import 'package:appflowy/workspace/application/workspace/workspace_service.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:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+const _homeLabel = 'home';
+const _addLabel = 'add';
+const _notificationLabel = 'notification';
+final _items = [
+ const BottomNavigationBarItem(
+ label: _homeLabel,
+ icon: FlowySvg(FlowySvgs.m_home_unselected_m),
+ activeIcon: FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null),
+ ),
+ const BottomNavigationBarItem(
+ label: _addLabel,
+ icon: FlowySvg(FlowySvgs.m_home_add_m),
+ ),
+ const BottomNavigationBarItem(
+ label: _notificationLabel,
+ icon: FlowySvg(FlowySvgs.m_home_notification_m),
+ activeIcon: FlowySvg(
+ FlowySvgs.m_home_notification_m,
+ ),
+ ),
+];
+
/// Builds the "shell" for the app by building a Scaffold with a
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
class MobileBottomNavigationBar extends StatelessWidget {
@@ -16,53 +46,23 @@ class MobileBottomNavigationBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final style = Theme.of(context);
-
return Scaffold(
body: navigationShell,
- bottomNavigationBar: BottomNavigationBar(
- showSelectedLabels: false,
- showUnselectedLabels: false,
- enableFeedback: true,
- type: BottomNavigationBarType.fixed,
- items: [
- BottomNavigationBarItem(
- // There is no text shown on the bottom navigation bar, but Exception will be thrown if label is null here.
- label: 'home',
- icon: const FlowySvg(FlowySvgs.m_home_unselected_lg),
- activeIcon: FlowySvg(
- FlowySvgs.m_home_selected_lg,
- color: style.colorScheme.primary,
- ),
- ),
- const BottomNavigationBarItem(
- label: 'favorite',
- icon: FlowySvg(FlowySvgs.m_favorite_unselected_lg),
- activeIcon: FlowySvg(
- FlowySvgs.m_favorite_selected_lg,
- blendMode: null,
- ),
- ),
- // Enable this when search is ready.
- // BottomNavigationBarItem(
- // label: 'search',
- // icon: const FlowySvg(FlowySvgs.m_search_lg),
- // activeIcon: FlowySvg(
- // FlowySvgs.m_search_lg,
- // color: style.colorScheme.primary,
- // ),
- // ),
- BottomNavigationBarItem(
- label: 'notification',
- icon: const FlowySvg(FlowySvgs.m_notification_unselected_lg),
- activeIcon: FlowySvg(
- FlowySvgs.m_notification_selected_lg,
- color: style.colorScheme.primary,
- ),
- ),
- ],
- currentIndex: navigationShell.currentIndex,
- onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
+ bottomNavigationBar: Theme(
+ data: Theme.of(context).copyWith(
+ splashColor: Colors.transparent,
+ highlightColor: Colors.transparent,
+ ),
+ child: BottomNavigationBar(
+ showSelectedLabels: false,
+ showUnselectedLabels: false,
+ enableFeedback: false,
+ type: BottomNavigationBarType.fixed,
+ elevation: 0,
+ items: _items,
+ currentIndex: navigationShell.currentIndex,
+ onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
+ ),
),
);
}
@@ -70,6 +70,11 @@ class MobileBottomNavigationBar extends StatelessWidget {
/// Navigate to the current location of the branch at the provided index when
/// tapping an item in the BottomNavigationBar.
void _onTap(BuildContext context, int bottomBarIndex) {
+ if (_items[bottomBarIndex].label == _addLabel) {
+ // show an add dialog
+ _showCreatePageBottomSheet(context);
+ return;
+ }
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the
// Navigator for the branch is restored.
@@ -82,4 +87,40 @@ class MobileBottomNavigationBar extends StatelessWidget {
initialLocation: bottomBarIndex == navigationShell.currentIndex,
);
}
+
+ void _showCreatePageBottomSheet(BuildContext context) {
+ showMobileBottomSheet(
+ context,
+ showHeader: true,
+ title: LocaleKeys.sideBar_addAPage.tr(),
+ showDragHandle: true,
+ showCloseButton: true,
+ useRootNavigator: true,
+ builder: (sheetContext) {
+ return AddNewPageWidgetBottomSheet(
+ view: ViewPB(),
+ onAction: (layout) async {
+ Navigator.of(sheetContext).pop();
+ final currentWorkspaceId =
+ await FolderEventReadCurrentWorkspace().send();
+ await currentWorkspaceId.fold((s) async {
+ final workspaceService = WorkspaceService(workspaceId: s.id);
+ final result = await workspaceService.createView(
+ name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
+ viewSection: ViewSectionPB.Private,
+ layout: layout,
+ );
+ result.fold((s) {
+ context.pushView(s);
+ }, (e) {
+ Log.error('Failed to create new page: $e');
+ });
+ }, (e) {
+ Log.error('Failed to read current workspace: $e');
+ });
+ },
+ );
+ },
+ );
+ }
}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart
index 862ce794b6..e21ff3be20 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart
@@ -1,11 +1,13 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item_add_button.dart';
-import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
+import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -17,8 +19,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
typedef ViewItemOnSelected = void Function(ViewPB);
typedef ActionPaneBuilder = ActionPane Function(BuildContext context);
-const _itemHeight = 48.0;
-
class MobileViewItem extends StatelessWidget {
const MobileViewItem({
super.key,
@@ -177,48 +177,10 @@ class InnerMobileViewItem extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
child,
- const Divider(
- height: 1,
- ),
...children,
],
);
- } else {
- child = Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- child,
- const Divider(
- height: 1,
- ),
- Container(
- height: _itemHeight,
- alignment: Alignment.centerLeft,
- child: Padding(
- padding: EdgeInsets.only(left: (level + 2) * leftPadding),
- child: FlowyText.medium(
- LocaleKeys.noPagesInside.tr(),
- color: Colors.grey,
- ),
- ),
- ),
- const Divider(
- height: 1,
- ),
- ],
- );
}
- } else {
- child = Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- child,
- const Divider(
- height: 1,
- ),
- ],
- );
}
// wrap the child with DraggableItem if isDraggable is true
@@ -226,7 +188,6 @@ class InnerMobileViewItem extends StatelessWidget {
child = DraggableViewItem(
isFirstChild: isFirstChild,
view: view,
- // FIXME: use better color
centerHighlightColor: Colors.blue.shade200,
topHighlightColor: Colors.blue.shade200,
bottomHighlightColor: Colors.blue.shade200,
@@ -296,15 +257,14 @@ class _SingleMobileInnerViewItemState extends State {
final children = [
// expand icon
_buildLeftIcon(),
- const HSpace(4),
// icon
_buildViewIcon(),
const HSpace(8),
// title
Expanded(
- child: FlowyText.medium(
+ child: FlowyText.regular(
widget.view.name,
- fontSize: 18.0,
+ fontSize: 16.0,
overflow: TextOverflow.ellipsis,
),
),
@@ -313,10 +273,11 @@ class _SingleMobileInnerViewItemState extends State {
// hover action
// ··· more action button
- // children.add(_buildViewMoreActionButton(context));
+ children.add(_buildViewMoreButton(context));
// only support add button for document layout
if (!widget.isFeedback && widget.view.layout == ViewLayoutPB.Document) {
// + button
+
children.add(_buildViewAddButton(context));
}
@@ -324,7 +285,7 @@ class _SingleMobileInnerViewItemState extends State {
borderRadius: BorderRadius.circular(4.0),
onTap: () => widget.onSelected(widget.view),
child: SizedBox(
- height: _itemHeight,
+ height: HomeSpaceViewSizes.mViewHeight,
child: Padding(
padding: EdgeInsets.only(left: widget.level * widget.leftPadding),
child: Row(
@@ -349,12 +310,12 @@ class _SingleMobileInnerViewItemState extends State {
Widget _buildViewIcon() {
final icon = widget.view.icon.value.isNotEmpty
- ? EmojiText(
- emoji: widget.view.icon.value,
- fontSize: 24.0,
+ ? FlowyText.emoji(
+ widget.view.icon.value,
+ fontSize: 20.0,
)
: SizedBox.square(
- dimension: 26.0,
+ dimension: 18.0,
child: widget.view.defaultIcon(),
);
return icon;
@@ -364,17 +325,17 @@ class _SingleMobileInnerViewItemState extends State {
// show > if the view is expandable.
// show · if the view can't contain child views.
Widget _buildLeftIcon() {
- if (isReferencedDatabaseView(widget.view, widget.parentView)) {
- return const _DotIconWidget();
+ if (context.read().state.view.childViews.isEmpty) {
+ return HSpace(widget.leftPadding);
}
return GestureDetector(
- child: AnimatedRotation(
- duration: const Duration(milliseconds: 250),
- turns: widget.isExpanded ? 0 : -0.25,
- child: const Icon(
- Icons.keyboard_arrow_down_rounded,
- size: 28,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.only(right: 6.0, top: 6.0, bottom: 6.0),
+ child: FlowySvg(
+ widget.isExpanded ? FlowySvgs.m_expand_s : FlowySvgs.m_collapse_s,
+ blendMode: null,
),
),
onTap: () {
@@ -418,23 +379,49 @@ class _SingleMobileInnerViewItemState extends State {
},
);
}
-}
-class _DotIconWidget extends StatelessWidget {
- const _DotIconWidget();
+ // + button
+ Widget _buildViewMoreButton(BuildContext context) {
+ return MobileViewMoreButton(onPressed: () => _showMoreActions(context));
+ }
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.all(6.0),
- child: Container(
- width: 4,
- height: 4,
- decoration: BoxDecoration(
- color: Theme.of(context).iconTheme.color,
- borderRadius: BorderRadius.circular(2),
- ),
- ),
+ Future _showMoreActions(BuildContext context) async {
+ final viewBloc = context.read();
+ final favoriteBloc = context.read();
+ await showMobileBottomSheet(
+ context,
+ showHeader: true,
+ title: widget.view.name,
+ showDragHandle: true,
+ showCloseButton: true,
+ useRootNavigator: true,
+ builder: (context) {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: viewBloc),
+ BlocProvider.value(value: favoriteBloc),
+ ],
+ child: BlocBuilder(
+ builder: (context, state) {
+ final isFavorite = state.view.isFavorite;
+ return MobileViewItemBottomSheet(
+ view: viewBloc.state.view,
+ actions: [
+ isFavorite
+ ? MobileViewItemBottomSheetBodyAction.removeFromFavorites
+ : MobileViewItemBottomSheetBodyAction.addToFavorites,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.rename,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.duplicate,
+ MobileViewItemBottomSheetBodyAction.divider,
+ MobileViewItemBottomSheetBodyAction.delete,
+ ],
+ );
+ },
+ ),
+ );
+ },
);
}
}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart
index eb2f8ea9f8..77bb57773f 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart
@@ -1,9 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
-const _iconSize = 32.0;
-
class MobileViewAddButton extends StatelessWidget {
const MobileViewAddButton({
super.key,
@@ -15,12 +14,31 @@ class MobileViewAddButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyIconButton(
- iconPadding: const EdgeInsets.all(2),
- width: _iconSize,
- height: _iconSize,
+ width: HomeSpaceViewSizes.mViewButtonDimension,
+ height: HomeSpaceViewSizes.mViewButtonDimension,
icon: const FlowySvg(
- FlowySvgs.add_s,
- size: Size.square(_iconSize),
+ FlowySvgs.m_space_add_s,
+ ),
+ onPressed: onPressed,
+ );
+ }
+}
+
+class MobileViewMoreButton extends StatelessWidget {
+ const MobileViewMoreButton({
+ super.key,
+ required this.onPressed,
+ });
+
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ return FlowyIconButton(
+ width: HomeSpaceViewSizes.mViewButtonDimension,
+ height: HomeSpaceViewSizes.mViewButtonDimension,
+ icon: const FlowySvg(
+ FlowySvgs.m_space_more_s,
),
onPressed: onPressed,
);
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 ba2d2b00cc..5be3d3e78c 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
@@ -1,7 +1,6 @@
-import 'package:flutter/material.dart';
-
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
class MobileQuickActionButton extends StatelessWidget {
const MobileQuickActionButton({
@@ -32,20 +31,20 @@ class MobileQuickActionButton extends StatelessWidget {
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
splashColor: Colors.transparent,
child: Container(
- height: 44,
+ height: 52,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FlowySvg(
icon,
- size: const Size.square(20),
+ size: const Size.square(18),
color: enable ? iconColor : Theme.of(context).disabledColor,
),
const HSpace(12),
Expanded(
- child: FlowyText(
+ child: FlowyText.regular(
text,
- fontSize: 15,
+ fontSize: 16,
color: enable ? textColor : Theme.of(context).disabledColor,
),
),
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 5a481eaa68..321632a36a 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
@@ -1,6 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
enum ConfirmDialogActionAlignment {
@@ -85,3 +87,46 @@ Future showFlowyMobileConfirmDialog(
},
);
}
+
+Future showFlowyCupertinoConfirmDialog({
+ BuildContext? context,
+ required String title,
+ required Widget leftButton,
+ required Widget rightButton,
+ void Function(BuildContext context)? onLeftButtonPressed,
+ void Function(BuildContext context)? onRightButtonPressed,
+}) {
+ return showDialog(
+ context: context ?? AppGlobals.context,
+ builder: (context) => CupertinoAlertDialog(
+ title: FlowyText.medium(
+ title,
+ fontSize: 18,
+ maxLines: 10,
+ lineHeight: 1.3,
+ ),
+ actions: [
+ CupertinoDialogAction(
+ onPressed: () {
+ if (onLeftButtonPressed != null) {
+ onLeftButtonPressed(context);
+ } else {
+ Navigator.of(context).pop();
+ }
+ },
+ child: leftButton,
+ ),
+ CupertinoDialogAction(
+ onPressed: () {
+ if (onRightButtonPressed != null) {
+ onRightButtonPressed(context);
+ } else {
+ Navigator.of(context).pop();
+ }
+ },
+ child: rightButton,
+ ),
+ ],
+ ),
+ );
+}
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
new file mode 100644
index 0000000000..2c5c8f5b0e
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart
@@ -0,0 +1,42 @@
+import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_chat_types/flutter_chat_types.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'chat_ai_message_bloc.freezed.dart';
+
+class ChatAIMessageBloc extends Bloc {
+ ChatAIMessageBloc({
+ required Message message,
+ }) : super(ChatAIMessageState.initial(message)) {
+ on(
+ (event, emit) async {
+ await event.when(
+ initial: () async {},
+ update: (userProfile, deviceId, states) {},
+ );
+ },
+ );
+ }
+}
+
+@freezed
+class ChatAIMessageEvent with _$ChatAIMessageEvent {
+ const factory ChatAIMessageEvent.initial() = Initial;
+ const factory ChatAIMessageEvent.update(
+ UserProfilePB userProfile,
+ String deviceId,
+ DocumentAwarenessStatesPB states,
+ ) = Update;
+}
+
+@freezed
+class ChatAIMessageState with _$ChatAIMessageState {
+ const factory ChatAIMessageState({
+ required Message message,
+ }) = _ChatAIMessageState;
+
+ factory ChatAIMessageState.initial(Message message) =>
+ ChatAIMessageState(message: message);
+}
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
new file mode 100644
index 0000000000..17eaad8c92
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart
@@ -0,0 +1,423 @@
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
+import 'package:collection/collection.dart';
+import 'package:fixnum/fixnum.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_chat_types/flutter_chat_types.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:nanoid/nanoid.dart';
+import 'chat_message_listener.dart';
+
+part 'chat_bloc.freezed.dart';
+
+const canRetryKey = "canRetry";
+const sendMessageErrorKey = "sendMessageError";
+
+class ChatBloc extends Bloc {
+ ChatBloc({
+ required ViewPB view,
+ required UserProfilePB userProfile,
+ }) : listener = ChatMessageListener(chatId: view.id),
+ chatId = view.id,
+ super(
+ ChatState.initial(view, userProfile),
+ ) {
+ _dispatch();
+
+ listener.start(
+ chatMessageCallback: _handleChatMessage,
+ lastUserSentMessageCallback: (message) {
+ if (!isClosed) {
+ add(ChatEvent.didSentUserMessage(message));
+ }
+ },
+ chatErrorMessageCallback: (err) {
+ if (!isClosed) {
+ Log.error("chat error: ${err.errorMessage}");
+ final metadata = OnetimeShotType.serverStreamError.toMap();
+ if (state.lastSentMessage != null) {
+ metadata[canRetryKey] = "true";
+ }
+ final error = CustomMessage(
+ metadata: metadata,
+ author: const User(id: "system"),
+ id: 'system',
+ );
+ add(ChatEvent.streaming([error]));
+ add(const ChatEvent.didFinishStreaming());
+ }
+ },
+ latestMessageCallback: (list) {
+ if (!isClosed) {
+ final messages = list.messages.map(_createChatMessage).toList();
+ add(ChatEvent.didLoadLatestMessages(messages));
+ }
+ },
+ prevMessageCallback: (list) {
+ if (!isClosed) {
+ final messages = list.messages.map(_createChatMessage).toList();
+ add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
+ }
+ },
+ finishAnswerQuestionCallback: () {
+ if (!isClosed) {
+ add(const ChatEvent.didFinishStreaming());
+ if (state.lastSentMessage != null) {
+ final payload = ChatMessageIdPB(
+ chatId: chatId,
+ messageId: state.lastSentMessage!.messageId,
+ );
+ // When user message was sent to the server, we start gettting related question
+ ChatEventGetRelatedQuestion(payload).send().then((result) {
+ if (!isClosed) {
+ result.fold(
+ (list) {
+ add(
+ ChatEvent.didReceiveRelatedQuestion(list.items),
+ );
+ },
+ (err) {
+ Log.error("Failed to get related question: $err");
+ },
+ );
+ }
+ });
+ }
+ }
+ },
+ );
+ }
+
+ final ChatMessageListener listener;
+ final String chatId;
+
+ @override
+ Future close() {
+ listener.stop();
+ return super.close();
+ }
+
+ void _dispatch() {
+ on(
+ (event, emit) async {
+ await event.when(
+ initialLoad: () {
+ final payload = LoadNextChatMessagePB(
+ chatId: state.view.id,
+ limit: Int64(10),
+ );
+ ChatEventLoadNextMessage(payload).send();
+ },
+ startLoadingPrevMessage: () async {
+ Int64? beforeMessageId;
+ if (state.messages.isNotEmpty) {
+ beforeMessageId = Int64.parseInt(state.messages.last.id);
+ }
+ _loadPrevMessage(beforeMessageId);
+ emit(
+ state.copyWith(
+ loadingPreviousStatus: const LoadingState.loading(),
+ ),
+ );
+ },
+ didLoadPreviousMessages: (List messages, bool hasMore) {
+ Log.debug("did load previous messages: ${messages.length}");
+ final uniqueMessages = {...state.messages, ...messages}.toList()
+ ..sort((a, b) => b.id.compareTo(a.id));
+ emit(
+ state.copyWith(
+ messages: uniqueMessages,
+ loadingPreviousStatus: const LoadingState.finish(),
+ hasMorePrevMessage: hasMore,
+ ),
+ );
+ },
+ didLoadLatestMessages: (List messages) {
+ final uniqueMessages = {...state.messages, ...messages}.toList()
+ ..sort((a, b) => b.id.compareTo(a.id));
+ emit(
+ state.copyWith(
+ messages: uniqueMessages,
+ initialLoadingStatus: const LoadingState.finish(),
+ ),
+ );
+ },
+ streaming: (List messages) {
+ final allMessages = _perminentMessages();
+ allMessages.insertAll(0, messages);
+ emit(state.copyWith(messages: allMessages));
+ },
+ didFinishStreaming: () {
+ emit(
+ state.copyWith(
+ answerQuestionStatus: const LoadingState.finish(),
+ ),
+ );
+ },
+ sendMessage: (String message) async {
+ await _handleSentMessage(message, emit);
+
+ // Create a loading indicator
+ final loadingMessage =
+ _loadingMessage(state.userProfile.id.toString());
+ final allMessages = List.from(state.messages)
+ ..insert(0, loadingMessage);
+
+ emit(
+ state.copyWith(
+ lastSentMessage: null,
+ messages: allMessages,
+ answerQuestionStatus: const LoadingState.loading(),
+ relatedQuestions: [],
+ ),
+ );
+ },
+ retryGenerate: () {
+ if (state.lastSentMessage == null) {
+ return;
+ }
+ final payload = ChatMessageIdPB(
+ chatId: chatId,
+ messageId: state.lastSentMessage!.messageId,
+ );
+ ChatEventGetAnswerForQuestion(payload).send().then((result) {
+ if (!isClosed) {
+ result.fold(
+ (answer) => _handleChatMessage(answer),
+ (err) {
+ Log.error("Failed to get answer: $err");
+ },
+ );
+ }
+ });
+ },
+ didReceiveRelatedQuestion: (List questions) {
+ final allMessages = _perminentMessages();
+ final message = CustomMessage(
+ metadata: OnetimeShotType.relatedQuestion.toMap(),
+ author: const User(id: "system"),
+ id: 'system',
+ );
+ allMessages.insert(0, message);
+ emit(
+ state.copyWith(
+ messages: allMessages,
+ relatedQuestions: questions,
+ ),
+ );
+ },
+ clearReleatedQuestion: () {
+ emit(
+ state.copyWith(
+ relatedQuestions: [],
+ ),
+ );
+ },
+ didSentUserMessage: (ChatMessagePB message) {
+ emit(
+ state.copyWith(
+ lastSentMessage: message,
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+
+// Returns the list of messages that are not include one-time messages.
+ List _perminentMessages() {
+ final allMessages = state.messages.where((element) {
+ return !(element.metadata?.containsKey(onetimeShotType) == true);
+ }).toList();
+
+ return allMessages;
+ }
+
+ void _loadPrevMessage(Int64? beforeMessageId) {
+ final payload = LoadPrevChatMessagePB(
+ chatId: state.view.id,
+ limit: Int64(10),
+ beforeMessageId: beforeMessageId,
+ );
+ ChatEventLoadPrevMessage(payload).send();
+ }
+
+ Future _handleSentMessage(
+ String message,
+ Emitter emit,
+ ) async {
+ final payload = SendChatPayloadPB(
+ chatId: state.view.id,
+ message: message,
+ messageType: ChatMessageTypePB.User,
+ );
+ final result = await ChatEventSendMessage(payload).send();
+ result.fold(
+ (_) {},
+ (err) {
+ if (!isClosed) {
+ Log.error("Failed to send message: ${err.msg}");
+ final metadata = OnetimeShotType.invalidSendMesssage.toMap();
+ metadata[sendMessageErrorKey] = err.msg;
+ final error = CustomMessage(
+ metadata: metadata,
+ author: const User(id: "system"),
+ id: 'system',
+ );
+
+ add(ChatEvent.streaming([error]));
+ }
+ },
+ );
+ }
+
+ void _handleChatMessage(ChatMessagePB pb) {
+ if (!isClosed) {
+ final message = _createChatMessage(pb);
+ final messages = pb.hasFollowing
+ ? [_loadingMessage(0.toString()), message]
+ : [message];
+ add(ChatEvent.streaming(messages));
+ }
+ }
+
+ Message _loadingMessage(String id) {
+ return CustomMessage(
+ author: User(id: id),
+ metadata: OnetimeShotType.loading.toMap(),
+ // fake id
+ id: nanoid(),
+ );
+ }
+
+ Message _createChatMessage(ChatMessagePB message) {
+ final messageId = message.messageId.toString();
+ return TextMessage(
+ author: User(id: message.authorId),
+ id: messageId,
+ text: message.content,
+ createdAt: message.createdAt.toInt(),
+ repliedMessage: _getReplyMessage(state.messages, messageId),
+ );
+ }
+
+ Message? _getReplyMessage(List messages, String messageId) {
+ return messages.firstWhereOrNull((element) => element?.id == messageId);
+ }
+}
+
+@freezed
+class ChatEvent with _$ChatEvent {
+ const factory ChatEvent.initialLoad() = _InitialLoadMessage;
+ const factory ChatEvent.sendMessage(String message) = _SendMessage;
+ const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
+ const factory ChatEvent.didLoadPreviousMessages(
+ List messages,
+ bool hasMore,
+ ) = _DidLoadPreviousMessages;
+ const factory ChatEvent.didLoadLatestMessages(List messages) =
+ _DidLoadMessages;
+ const factory ChatEvent.streaming(List messages) = _DidStreamMessage;
+ const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
+ const factory ChatEvent.didReceiveRelatedQuestion(
+ List questions,
+ ) = _DidReceiveRelatedQueston;
+ const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
+ const factory ChatEvent.retryGenerate() = _RetryGenerate;
+ const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
+ _DidSendUserMessage;
+}
+
+@freezed
+class ChatState with _$ChatState {
+ const factory ChatState({
+ required ViewPB view,
+ required List messages,
+ required UserProfilePB userProfile,
+ // When opening the chat, the initial loading status will be set as loading.
+ //After the initial loading is done, the status will be set as finished.
+ required LoadingState initialLoadingStatus,
+ // When loading previous messages, the status will be set as loading.
+ // After the loading is done, the status will be set as finished.
+ required LoadingState loadingPreviousStatus,
+ // When sending a user message, the status will be set as loading.
+ // After the message is sent, the status will be set as finished.
+ required LoadingState answerQuestionStatus,
+ // Indicate whether there are more previous messages to load.
+ required bool hasMorePrevMessage,
+ // The related questions that are received after the user message is sent.
+ required List relatedQuestions,
+ // The last user message that is sent to the server.
+ ChatMessagePB? lastSentMessage,
+ }) = _ChatState;
+
+ factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
+ ChatState(
+ view: view,
+ messages: [],
+ userProfile: userProfile,
+ initialLoadingStatus: const LoadingState.finish(),
+ loadingPreviousStatus: const LoadingState.finish(),
+ answerQuestionStatus: const LoadingState.finish(),
+ hasMorePrevMessage: true,
+ relatedQuestions: [],
+ );
+}
+
+@freezed
+class LoadingState with _$LoadingState {
+ const factory LoadingState.loading() = _Loading;
+ const factory LoadingState.finish() = _Finish;
+}
+
+enum OnetimeShotType {
+ unknown,
+ loading,
+ serverStreamError,
+ relatedQuestion,
+ invalidSendMesssage
+}
+
+const onetimeShotType = "OnetimeShotType";
+
+extension OnetimeMessageTypeExtension on OnetimeShotType {
+ static OnetimeShotType fromString(String value) {
+ switch (value) {
+ case 'OnetimeShotType.loading':
+ return OnetimeShotType.loading;
+ case 'OnetimeShotType.serverStreamError':
+ return OnetimeShotType.serverStreamError;
+ case 'OnetimeShotType.relatedQuestion':
+ return OnetimeShotType.relatedQuestion;
+ case 'OnetimeShotType.invalidSendMesssage':
+ return OnetimeShotType.invalidSendMesssage;
+ default:
+ Log.error('Unknown OnetimeShotType: $value');
+ return OnetimeShotType.unknown;
+ }
+ }
+
+ Map toMap() {
+ return {
+ onetimeShotType: toString(),
+ };
+ }
+}
+
+OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) {
+ if (metadata == null) {
+ return null;
+ }
+
+ for (final entry in metadata.entries) {
+ if (entry.key == onetimeShotType) {
+ return OnetimeMessageTypeExtension.fromString(entry.value as String);
+ }
+ }
+ return null;
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart
new file mode 100644
index 0000000000..3b40c18d36
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart
@@ -0,0 +1,87 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
+import 'package:appflowy_backend/rust_stream.dart';
+import 'package:appflowy_result/appflowy_result.dart';
+
+import 'chat_notification.dart';
+
+typedef ChatMessageCallback = void Function(ChatMessagePB message);
+typedef ChatErrorMessageCallback = void Function(ChatMessageErrorPB message);
+typedef LatestMessageCallback = void Function(ChatMessageListPB list);
+typedef PrevMessageCallback = void Function(ChatMessageListPB list);
+
+class ChatMessageListener {
+ ChatMessageListener({required this.chatId}) {
+ _parser = ChatNotificationParser(id: chatId, callback: _callback);
+ _subscription = RustStreamReceiver.listen(
+ (observable) => _parser?.parse(observable),
+ );
+ }
+
+ final String chatId;
+ StreamSubscription? _subscription;
+ ChatNotificationParser? _parser;
+
+ ChatMessageCallback? chatMessageCallback;
+ ChatMessageCallback? lastUserSentMessageCallback;
+ ChatErrorMessageCallback? chatErrorMessageCallback;
+ LatestMessageCallback? latestMessageCallback;
+ PrevMessageCallback? prevMessageCallback;
+ void Function()? finishAnswerQuestionCallback;
+
+ void start({
+ ChatMessageCallback? chatMessageCallback,
+ ChatErrorMessageCallback? chatErrorMessageCallback,
+ LatestMessageCallback? latestMessageCallback,
+ PrevMessageCallback? prevMessageCallback,
+ ChatMessageCallback? lastUserSentMessageCallback,
+ void Function()? finishAnswerQuestionCallback,
+ }) {
+ this.chatMessageCallback = chatMessageCallback;
+ this.chatErrorMessageCallback = chatErrorMessageCallback;
+ this.latestMessageCallback = latestMessageCallback;
+ this.prevMessageCallback = prevMessageCallback;
+ this.lastUserSentMessageCallback = lastUserSentMessageCallback;
+ this.finishAnswerQuestionCallback = finishAnswerQuestionCallback;
+ }
+
+ void _callback(
+ ChatNotification ty,
+ FlowyResult result,
+ ) {
+ result.map((r) {
+ switch (ty) {
+ case ChatNotification.DidReceiveChatMessage:
+ chatMessageCallback?.call(ChatMessagePB.fromBuffer(r));
+ break;
+ case ChatNotification.LastUserSentMessage:
+ lastUserSentMessageCallback?.call(ChatMessagePB.fromBuffer(r));
+ break;
+ case ChatNotification.StreamChatMessageError:
+ chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r));
+ break;
+ case ChatNotification.DidLoadLatestChatMessage:
+ latestMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
+ break;
+ case ChatNotification.DidLoadPrevChatMessage:
+ prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
+ break;
+ case ChatNotification.FinishAnswerQuestion:
+ finishAnswerQuestionCallback?.call();
+ break;
+ default:
+ break;
+ }
+ });
+ }
+
+ Future stop() async {
+ await _subscription?.cancel();
+ _subscription = null;
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart
new file mode 100644
index 0000000000..194748858b
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart
@@ -0,0 +1,45 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:appflowy/core/notification/notification_helper.dart';
+import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart';
+import 'package:appflowy_backend/rust_stream.dart';
+import 'package:appflowy_result/appflowy_result.dart';
+
+class ChatNotificationParser
+ extends NotificationParser {
+ ChatNotificationParser({
+ super.id,
+ required super.callback,
+ }) : super(
+ tyParser: (ty, source) =>
+ source == "Chat" ? ChatNotification.valueOf(ty) : null,
+ errorParser: (bytes) => FlowyError.fromBuffer(bytes),
+ );
+}
+
+typedef ChatNotificationHandler = Function(
+ ChatNotification ty,
+ FlowyResult result,
+);
+
+class ChatNotificationListener {
+ ChatNotificationListener({
+ required String objectId,
+ required ChatNotificationHandler handler,
+ }) : _parser = ChatNotificationParser(id: objectId, callback: handler) {
+ _subscription =
+ RustStreamReceiver.listen((observable) => _parser?.parse(observable));
+ }
+
+ ChatNotificationParser? _parser;
+ StreamSubscription? _subscription;
+
+ Future stop() async {
+ _parser = null;
+ await _subscription?.cancel();
+ _subscription = null;
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart
new file mode 100644
index 0000000000..d6db6afc37
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_related_question_bloc.dart
@@ -0,0 +1,103 @@
+import 'package:appflowy/plugins/ai_chat/application/chat_message_listener.dart';
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'chat_related_question_bloc.freezed.dart';
+
+class ChatRelatedMessageBloc
+ extends Bloc {
+ ChatRelatedMessageBloc({
+ required String chatId,
+ }) : listener = ChatMessageListener(chatId: chatId),
+ super(ChatRelatedMessageState.initial()) {
+ on(
+ (event, emit) async {
+ await event.when(
+ initial: () async {
+ listener.start(
+ lastUserSentMessageCallback: (message) {
+ if (!isClosed) {
+ add(ChatRelatedMessageEvent.updateLastSentMessage(message));
+ }
+ },
+ );
+ },
+ didReceiveRelatedQuestion: (List questions) {
+ Log.debug("Related questions: $questions");
+ emit(
+ state.copyWith(
+ relatedQuestions: questions,
+ ),
+ );
+ },
+ updateLastSentMessage: (ChatMessagePB message) {
+ final payload =
+ ChatMessageIdPB(chatId: chatId, messageId: message.messageId);
+ ChatEventGetRelatedQuestion(payload).send().then((result) {
+ if (!isClosed) {
+ result.fold(
+ (list) {
+ add(
+ ChatRelatedMessageEvent.didReceiveRelatedQuestion(
+ list.items,
+ ),
+ );
+ },
+ (err) {
+ Log.error("Failed to get related question: $err");
+ },
+ );
+ }
+ });
+
+ emit(
+ state.copyWith(
+ lastSentMessage: message,
+ relatedQuestions: [],
+ ),
+ );
+ },
+ clear: () {
+ emit(
+ state.copyWith(
+ relatedQuestions: [],
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+
+ final ChatMessageListener listener;
+ @override
+ Future close() {
+ listener.stop();
+ return super.close();
+ }
+}
+
+@freezed
+class ChatRelatedMessageEvent with _$ChatRelatedMessageEvent {
+ const factory ChatRelatedMessageEvent.initial() = Initial;
+ const factory ChatRelatedMessageEvent.updateLastSentMessage(
+ ChatMessagePB message,
+ ) = _LastSentMessage;
+ const factory ChatRelatedMessageEvent.didReceiveRelatedQuestion(
+ List questions,
+ ) = _RelatedQuestion;
+ const factory ChatRelatedMessageEvent.clear() = _Clear;
+}
+
+@freezed
+class ChatRelatedMessageState with _$ChatRelatedMessageState {
+ const factory ChatRelatedMessageState({
+ ChatMessagePB? lastSentMessage,
+ @Default([]) List relatedQuestions,
+ }) = _ChatRelatedMessageState;
+
+ factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState();
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart
new file mode 100644
index 0000000000..d75b0533e2
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart
@@ -0,0 +1,44 @@
+import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_chat_types/flutter_chat_types.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'chat_user_message_bloc.freezed.dart';
+
+class ChatUserMessageBloc
+ extends Bloc {
+ ChatUserMessageBloc({
+ required Message message,
+ }) : super(ChatUserMessageState.initial(message)) {
+ on(
+ (event, emit) async {
+ await event.when(
+ initial: () async {},
+ update: (userProfile, deviceId, states) {},
+ );
+ },
+ );
+ }
+}
+
+@freezed
+class ChatUserMessageEvent with _$ChatUserMessageEvent {
+ const factory ChatUserMessageEvent.initial() = Initial;
+ const factory ChatUserMessageEvent.update(
+ UserProfilePB userProfile,
+ String deviceId,
+ DocumentAwarenessStatesPB states,
+ ) = Update;
+}
+
+@freezed
+class ChatUserMessageState with _$ChatUserMessageState {
+ const factory ChatUserMessageState({
+ required Message message,
+ WorkspaceMemberPB? member,
+ }) = _ChatUserMessageState;
+
+ factory ChatUserMessageState.initial(Message message) =>
+ ChatUserMessageState(message: message);
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart
new file mode 100644
index 0000000000..cb69ee8d8d
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart
@@ -0,0 +1,114 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/plugins/ai_chat/chat_page.dart';
+import 'package:appflowy/plugins/util.dart';
+import 'package:appflowy/startup/plugin/plugin.dart';
+import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
+import 'package:appflowy/workspace/presentation/home/home_stack.dart';
+import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
+import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class AIChatPluginBuilder extends PluginBuilder {
+ @override
+ Plugin build(dynamic data) {
+ if (data is ViewPB) {
+ return AIChatPagePlugin(view: data);
+ }
+
+ throw FlowyPluginException.invalidData;
+ }
+
+ @override
+ String get menuName => "AIChat";
+
+ @override
+ FlowySvgData get icon => FlowySvgs.chat_ai_page_s;
+
+ @override
+ PluginType get pluginType => PluginType.chat;
+
+ @override
+ ViewLayoutPB get layoutType => ViewLayoutPB.Chat;
+}
+
+class AIChatPluginConfig implements PluginConfig {
+ @override
+ bool get creatable => true;
+}
+
+class AIChatPagePlugin extends Plugin {
+ AIChatPagePlugin({
+ required ViewPB view,
+ }) : notifier = ViewPluginNotifier(view: view);
+
+ late final ViewInfoBloc _viewInfoBloc;
+
+ @override
+ final ViewPluginNotifier notifier;
+
+ @override
+ PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder(
+ bloc: _viewInfoBloc,
+ notifier: notifier,
+ );
+
+ @override
+ PluginId get id => notifier.view.id;
+
+ @override
+ PluginType get pluginType => PluginType.chat;
+
+ @override
+ void init() {
+ _viewInfoBloc = ViewInfoBloc(view: notifier.view)
+ ..add(const ViewInfoEvent.started());
+ }
+}
+
+class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
+ with NavigationItem {
+ AIChatPagePluginWidgetBuilder({
+ required this.bloc,
+ required this.notifier,
+ });
+
+ final ViewInfoBloc bloc;
+ final ViewPluginNotifier notifier;
+ int? deletedViewIndex;
+
+ @override
+ Widget get leftBarItem =>
+ ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
+
+ @override
+ Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
+
+ @override
+ Widget buildWidget({
+ required PluginContext context,
+ required bool shrinkWrap,
+ }) {
+ notifier.isDeleted.addListener(() {
+ final deletedView = notifier.isDeleted.value;
+ if (deletedView != null && deletedView.hasIndex()) {
+ deletedViewIndex = deletedView.index;
+ }
+ });
+
+ return BlocProvider.value(
+ value: bloc,
+ child: AIChatPage(
+ userProfile: context.userProfile!,
+ key: ValueKey(notifier.view.id),
+ view: notifier.view,
+ onDeleted: () =>
+ context.onDeleted?.call(notifier.view, deletedViewIndex),
+ ),
+ );
+ }
+
+ @override
+ List get navigationItems => [this];
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart
new file mode 100644
index 0000000000..4d103e342f
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart
@@ -0,0 +1,332 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_ai_message.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_streaming_error_message.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_user_message.dart';
+import 'package:appflowy/workspace/presentation/home/toast.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/theme_extension.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';
+import 'package:flutter_chat_types/flutter_chat_types.dart';
+import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
+import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
+
+import 'presentation/chat_input.dart';
+import 'presentation/chat_loading.dart';
+import 'presentation/chat_popmenu.dart';
+import 'presentation/chat_theme.dart';
+import 'presentation/chat_user_invalid_message.dart';
+import 'presentation/chat_welcome_page.dart';
+
+class AIChatPage extends StatefulWidget {
+ const AIChatPage({
+ super.key,
+ required this.view,
+ required this.onDeleted,
+ required this.userProfile,
+ });
+
+ final ViewPB view;
+ final VoidCallback onDeleted;
+ final UserProfilePB userProfile;
+
+ @override
+ State createState() => _AIChatPageState();
+}
+
+class _AIChatPageState extends State {
+ late types.User _user;
+
+ @override
+ void initState() {
+ super.initState();
+ _user = types.User(id: widget.userProfile.id.toString());
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
+ return buildChatWidget();
+ } else {
+ return Center(
+ child: FlowyText(
+ LocaleKeys.chat_unsupportedCloudPrompt.tr(),
+ fontSize: 20,
+ ),
+ );
+ }
+ }
+
+ Widget buildChatWidget() {
+ return SizedBox.expand(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 60),
+ child: BlocProvider(
+ create: (context) => ChatBloc(
+ view: widget.view,
+ userProfile: widget.userProfile,
+ )..add(const ChatEvent.initialLoad()),
+ child: BlocBuilder(
+ builder: (blocContext, state) {
+ return Chat(
+ messages: state.messages,
+ onAttachmentPressed: () {},
+ onSendPressed: (types.PartialText message) {
+ // We use custom bottom widget for chat input, so
+ // do not need to handle this event.
+ },
+ customBottomWidget: buildChatInput(blocContext),
+ user: _user,
+ theme: buildTheme(context),
+ customMessageBuilder: _customMessageBuilder,
+ onEndReached: () async {
+ if (state.hasMorePrevMessage &&
+ state.loadingPreviousStatus !=
+ const LoadingState.loading()) {
+ blocContext
+ .read()
+ .add(const ChatEvent.startLoadingPrevMessage());
+ }
+ },
+ emptyState: BlocBuilder(
+ builder: (context, state) {
+ return state.initialLoadingStatus ==
+ const LoadingState.finish()
+ ? const ChatWelcomePage()
+ : const Center(
+ child: CircularProgressIndicator.adaptive(),
+ );
+ },
+ ),
+ messageWidthRatio: isMobile ? 0.8 : 0.86,
+ bubbleBuilder: (
+ child, {
+ required message,
+ required nextMessageInGroup,
+ }) {
+ if (message.author.id == _user.id) {
+ return ChatUserMessageBubble(
+ message: message,
+ child: child,
+ );
+ } else {
+ final messageType = onetimeMessageTypeFromMeta(
+ message.metadata,
+ );
+ if (messageType == OnetimeShotType.serverStreamError) {
+ return ChatStreamingError(
+ message: message,
+ onRetryPressed: () {
+ blocContext
+ .read()
+ .add(const ChatEvent.retryGenerate());
+ },
+ );
+ }
+
+ if (messageType == OnetimeShotType.invalidSendMesssage) {
+ return ChatInvalidUserMessage(
+ message: message,
+ );
+ }
+
+ if (messageType == OnetimeShotType.relatedQuestion) {
+ return RelatedQuestionList(
+ onQuestionSelected: (question) {
+ blocContext
+ .read()
+ .add(ChatEvent.sendMessage(question));
+ blocContext
+ .read()
+ .add(const ChatEvent.clearReleatedQuestion());
+ },
+ chatId: widget.view.id,
+ relatedQuestions: state.relatedQuestions,
+ );
+ }
+
+ return ChatAIMessageBubble(
+ message: message,
+ customMessageType: messageType,
+ child: child,
+ );
+ }
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget buildBubble(Message message, Widget child) {
+ final isAuthor = message.author.id == _user.id;
+ const borderRadius = BorderRadius.all(Radius.circular(6));
+
+ final childWithPadding = isAuthor
+ ? Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ child: child,
+ )
+ : Padding(
+ padding: const EdgeInsets.all(8),
+ child: child,
+ );
+
+ // If the message is from the author, we will decorate it with a different color
+ final decoratedChild = isAuthor
+ ? DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: borderRadius,
+ color: !isAuthor || message.type == types.MessageType.image
+ ? AFThemeExtension.of(context).tint1
+ : Theme.of(context).colorScheme.secondary,
+ ),
+ child: childWithPadding,
+ )
+ : childWithPadding;
+
+ // If the message is from the author, no further actions are needed
+ if (isAuthor) {
+ return ClipRRect(
+ borderRadius: borderRadius,
+ child: decoratedChild,
+ );
+ } else {
+ if (isMobile) {
+ return ChatPopupMenu(
+ onAction: (action) {
+ switch (action) {
+ case ChatMessageAction.copy:
+ if (message is TextMessage) {
+ Clipboard.setData(ClipboardData(text: message.text));
+ showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
+ }
+ break;
+ }
+ },
+ builder: (context) =>
+ ClipRRect(borderRadius: borderRadius, child: decoratedChild),
+ );
+ } else {
+ // Show hover effect only on desktop
+ return ClipRRect(
+ borderRadius: borderRadius,
+ child: ChatAIMessageHover(
+ message: message,
+ child: decoratedChild,
+ ),
+ );
+ }
+ }
+ }
+
+ Widget _customMessageBuilder(
+ types.CustomMessage message, {
+ required int messageWidth,
+ }) {
+ // iteration custom message type
+ final messageType = onetimeMessageTypeFromMeta(message.metadata);
+ if (messageType == null) {
+ return const SizedBox.shrink();
+ }
+
+ switch (messageType) {
+ case OnetimeShotType.loading:
+ return const ChatAILoading();
+ default:
+ return const SizedBox.shrink();
+ }
+ }
+
+ Widget buildChatInput(BuildContext context) {
+ final query = MediaQuery.of(context);
+ final safeAreaInsets = isMobile
+ ? EdgeInsets.fromLTRB(
+ query.padding.left,
+ 0,
+ query.padding.right,
+ query.viewInsets.bottom + query.padding.bottom,
+ )
+ : const EdgeInsets.symmetric(horizontal: 70);
+ return Column(
+ children: [
+ ClipRect(
+ child: Padding(
+ padding: safeAreaInsets,
+ child: ChatInput(
+ chatId: widget.view.id,
+ onSendPressed: (message) => onSendPressed(context, message.text),
+ ),
+ ),
+ ),
+ const VSpace(6),
+ Opacity(
+ opacity: 0.6,
+ child: FlowyText(
+ LocaleKeys.chat_aiMistakePrompt.tr(),
+ fontSize: 12,
+ ),
+ ),
+ ],
+ );
+ }
+
+ AFDefaultChatTheme buildTheme(BuildContext context) {
+ return AFDefaultChatTheme(
+ backgroundColor: AFThemeExtension.of(context).background,
+ primaryColor: Theme.of(context).colorScheme.primary,
+ secondaryColor: AFThemeExtension.of(context).tint1,
+ receivedMessageDocumentIconColor: Theme.of(context).primaryColor,
+ receivedMessageCaptionTextStyle: TextStyle(
+ color: AFThemeExtension.of(context).textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ receivedMessageBodyTextStyle: TextStyle(
+ color: AFThemeExtension.of(context).textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ receivedMessageLinkTitleTextStyle: TextStyle(
+ color: AFThemeExtension.of(context).textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ receivedMessageBodyLinkTextStyle: const TextStyle(
+ color: Colors.lightBlue,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ sentMessageBodyTextStyle: TextStyle(
+ color: AFThemeExtension.of(context).textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ sentMessageBodyLinkTextStyle: const TextStyle(
+ color: Colors.blue,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ ),
+ inputElevation: 2,
+ );
+ }
+
+ void onSendPressed(BuildContext context, String message) {
+ context.read().add(ChatEvent.sendMessage(message));
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart
new file mode 100644
index 0000000000..5a8e65012d
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_ai_message.dart
@@ -0,0 +1,197 @@
+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_bloc.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';
+import 'package:appflowy/workspace/presentation/home/toast.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/size.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_chat_types/flutter_chat_types.dart';
+import 'package:styled_widget/styled_widget.dart';
+
+const _leftPadding = 16.0;
+
+class ChatAIMessageBubble extends StatelessWidget {
+ const ChatAIMessageBubble({
+ super.key,
+ required this.message,
+ required this.child,
+ this.customMessageType,
+ });
+
+ final Message message;
+ final Widget child;
+ final OnetimeShotType? customMessageType;
+
+ @override
+ Widget build(BuildContext context) {
+ const padding = EdgeInsets.symmetric(horizontal: _leftPadding);
+ final childWithPadding = Padding(padding: padding, child: child);
+
+ return BlocProvider(
+ create: (context) => ChatAIMessageBloc(message: message),
+ child: BlocBuilder(
+ builder: (context, state) {
+ final widget = isMobile
+ ? _wrapPopMenu(childWithPadding)
+ : _wrapHover(childWithPadding);
+
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ ChatBorderedCircleAvatar(
+ backgroundColor: Theme.of(context).colorScheme.secondary,
+ child: const FlowySvg(
+ FlowySvgs.flowy_ai_chat_logo_s,
+ size: Size.square(24),
+ ),
+ ),
+ Expanded(child: widget),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ ChatAIMessageHover _wrapHover(Padding child) {
+ return ChatAIMessageHover(
+ message: message,
+ customMessageType: customMessageType,
+ child: child,
+ );
+ }
+
+ ChatPopupMenu _wrapPopMenu(Padding childWithPadding) {
+ return ChatPopupMenu(
+ onAction: (action) {
+ if (action == ChatMessageAction.copy && message is TextMessage) {
+ Clipboard.setData(ClipboardData(text: (message as TextMessage).text));
+ showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
+ }
+ },
+ builder: (context) => childWithPadding,
+ );
+ }
+}
+
+class ChatAIMessageHover extends StatefulWidget {
+ const ChatAIMessageHover({
+ super.key,
+ required this.child,
+ required this.message,
+ this.customMessageType,
+ });
+
+ final Widget child;
+ final Message message;
+ final bool autoShowHover = true;
+ final OnetimeShotType? customMessageType;
+
+ @override
+ State createState() => _ChatAIMessageHoverState();
+}
+
+class _ChatAIMessageHoverState extends State {
+ bool _isHover = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _isHover = widget.autoShowHover ? false : true;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final List children = [
+ DecoratedBox(
+ decoration: const BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: Corners.s6Border,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 40),
+ child: widget.child,
+ ),
+ ),
+ ];
+
+ if (_isHover) {
+ children.addAll(_buildOnHoverItems());
+ }
+
+ return MouseRegion(
+ cursor: SystemMouseCursors.click,
+ opaque: false,
+ onEnter: (p) => setState(() {
+ if (widget.autoShowHover) {
+ _isHover = true;
+ }
+ }),
+ onExit: (p) => setState(() {
+ if (widget.autoShowHover) {
+ _isHover = false;
+ }
+ }),
+ child: Stack(
+ alignment: AlignmentDirectional.centerStart,
+ children: children,
+ ),
+ );
+ }
+
+ List _buildOnHoverItems() {
+ final List children = [];
+ if (widget.customMessageType != null) {
+ //
+ } else {
+ if (widget.message is TextMessage) {
+ children.add(
+ CopyButton(
+ textMessage: widget.message as TextMessage,
+ ).positioned(left: _leftPadding, bottom: 0),
+ );
+ }
+ }
+
+ return children;
+ }
+}
+
+class CopyButton extends StatelessWidget {
+ const CopyButton({
+ super.key,
+ required this.textMessage,
+ });
+ final TextMessage textMessage;
+
+ @override
+ Widget build(BuildContext context) {
+ return FlowyTooltip(
+ message: LocaleKeys.settings_menu_clickToCopy.tr(),
+ child: FlowyIconButton(
+ width: 24,
+ hoverColor: AFThemeExtension.of(context).lightGreyHover,
+ fillColor: Theme.of(context).cardColor,
+ icon: FlowySvg(
+ FlowySvgs.ai_copy_s,
+ size: const Size.square(14),
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: textMessage.text));
+ showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
+ },
+ ),
+ );
+ }
+}
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
new file mode 100644
index 0000000000..141432520b
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart
@@ -0,0 +1,184 @@
+import 'package:flutter/material.dart';
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/util/built_in_svgs.dart';
+import 'package:appflowy/util/color_generator/color_generator.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/size.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:string_validator/string_validator.dart';
+
+class ChatChatUserAvatar extends StatelessWidget {
+ const ChatChatUserAvatar({required this.userId, super.key});
+
+ final String userId;
+
+ @override
+ Widget build(BuildContext context) {
+ return const ChatBorderedCircleAvatar();
+ }
+}
+
+class ChatBorderedCircleAvatar extends StatelessWidget {
+ const ChatBorderedCircleAvatar({
+ super.key,
+ this.border = const BorderSide(),
+ this.backgroundImage,
+ this.backgroundColor,
+ this.child,
+ });
+
+ final BorderSide border;
+ final ImageProvider