mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
chore: merge branch 'upstream/main' into feat/billing-client
This commit is contained in:
2
.github/actions/flutter_build/action.yml
vendored
2
.github/actions/flutter_build/action.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
24
.github/workflows/flutter_ci.yaml
vendored
24
.github/workflows/flutter_ci.yaml
vendored
@ -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:
|
||||
|
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -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
|
||||
|
4
.github/workflows/web2_ci.yaml
vendored
4
.github/workflows/web2_ci.yaml
vendored
@ -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: |
|
||||
|
@ -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
|
||||
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
|
||||
|
@ -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"
|
||||
|
@ -58,4 +58,11 @@
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
<!--
|
||||
Media access permissions.
|
||||
Android 13 or higher.
|
||||
Used for VideoBlock (edia_kit)
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
</manifest>
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -66,5 +66,10 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
@ -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';
|
||||
}
|
||||
|
@ -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<MoveWindowDetector> {
|
||||
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
|
||||
|
@ -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<void> main() async {
|
||||
ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.0);
|
||||
VideoBlockKit.ensureInitialized();
|
||||
|
||||
await runAppFlowy();
|
||||
}
|
||||
|
@ -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<void> pushView(ViewPB view, [Map<String, dynamic>? arguments]) async {
|
||||
await push(
|
||||
Uri(
|
||||
path: view.routeName,
|
||||
queryParameters: view.queryParameters(arguments),
|
||||
).toString(),
|
||||
).then((value) {
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
getIt<CachedRecentService>().updateRecentViews([view.id], true);
|
||||
});
|
||||
// set the current view before pushing the new view
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
unawaited(getIt<CachedRecentService>().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',
|
||||
|
@ -57,7 +57,19 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 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(
|
||||
|
@ -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<MobileViewPage> {
|
||||
(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(
|
||||
|
@ -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),
|
||||
|
@ -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<MobileViewItemBottomSheetBodyAction> actions;
|
||||
|
||||
@override
|
||||
State<MobileViewItemBottomSheet> createState() =>
|
||||
@ -27,12 +37,14 @@ class MobileViewItemBottomSheet extends StatefulWidget {
|
||||
|
||||
class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
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<MobileViewItemBottomSheet> {
|
||||
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<MobileViewItemBottomSheet> {
|
||||
case MobileViewItemBottomSheetBodyAction.delete:
|
||||
Navigator.pop(context);
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
|
||||
break;
|
||||
case MobileViewItemBottomSheetBodyAction.addToFavorites:
|
||||
case MobileViewItemBottomSheetBodyAction.removeFromFavorites:
|
||||
@ -68,6 +80,11 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
.read<FavoriteBloc>()
|
||||
.add(FavoriteEvent.toggle(widget.view));
|
||||
break;
|
||||
case MobileViewItemBottomSheetBodyAction.removeFromRecent:
|
||||
_removeFromRecent(context);
|
||||
break;
|
||||
case MobileViewItemBottomSheetBodyAction.divider:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -83,4 +100,74 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeFromRecent(BuildContext context) async {
|
||||
final viewId = context.read<ViewBloc>().view.id;
|
||||
final recentViewsBloc = context.read<RecentViewsBloc>();
|
||||
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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<MobileViewItemBottomSheetBodyAction> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,8 +65,21 @@ enum MobilePaneActionType {
|
||||
],
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
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,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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<UserWorkspaceBloc>().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),
|
||||
],
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -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<MobileFavoriteSpace> createState() => _MobileFavoriteSpaceState();
|
||||
}
|
||||
|
||||
class _MobileFavoriteSpaceState extends State<MobileFavoriteSpace>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final workspaceId =
|
||||
context.read<UserWorkspaceBloc>().state.currentWorkspace?.workspaceId ??
|
||||
'';
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => SidebarSectionsBloc()
|
||||
..add(
|
||||
SidebarSectionsEvent.initial(widget.userProfile, workspaceId),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
listener: (context, state) =>
|
||||
context.read<FavoriteBloc>().add(const FavoriteEvent.initial()),
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
|
||||
listener: (context, state) =>
|
||||
context.pushView(state.lastCreatedRootView!),
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final favoriteState = context.watch<FavoriteBloc>().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<SectionViewPB> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<MobileHomeSpace> createState() => _MobileHomeSpaceState();
|
||||
}
|
||||
|
||||
class _MobileHomeSpaceState extends State<MobileHomeSpace>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final workspaceId =
|
||||
context.read<UserWorkspaceBloc>().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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<MobileHomePage> createState() => _MobileHomePageState();
|
||||
}
|
||||
|
||||
class _MobileHomePageState extends State<MobileHomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
getIt<MenuSharedState>().addLatestViewListener(_onLatestViewChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
getIt<MenuSharedState>().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<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
FavoriteBloc()..add(const FavoriteEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocConsumer<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.currentWorkspace?.workspaceId !=
|
||||
current.currentWorkspace?.workspaceId,
|
||||
listener: (context, state) => getIt<CachedRecentService>().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<MenuSharedState>().latestOpenView?.id;
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
await FolderEventSetLatestView(ViewIdPB(value: id)).send();
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ class MobileHomePageHeader extends StatelessWidget {
|
||||
final isCollaborativeWorkspace =
|
||||
context.read<UserWorkspaceBloc>().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,
|
||||
|
@ -38,7 +38,8 @@ class _MobileRecentFolderState extends State<MobileRecentFolder> {
|
||||
builder: (context, state) {
|
||||
final ids = <String>{};
|
||||
|
||||
List<ViewPB> recentViews = state.views.reversed.toList();
|
||||
List<ViewPB> recentViews =
|
||||
state.views.reversed.map((e) => e.item).toList();
|
||||
recentViews.retainWhere((element) => ids.add(element.id));
|
||||
|
||||
// only keep the first 20 items.
|
||||
|
@ -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<MobileRecentSpace> createState() => _MobileRecentSpaceState();
|
||||
}
|
||||
|
||||
class _MobileRecentSpaceState extends State<MobileRecentSpace>
|
||||
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<RecentViewsBloc, RecentViewsState>(
|
||||
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<SectionViewPB> _filterRecentViews(List<SectionViewPB> recentViews) {
|
||||
final ids = <String>{};
|
||||
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<SectionViewPB> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<FolderBloc>().state.isExpanded,
|
||||
onPressed: () => context
|
||||
.read<FolderBloc>()
|
||||
.add(const FolderEvent.expandOrUnExpand()),
|
||||
onAdded: () {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.createRootViewInSection(
|
||||
name:
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
index: 0,
|
||||
viewSection: spaceType.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
context.read<FolderBloc>().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<FolderBloc>().state.isExpanded,
|
||||
onPressed: () => context
|
||||
.read<FolderBloc>()
|
||||
.add(const FolderEvent.expandOrUnExpand()),
|
||||
onAdded: () {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.createRootViewInSection(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName
|
||||
.tr(),
|
||||
index: 0,
|
||||
viewSection: spaceType.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
context.read<FolderBloc>().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<ViewBloc>().state.view;
|
||||
return buildEndActionPane(context, [
|
||||
MobilePaneActionType.delete,
|
||||
view.isFavorite
|
||||
? MobilePaneActionType.removeFromFavorites
|
||||
: MobilePaneActionType.addToFavorites,
|
||||
MobilePaneActionType.more,
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -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<MobileSectionFolderHeader> {
|
||||
|
||||
@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<MobileSectionFolderHeader> {
|
||||
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,
|
||||
),
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
@ -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<ViewBloc>(
|
||||
create: (context) => ViewBloc(view: view, shouldLoadChildViews: false)
|
||||
..add(const ViewEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
RecentViewBloc(view: view)..add(const RecentViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<RecentViewBloc, RecentViewState>(
|
||||
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<UserProfilePB?>(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<void> _showActionSheet(BuildContext context) async {
|
||||
final viewBloc = context.read<ViewBloc>();
|
||||
final favoriteBloc = context.read<FavoriteBloc>();
|
||||
final recentViewsBloc = context.read<RecentViewsBloc?>();
|
||||
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<ViewBloc, ViewState>(
|
||||
builder: (context, state) {
|
||||
final isFavorite = state.view.isFavorite;
|
||||
return MobileViewItemBottomSheet(
|
||||
view: viewBloc.state.view,
|
||||
actions: _buildActions(isFavorite),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<MobileViewItemBottomSheetBodyAction> _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<UserProfilePB?>(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<UserProfilePB?>(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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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<MobileSpaceTabType> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<MobileSpaceTab> createState() => _MobileSpaceTabState();
|
||||
}
|
||||
|
||||
class _MobileSpaceTabState extends State<MobileSpaceTab>
|
||||
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<SpaceOrderBloc, SpaceOrderState>(
|
||||
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<SpaceOrderBloc>().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<SpaceOrderBloc>().add(
|
||||
SpaceOrderEvent.open(
|
||||
tabController!.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _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();
|
||||
}
|
||||
}
|
@ -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<SpaceOrderEvent, SpaceOrderState> {
|
||||
SpaceOrderBloc() : super(const SpaceOrderState()) {
|
||||
on<SpaceOrderEvent>(
|
||||
(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<KeyValueStorage>();
|
||||
|
||||
Future<MobileSpaceTabType> _getDefaultTab() async {
|
||||
try {
|
||||
return await _storage.getWithFormat<MobileSpaceTabType>(
|
||||
KVKeys.lastOpenedSpace, (value) {
|
||||
return MobileSpaceTabType.values[int.parse(value)];
|
||||
}) ??
|
||||
MobileSpaceTabType.spaces;
|
||||
} catch (e) {
|
||||
return MobileSpaceTabType.spaces;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setDefaultTab(MobileSpaceTabType tab) async {
|
||||
await _storage.set(
|
||||
KVKeys.lastOpenedSpace,
|
||||
tab.index.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<MobileSpaceTabType>> _getTabsOrder() async {
|
||||
try {
|
||||
return await _storage.getWithFormat<List<MobileSpaceTabType>>(
|
||||
KVKeys.spaceOrder, (value) {
|
||||
final order = jsonDecode(value).cast<int>();
|
||||
if (order.isEmpty) {
|
||||
return MobileSpaceTabType.values;
|
||||
}
|
||||
return order
|
||||
.map((e) => MobileSpaceTabType.values[e])
|
||||
.cast<MobileSpaceTabType>()
|
||||
.toList();
|
||||
}) ??
|
||||
MobileSpaceTabType.values;
|
||||
} catch (e) {
|
||||
return MobileSpaceTabType.values;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setTabsOrder(List<MobileSpaceTabType> 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<MobileSpaceTabType> tabsOrder,
|
||||
@Default(true) bool isLoading,
|
||||
}) = _SpaceOrderState;
|
||||
|
||||
factory SpaceOrderState.initial() => const SpaceOrderState();
|
||||
}
|
@ -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 = <BottomNavigationBarItem>[
|
||||
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>[
|
||||
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');
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<SingleMobileInnerViewItem> {
|
||||
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<SingleMobileInnerViewItem> {
|
||||
// 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<SingleMobileInnerViewItem> {
|
||||
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<SingleMobileInnerViewItem> {
|
||||
|
||||
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<SingleMobileInnerViewItem> {
|
||||
// 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<ViewBloc>().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<SingleMobileInnerViewItem> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<void> _showMoreActions(BuildContext context) async {
|
||||
final viewBloc = context.read<ViewBloc>();
|
||||
final favoriteBloc = context.read<FavoriteBloc>();
|
||||
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<ViewBloc, ViewState>(
|
||||
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,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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<T?> showFlowyMobileConfirmDialog<T>(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> showFlowyCupertinoConfirmDialog<T>({
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
ChatAIMessageBloc({
|
||||
required Message message,
|
||||
}) : super(ChatAIMessageState.initial(message)) {
|
||||
on<ChatAIMessageEvent>(
|
||||
(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);
|
||||
}
|
@ -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<ChatEvent, ChatState> {
|
||||
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<void> close() {
|
||||
listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatEvent>(
|
||||
(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<Message> 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<Message> 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<Message> 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<Message>.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<RelatedQuestionPB> 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<Message> _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<void> _handleSentMessage(
|
||||
String message,
|
||||
Emitter<ChatState> 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<Message?> 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<Message> messages,
|
||||
bool hasMore,
|
||||
) = _DidLoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
const factory ChatEvent.streaming(List<Message> messages) = _DidStreamMessage;
|
||||
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
|
||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
||||
List<RelatedQuestionPB> 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<Message> 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<RelatedQuestionPB> 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<String, String> toMap() {
|
||||
return {
|
||||
onetimeShotType: toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? 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;
|
||||
}
|
@ -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<SubscribeObject>? _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<Uint8List, FlowyError> 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<void> stop() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
}
|
@ -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<ChatNotification, FlowyError> {
|
||||
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<Uint8List, FlowyError> 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<SubscribeObject>? _subscription;
|
||||
|
||||
Future<void> stop() async {
|
||||
_parser = null;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
}
|
@ -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<ChatRelatedMessageEvent, ChatRelatedMessageState> {
|
||||
ChatRelatedMessageBloc({
|
||||
required String chatId,
|
||||
}) : listener = ChatMessageListener(chatId: chatId),
|
||||
super(ChatRelatedMessageState.initial()) {
|
||||
on<ChatRelatedMessageEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
listener.start(
|
||||
lastUserSentMessageCallback: (message) {
|
||||
if (!isClosed) {
|
||||
add(ChatRelatedMessageEvent.updateLastSentMessage(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> 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<void> 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<RelatedQuestionPB> questions,
|
||||
) = _RelatedQuestion;
|
||||
const factory ChatRelatedMessageEvent.clear() = _Clear;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatRelatedMessageState with _$ChatRelatedMessageState {
|
||||
const factory ChatRelatedMessageState({
|
||||
ChatMessagePB? lastSentMessage,
|
||||
@Default([]) List<RelatedQuestionPB> relatedQuestions,
|
||||
}) = _ChatRelatedMessageState;
|
||||
|
||||
factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState();
|
||||
}
|
@ -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<ChatUserMessageEvent, ChatUserMessageState> {
|
||||
ChatUserMessageBloc({
|
||||
required Message message,
|
||||
}) : super(ChatUserMessageState.initial(message)) {
|
||||
on<ChatUserMessageEvent>(
|
||||
(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);
|
||||
}
|
114
frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart
Normal file
114
frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart
Normal file
@ -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<ViewInfoBloc>.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<NavigationItem> get navigationItems => [this];
|
||||
}
|
332
frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart
Normal file
332
frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart
Normal file
@ -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<AIChatPage> createState() => _AIChatPageState();
|
||||
}
|
||||
|
||||
class _AIChatPageState extends State<AIChatPage> {
|
||||
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<ChatBloc, ChatState>(
|
||||
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<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
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<ChatBloc>()
|
||||
.add(const ChatEvent.retryGenerate());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||
return ChatInvalidUserMessage(
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
onQuestionSelected: (question) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(question));
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.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<ChatBloc>().add(ChatEvent.sendMessage(message));
|
||||
}
|
||||
}
|
@ -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<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
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<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
|
||||
}
|
||||
|
||||
class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
bool _isHover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isHover = widget.autoShowHover ? false : true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> 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<Widget> _buildOnHoverItems() {
|
||||
final List<Widget> 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());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<Object>? backgroundImage;
|
||||
final Color? backgroundColor;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: border.color,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(),
|
||||
child: CircleAvatar(
|
||||
backgroundImage: backgroundImage,
|
||||
backgroundColor: backgroundColor,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatUserAvatar extends StatelessWidget {
|
||||
const ChatUserAvatar({
|
||||
super.key,
|
||||
required this.iconUrl,
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.isHovering = false,
|
||||
});
|
||||
|
||||
final String iconUrl;
|
||||
final String name;
|
||||
final double size;
|
||||
|
||||
// If true, a border will be applied on top of the avatar
|
||||
final bool isHovering;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (iconUrl.isEmpty) {
|
||||
return _buildEmptyAvatar(context);
|
||||
} else if (isURL(iconUrl)) {
|
||||
return _buildUrlAvatar(context);
|
||||
} else {
|
||||
return _buildEmojiAvatar(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyAvatar(BuildContext context) {
|
||||
final String nameOrDefault = _userName(name);
|
||||
final Color color = ColorGenerator(name).toColor();
|
||||
const initialsCount = 2;
|
||||
|
||||
// Taking the first letters of the name components and limiting to 2 elements
|
||||
final nameInitials = nameOrDefault
|
||||
.split(' ')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.take(initialsCount)
|
||||
.map((element) => element[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: _darken(color),
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: FlowyText.regular(
|
||||
nameInitials,
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUrlAvatar(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: Corners.s5Border,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildEmptyAvatar(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmojiAvatar(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: Corners.s5Border,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: builtInSVGIcons.contains(iconUrl)
|
||||
? FlowySvg(
|
||||
FlowySvgData('emoji/$iconUrl'),
|
||||
blendMode: null,
|
||||
)
|
||||
: FlowyText.emoji(iconUrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Return the user name, if the user name is empty,
|
||||
/// return the default user name.
|
||||
///
|
||||
String _userName(String name) =>
|
||||
name.isEmpty ? LocaleKeys.defaultUsername.tr() : name;
|
||||
|
||||
/// Used to darken the generated color for the hover border effect.
|
||||
/// The color is darkened by 15% - Hence the 0.15 value.
|
||||
///
|
||||
Color _darken(Color color) {
|
||||
final hsl = HSLColor.fromColor(color);
|
||||
return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
}
|
@ -0,0 +1,257 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
/// Creates [ChatInput] widget.
|
||||
const ChatInput({
|
||||
super.key,
|
||||
this.isAttachmentUploading,
|
||||
this.onAttachmentPressed,
|
||||
required this.onSendPressed,
|
||||
required this.chatId,
|
||||
this.options = const InputOptions(),
|
||||
});
|
||||
|
||||
final bool? isAttachmentUploading;
|
||||
final VoidCallback? onAttachmentPressed;
|
||||
final void Function(types.PartialText) onSendPressed;
|
||||
final InputOptions options;
|
||||
final String chatId;
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
/// [ChatInput] widget state.
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
late final _inputFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event.physicalKey == PhysicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard.instance.physicalKeysPressed.any(
|
||||
(el) => <PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.shiftLeft,
|
||||
PhysicalKeyboardKey.shiftRight,
|
||||
}.contains(el),
|
||||
)) {
|
||||
if (kIsWeb && _textController.value.isComposingRangeValid) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
_handleSendPressed();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
bool _sendButtonVisible = false;
|
||||
late TextEditingController _textController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController =
|
||||
widget.options.textEditingController ?? InputTextFieldController();
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
|
||||
void _handleSendButtonVisibilityModeChange() {
|
||||
_textController.removeListener(_handleTextControllerChange);
|
||||
if (widget.options.sendButtonVisibilityMode ==
|
||||
SendButtonVisibilityMode.hidden) {
|
||||
_sendButtonVisible = false;
|
||||
} else if (widget.options.sendButtonVisibilityMode ==
|
||||
SendButtonVisibilityMode.editing) {
|
||||
_sendButtonVisible = _textController.text.trim() != '';
|
||||
_textController.addListener(_handleTextControllerChange);
|
||||
} else {
|
||||
_sendButtonVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSendPressed() {
|
||||
final trimmedText = _textController.text.trim();
|
||||
if (trimmedText != '') {
|
||||
final partialText = types.PartialText(text: trimmedText);
|
||||
widget.onSendPressed(partialText);
|
||||
|
||||
if (widget.options.inputClearMode == InputClearMode.always) {
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
if (_textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sendButtonVisible = _textController.text.trim() != '';
|
||||
});
|
||||
}
|
||||
|
||||
Widget _inputBuilder() {
|
||||
const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
const inputPadding = EdgeInsets.all(6);
|
||||
|
||||
return Focus(
|
||||
autofocus: !widget.options.autofocus,
|
||||
child: Padding(
|
||||
padding: inputPadding,
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isMobile
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
elevation: 0.6,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onAttachmentPressed != null)
|
||||
AttachmentButton(
|
||||
isLoading: widget.isAttachmentUploading ?? false,
|
||||
onPressed: widget.onAttachmentPressed,
|
||||
padding: buttonPadding,
|
||||
),
|
||||
Expanded(child: _inputTextField(textPadding)),
|
||||
_sendButton(buttonPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Padding _inputTextField(EdgeInsets textPadding) {
|
||||
return Padding(
|
||||
padding: textPadding,
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _inputFocusNode,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: LocaleKeys.chat_inputMessageHint.tr(),
|
||||
hintStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
enabled: widget.options.enabled,
|
||||
autocorrect: widget.options.autocorrect,
|
||||
autofocus: widget.options.autofocus,
|
||||
enableSuggestions: widget.options.enableSuggestions,
|
||||
spellCheckConfiguration: const SpellCheckConfiguration(),
|
||||
keyboardType: widget.options.keyboardType,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLines: 10,
|
||||
minLines: 1,
|
||||
onChanged: widget.options.onTextChanged,
|
||||
onTap: widget.options.onTextFieldTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox _sendButton(EdgeInsets buttonPadding) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: buttonPadding.bottom + buttonPadding.top + 24,
|
||||
),
|
||||
child: Visibility(
|
||||
visible: _sendButtonVisible,
|
||||
child: SendButton(
|
||||
onPressed: _handleSendPressed,
|
||||
padding: buttonPadding,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.options.sendButtonVisibilityMode !=
|
||||
oldWidget.options.sendButtonVisibilityMode) {
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: () => _inputFocusNode.requestFocus(),
|
||||
child: _inputBuilder(),
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class InputOptions {
|
||||
const InputOptions({
|
||||
this.inputClearMode = InputClearMode.always,
|
||||
this.keyboardType = TextInputType.multiline,
|
||||
this.onTextChanged,
|
||||
this.onTextFieldTap,
|
||||
this.sendButtonVisibilityMode = SendButtonVisibilityMode.editing,
|
||||
this.textEditingController,
|
||||
this.autocorrect = true,
|
||||
this.autofocus = false,
|
||||
this.enableSuggestions = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always].
|
||||
final InputClearMode inputClearMode;
|
||||
|
||||
/// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline].
|
||||
final TextInputType keyboardType;
|
||||
|
||||
/// Will be called whenever the text inside [TextField] changes.
|
||||
final void Function(String)? onTextChanged;
|
||||
|
||||
/// Will be called on [TextField] tap.
|
||||
final VoidCallback? onTextFieldTap;
|
||||
|
||||
/// Controls the visibility behavior of the [SendButton] based on the
|
||||
/// [TextField] state inside the [ChatInput] widget.
|
||||
/// Defaults to [SendButtonVisibilityMode.editing].
|
||||
final SendButtonVisibilityMode sendButtonVisibilityMode;
|
||||
|
||||
/// Custom [TextEditingController]. If not provided, defaults to the
|
||||
/// [InputTextFieldController], which extends [TextEditingController] and has
|
||||
/// additional fatures like markdown support. If you want to keep additional
|
||||
/// features but still need some methods from the default [TextEditingController],
|
||||
/// you can create your own [InputTextFieldController] (imported from this lib)
|
||||
/// and pass it here.
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
/// Controls the [TextInput] autocorrect behavior. Defaults to [true].
|
||||
final bool autocorrect;
|
||||
|
||||
/// Whether [TextInput] should have focus. Defaults to [false].
|
||||
final bool autofocus;
|
||||
|
||||
/// Controls the [TextInput] enableSuggestions behavior. Defaults to [true].
|
||||
final bool enableSuggestions;
|
||||
|
||||
/// Controls the [TextInput] enabled behavior. Defaults to [true].
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
@ -0,0 +1,69 @@
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class ChatAILoading extends StatelessWidget {
|
||||
const ChatAILoading({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
highlightColor:
|
||||
AFThemeExtension.of(context).lightGreyHover.withOpacity(0.5),
|
||||
period: const Duration(seconds: 3),
|
||||
child: const ContentPlaceholder(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContentPlaceholder extends StatelessWidget {
|
||||
const ContentPlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 16.0,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
const HSpace(10),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 16.0,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 140,
|
||||
height: 16.0,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatPopupMenu extends StatefulWidget {
|
||||
const ChatPopupMenu({
|
||||
super.key,
|
||||
required this.onAction,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final Function(ChatMessageAction) onAction;
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
@override
|
||||
State<ChatPopupMenu> createState() => _ChatPopupMenuState();
|
||||
}
|
||||
|
||||
class _ChatPopupMenuState extends State<ChatPopupMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<ChatMessageActionWrapper>(
|
||||
asBarrier: true,
|
||||
actions: ChatMessageAction.values
|
||||
.map((action) => ChatMessageActionWrapper(action))
|
||||
.toList(),
|
||||
buildChild: (controller) {
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
controller.show();
|
||||
},
|
||||
child: widget.builder(context),
|
||||
);
|
||||
},
|
||||
onSelected: (action, controller) async {
|
||||
widget.onAction(action.inner);
|
||||
controller.close();
|
||||
},
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatMessageAction {
|
||||
copy,
|
||||
}
|
||||
|
||||
class ChatMessageActionWrapper extends ActionCell {
|
||||
ChatMessageActionWrapper(this.inner);
|
||||
|
||||
final ChatMessageAction inner;
|
||||
|
||||
@override
|
||||
Widget? leftIcon(Color iconColor) => null;
|
||||
|
||||
@override
|
||||
String get name => inner.name;
|
||||
}
|
||||
|
||||
extension ChatMessageActionExtension on ChatMessageAction {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case ChatMessageAction.copy:
|
||||
return LocaleKeys.document_plugins_contextMenu_copy.tr();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_related_question_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RelatedQuestionPage extends StatefulWidget {
|
||||
const RelatedQuestionPage({
|
||||
required this.chatId,
|
||||
required this.onQuestionSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final Function(String) onQuestionSelected;
|
||||
|
||||
@override
|
||||
State<RelatedQuestionPage> createState() => _RelatedQuestionPageState();
|
||||
}
|
||||
|
||||
class _RelatedQuestionPageState extends State<RelatedQuestionPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ChatRelatedMessageBloc(chatId: widget.chatId)
|
||||
..add(
|
||||
const ChatRelatedMessageEvent.initial(),
|
||||
),
|
||||
child: BlocBuilder<ChatRelatedMessageBloc, ChatRelatedMessageState>(
|
||||
builder: (blocContext, state) {
|
||||
return RelatedQuestionList(
|
||||
chatId: widget.chatId,
|
||||
onQuestionSelected: widget.onQuestionSelected,
|
||||
relatedQuestions: state.relatedQuestions,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RelatedQuestionList extends StatelessWidget {
|
||||
const RelatedQuestionList({
|
||||
required this.chatId,
|
||||
required this.onQuestionSelected,
|
||||
required this.relatedQuestions,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final Function(String) onQuestionSelected;
|
||||
final List<RelatedQuestionPB> relatedQuestions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final question = relatedQuestions[index];
|
||||
if (index == 0) {
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(height: 36),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.ai_summary_generate_s,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText(
|
||||
LocaleKeys.chat_relatedQuestion.tr(),
|
||||
fontSize: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 6),
|
||||
RelatedQuestionItem(
|
||||
question: question,
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return RelatedQuestionItem(
|
||||
question: question,
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RelatedQuestionItem extends StatefulWidget {
|
||||
const RelatedQuestionItem({
|
||||
required this.question,
|
||||
required this.onQuestionSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final RelatedQuestionPB question;
|
||||
final Function(String) onQuestionSelected;
|
||||
|
||||
@override
|
||||
State<RelatedQuestionItem> createState() => _RelatedQuestionItemState();
|
||||
}
|
||||
|
||||
class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
title: Text(
|
||||
widget.question.content,
|
||||
style: TextStyle(
|
||||
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
widget.onQuestionSelected(widget.question.content);
|
||||
},
|
||||
trailing: FlowySvg(
|
||||
FlowySvgs.add_m,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
|
||||
class ChatStreamingError extends StatelessWidget {
|
||||
const ChatStreamingError({
|
||||
required this.message,
|
||||
required this.onRetryPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final void Function() onRetryPressed;
|
||||
final Message message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canRetry = message.metadata?[canRetryKey] != null;
|
||||
|
||||
if (canRetry) {
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(height: 4, thickness: 1),
|
||||
const VSpace(16),
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
_aiUnvaliable(),
|
||||
const VSpace(10),
|
||||
_retryButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(height: 20, thickness: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_serverUnavailable.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FlowyButton _retryButton() {
|
||||
return FlowyButton(
|
||||
radius: BorderRadius.circular(20),
|
||||
useIntrinsicWidth: true,
|
||||
text: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_regenerateAnswer.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
onTap: onRetryPressed,
|
||||
iconPadding: 0,
|
||||
leftIcon: const Icon(
|
||||
Icons.refresh,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Padding _aiUnvaliable() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
// For internal usage only. Use values from theme itself.
|
||||
|
||||
/// See [ChatTheme.userAvatarNameColors].
|
||||
const colors = [
|
||||
Color(0xffff6767),
|
||||
Color(0xff66e0da),
|
||||
Color(0xfff5a2d9),
|
||||
Color(0xfff0c722),
|
||||
Color(0xff6a85e5),
|
||||
Color(0xfffd9a6f),
|
||||
Color(0xff92db6e),
|
||||
Color(0xff73b8e5),
|
||||
Color(0xfffd7590),
|
||||
Color(0xffc78ae5),
|
||||
];
|
||||
|
||||
/// Dark.
|
||||
const dark = Color(0xff1f1c38);
|
||||
|
||||
/// Error.
|
||||
const error = Color(0xffff6767);
|
||||
|
||||
/// N0.
|
||||
const neutral0 = Color(0xff1d1c21);
|
||||
|
||||
/// N1.
|
||||
const neutral1 = Color(0xff615e6e);
|
||||
|
||||
/// N2.
|
||||
const neutral2 = Color(0xff9e9cab);
|
||||
|
||||
/// N7.
|
||||
const neutral7 = Color(0xffffffff);
|
||||
|
||||
/// N7 with opacity.
|
||||
const neutral7WithOpacity = Color(0x80ffffff);
|
||||
|
||||
/// Primary.
|
||||
const primary = Color(0xff6f61e8);
|
||||
|
||||
/// Secondary.
|
||||
const secondary = Color(0xfff5f5f7);
|
||||
|
||||
/// Secondary dark.
|
||||
const secondaryDark = Color(0xff2b2250);
|
||||
|
||||
/// Default chat theme which extends [ChatTheme].
|
||||
@immutable
|
||||
class AFDefaultChatTheme extends ChatTheme {
|
||||
/// Creates a default chat theme. Use this constructor if you want to
|
||||
/// override only a couple of properties, otherwise create a new class
|
||||
/// which extends [ChatTheme].
|
||||
const AFDefaultChatTheme({
|
||||
super.attachmentButtonIcon,
|
||||
super.attachmentButtonMargin,
|
||||
super.backgroundColor = neutral7,
|
||||
super.bubbleMargin,
|
||||
super.dateDividerMargin = const EdgeInsets.only(
|
||||
bottom: 32,
|
||||
top: 16,
|
||||
),
|
||||
super.dateDividerTextStyle = const TextStyle(
|
||||
color: neutral2,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.333,
|
||||
),
|
||||
super.deliveredIcon,
|
||||
super.documentIcon,
|
||||
super.emptyChatPlaceholderTextStyle = const TextStyle(
|
||||
color: neutral2,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
super.errorColor = error,
|
||||
super.errorIcon,
|
||||
super.inputBackgroundColor = neutral0,
|
||||
super.inputSurfaceTintColor = neutral0,
|
||||
super.inputElevation = 0,
|
||||
super.inputBorderRadius = const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
super.inputContainerDecoration,
|
||||
super.inputMargin = EdgeInsets.zero,
|
||||
super.inputPadding = const EdgeInsets.fromLTRB(14, 20, 14, 20),
|
||||
super.inputTextColor = neutral7,
|
||||
super.inputTextCursorColor,
|
||||
super.inputTextDecoration = const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isCollapsed: true,
|
||||
),
|
||||
super.inputTextStyle = const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
super.messageBorderRadius = 20,
|
||||
super.messageInsetsHorizontal = 0,
|
||||
super.messageInsetsVertical = 0,
|
||||
super.messageMaxWidth = 1000,
|
||||
super.primaryColor = primary,
|
||||
super.receivedEmojiMessageTextStyle = const TextStyle(fontSize: 40),
|
||||
super.receivedMessageBodyBoldTextStyle,
|
||||
super.receivedMessageBodyCodeTextStyle,
|
||||
super.receivedMessageBodyLinkTextStyle,
|
||||
super.receivedMessageBodyTextStyle = const TextStyle(
|
||||
color: neutral0,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
super.receivedMessageCaptionTextStyle = const TextStyle(
|
||||
color: neutral2,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.333,
|
||||
),
|
||||
super.receivedMessageDocumentIconColor = primary,
|
||||
super.receivedMessageLinkDescriptionTextStyle = const TextStyle(
|
||||
color: neutral0,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.428,
|
||||
),
|
||||
super.receivedMessageLinkTitleTextStyle = const TextStyle(
|
||||
color: neutral0,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.375,
|
||||
),
|
||||
super.secondaryColor = secondary,
|
||||
super.seenIcon,
|
||||
super.sendButtonIcon,
|
||||
super.sendButtonMargin,
|
||||
super.sendingIcon,
|
||||
super.sentEmojiMessageTextStyle = const TextStyle(fontSize: 40),
|
||||
super.sentMessageBodyBoldTextStyle,
|
||||
super.sentMessageBodyCodeTextStyle,
|
||||
super.sentMessageBodyLinkTextStyle,
|
||||
super.sentMessageBodyTextStyle = const TextStyle(
|
||||
color: neutral7,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
super.sentMessageCaptionTextStyle = const TextStyle(
|
||||
color: neutral7WithOpacity,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.333,
|
||||
),
|
||||
super.sentMessageDocumentIconColor = neutral7,
|
||||
super.sentMessageLinkDescriptionTextStyle = const TextStyle(
|
||||
color: neutral7,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.428,
|
||||
),
|
||||
super.sentMessageLinkTitleTextStyle = const TextStyle(
|
||||
color: neutral7,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.375,
|
||||
),
|
||||
super.statusIconPadding = const EdgeInsets.symmetric(horizontal: 4),
|
||||
super.systemMessageTheme = const SystemMessageTheme(
|
||||
margin: EdgeInsets.only(
|
||||
bottom: 24,
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
color: neutral2,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.333,
|
||||
),
|
||||
),
|
||||
super.typingIndicatorTheme = const TypingIndicatorTheme(
|
||||
animatedCirclesColor: neutral1,
|
||||
animatedCircleSize: 5.0,
|
||||
bubbleBorder: BorderRadius.all(Radius.circular(27.0)),
|
||||
bubbleColor: neutral7,
|
||||
countAvatarColor: primary,
|
||||
countTextColor: secondary,
|
||||
multipleUserTextStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: neutral2,
|
||||
),
|
||||
),
|
||||
super.unreadHeaderTheme = const UnreadHeaderTheme(
|
||||
color: secondary,
|
||||
textStyle: TextStyle(
|
||||
color: neutral2,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.333,
|
||||
),
|
||||
),
|
||||
super.userAvatarImageBackgroundColor = Colors.transparent,
|
||||
super.userAvatarNameColors = colors,
|
||||
super.userAvatarTextStyle = const TextStyle(
|
||||
color: neutral7,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.333,
|
||||
),
|
||||
super.userNameTextStyle = const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.333,
|
||||
),
|
||||
super.highlightMessageColor,
|
||||
});
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
|
||||
class ChatInvalidUserMessage extends StatelessWidget {
|
||||
const ChatInvalidUserMessage({
|
||||
required this.message,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final errorMessage = message.metadata?[sendMessageErrorKey] ?? "";
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(height: 20, thickness: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowyText(
|
||||
errorMessage,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class ChatUserMessageBubble extends StatelessWidget {
|
||||
const ChatUserMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const borderRadius = BorderRadius.all(Radius.circular(6));
|
||||
final backgroundColor = Theme.of(context).colorScheme.secondary;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => ChatUserMessageBloc(message: message),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// _wrapHover(
|
||||
Flexible(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
// ),
|
||||
BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ChatUserAvatar(
|
||||
iconUrl: state.member?.avatarUrl ?? "",
|
||||
name: state.member?.name ?? "",
|
||||
size: 36,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatUserMessageHover extends StatefulWidget {
|
||||
const ChatUserMessageHover({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final bool autoShowHover = true;
|
||||
|
||||
@override
|
||||
State<ChatUserMessageHover> createState() => _ChatUserMessageHoverState();
|
||||
}
|
||||
|
||||
class _ChatUserMessageHoverState extends State<ChatUserMessageHover> {
|
||||
bool _isHover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isHover = widget.autoShowHover ? false : true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> children = [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 30),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (_isHover) {
|
||||
if (widget.message is TextMessage) {
|
||||
children.add(
|
||||
EditButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
).positioned(right: 0, bottom: 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditButton extends StatelessWidget {
|
||||
const EditButton({
|
||||
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: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatWelcomePage extends StatelessWidget {
|
||||
const ChatWelcomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -20,6 +21,9 @@ class BlankPluginBuilder extends PluginBuilder {
|
||||
|
||||
@override
|
||||
PluginType get pluginType => PluginType.blank;
|
||||
|
||||
@override
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
|
||||
}
|
||||
|
||||
class BlankPluginConfig implements PluginConfig {
|
||||
@ -47,7 +51,10 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
Widget tabBarItem(String pluginId) => leftBarItem;
|
||||
|
||||
@override
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) =>
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
}) =>
|
||||
const BlankPage();
|
||||
|
||||
@override
|
||||
|
@ -25,7 +25,7 @@ class BoardPluginBuilder implements PluginBuilder {
|
||||
PluginType get pluginType => PluginType.board;
|
||||
|
||||
@override
|
||||
ViewLayoutPB? get layoutType => ViewLayoutPB.Board;
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Board;
|
||||
}
|
||||
|
||||
class BoardPluginConfig implements PluginConfig {
|
||||
|
@ -25,7 +25,7 @@ class CalendarPluginBuilder extends PluginBuilder {
|
||||
PluginType get pluginType => PluginType.calendar;
|
||||
|
||||
@override
|
||||
ViewLayoutPB? get layoutType => ViewLayoutPB.Calendar;
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Calendar;
|
||||
}
|
||||
|
||||
class CalendarPluginConfig implements PluginConfig {
|
||||
|
@ -25,7 +25,7 @@ class GridPluginBuilder implements PluginBuilder {
|
||||
PluginType get pluginType => PluginType.grid;
|
||||
|
||||
@override
|
||||
ViewLayoutPB? get layoutType => ViewLayoutPB.Grid;
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Grid;
|
||||
}
|
||||
|
||||
class GridPluginConfig implements PluginConfig {
|
||||
|
@ -243,11 +243,14 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
|
||||
|
||||
@override
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
}) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
final deletedView = notifier.isDeleted.value;
|
||||
if (deletedView != null && deletedView.hasIndex()) {
|
||||
context?.onDeleted(notifier.view, deletedView.index);
|
||||
context.onDeleted?.call(notifier.view, deletedView.index);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -54,6 +54,7 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
|
||||
valueListenable: _layoutTypeChangeNotifier,
|
||||
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
context: PluginContext(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -48,6 +48,9 @@ class DatabaseDocumentPluginBuilder extends PluginBuilder {
|
||||
|
||||
@override
|
||||
PluginType get pluginType => PluginType.databaseDocument;
|
||||
|
||||
@override
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
|
||||
}
|
||||
|
||||
class DatabaseDocumentPlugin extends Plugin {
|
||||
@ -98,7 +101,10 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
EdgeInsets get contentPadding => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
}) {
|
||||
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
|
||||
builder: (_, state) => DatabaseDocumentPage(
|
||||
key: ValueKey(documentId),
|
||||
|
@ -43,7 +43,7 @@ class DocumentPluginBuilder extends PluginBuilder {
|
||||
PluginType get pluginType => PluginType.document;
|
||||
|
||||
@override
|
||||
ViewLayoutPB? get layoutType => ViewLayoutPB.Document;
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
|
||||
}
|
||||
|
||||
class DocumentPlugin extends Plugin {
|
||||
@ -107,7 +107,10 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
EdgeInsets get contentPadding => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
}) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
final deletedView = notifier.isDeleted.value;
|
||||
if (deletedView != null && deletedView.hasIndex()) {
|
||||
@ -121,7 +124,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
builder: (_, state) => DocumentPage(
|
||||
key: ValueKey(view.id),
|
||||
view: view,
|
||||
onDeleted: () => context?.onDeleted(view, deletedViewIndex),
|
||||
onDeleted: () => context.onDeleted?.call(view, deletedViewIndex),
|
||||
initialSelection: initialSelection,
|
||||
),
|
||||
),
|
||||
|
@ -5,6 +5,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mo
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/video_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/video_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@ -53,9 +55,8 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
|
||||
),
|
||||
iconBuilder: PlatformExtension.isMobile
|
||||
? (_, node, onCheck) => TodoListIcon(node: node, onCheck: onCheck)
|
||||
: null,
|
||||
iconBuilder: (_, node, onCheck) =>
|
||||
TodoListIcon(node: node, onCheck: onCheck),
|
||||
toggleChildrenTriggers: [
|
||||
LogicalKeyboardKey.shift,
|
||||
LogicalKeyboardKey.shiftLeft,
|
||||
@ -66,18 +67,14 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
|
||||
),
|
||||
iconBuilder: PlatformExtension.isMobile
|
||||
? (_, node) => BulletedListIcon(node: node)
|
||||
: null,
|
||||
iconBuilder: (_, node) => BulletedListIcon(node: node),
|
||||
),
|
||||
NumberedListBlockKeys.type: NumberedListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
|
||||
),
|
||||
iconBuilder: PlatformExtension.isMobile
|
||||
? (_, node, textDirection) =>
|
||||
NumberedListIcon(node: node, textDirection: textDirection)
|
||||
: null,
|
||||
iconBuilder: (_, node, textDirection) =>
|
||||
NumberedListIcon(node: node, textDirection: textDirection),
|
||||
),
|
||||
QuoteBlockKeys.type: QuoteBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
@ -99,9 +96,13 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
|
||||
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
|
||||
},
|
||||
placeholderText: (node) => LocaleKeys.blockPlaceholders_heading.tr(
|
||||
args: [node.attributes[HeadingBlockKeys.level].toString()],
|
||||
),
|
||||
placeholderText: (node) {
|
||||
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
|
||||
level = level.clamp(1, 6);
|
||||
return LocaleKeys.blockPlaceholders_heading.tr(
|
||||
args: [level.toString()],
|
||||
);
|
||||
},
|
||||
),
|
||||
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
|
||||
),
|
||||
@ -110,7 +111,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
showMenu: true,
|
||||
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
|
||||
Positioned(
|
||||
top: 0,
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: ImageMenu(node: node, state: state),
|
||||
),
|
||||
@ -163,7 +164,10 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
),
|
||||
),
|
||||
CalloutBlockKeys.type: CalloutBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
configuration: configuration.copyWith(
|
||||
textStyle: (_) => styleCustomizer.calloutBlockStyleBuilder(),
|
||||
placeholderTextStyle: (_) => styleCustomizer.calloutBlockStyleBuilder(),
|
||||
),
|
||||
defaultColor: calloutBGColor,
|
||||
),
|
||||
DividerBlockKeys.type: DividerBlockComponentBuilder(
|
||||
@ -180,7 +184,6 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
configuration: configuration,
|
||||
),
|
||||
CodeBlockKeys.type: CodeBlockComponentBuilder(
|
||||
editorState: editorState,
|
||||
configuration: configuration.copyWith(
|
||||
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
||||
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
||||
@ -228,6 +231,16 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
VideoBlockKeys.type: VideoBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
showMenu: true,
|
||||
menuBuilder: (node, state) => Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: VideoMenu(node: node, state: state),
|
||||
),
|
||||
placeholderBuilder: (node) => VideoPlaceholder(node: node),
|
||||
),
|
||||
};
|
||||
|
||||
final builders = {
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
|
||||
@ -20,6 +23,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@ -27,8 +31,6 @@ import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
final codeBlockLocalization = CodeBlockLocalizations(
|
||||
@ -211,6 +213,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
).handler(editorState);
|
||||
|
||||
AFFocusManager? focusManager;
|
||||
|
||||
void _loseFocus() => widget.editorState.selection = null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -251,17 +257,35 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
// customize the dynamic theme color
|
||||
_customizeBlockComponentBackgroundColorDecorator();
|
||||
|
||||
if (widget.initialSelection != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
widget.initialSelection,
|
||||
);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusManager = AFFocusManager.maybeOf(context);
|
||||
focusManager?.loseFocusNotifier.addListener(_loseFocus);
|
||||
|
||||
if (widget.initialSelection != null) {
|
||||
widget.editorState.updateSelectionWithReason(widget.initialSelection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
final currFocusManager = AFFocusManager.maybeOf(context);
|
||||
if (focusManager != currFocusManager) {
|
||||
focusManager?.loseFocusNotifier.removeListener(_loseFocus);
|
||||
focusManager = currFocusManager;
|
||||
focusManager?.loseFocusNotifier.addListener(_loseFocus);
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusManager?.loseFocusNotifier.removeListener(_loseFocus);
|
||||
|
||||
if (widget.useViewInfoBloc && !viewInfoBloc.isClosed) {
|
||||
viewInfoBloc.add(const ViewInfoEvent.unregisterEditorState());
|
||||
}
|
||||
@ -388,6 +412,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
emojiMenuItem,
|
||||
autoGeneratorMenuItem,
|
||||
dateMenuItem,
|
||||
videoBlockItem(LocaleKeys.document_plugins_video_label.tr()),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,9 @@ class BulletedListIcon extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconPadding = context.read<DocumentPageStyleBloc>().state.iconPadding;
|
||||
final iconPadding = PlatformExtension.isMobile
|
||||
? context.read<DocumentPageStyleBloc>().state.iconPadding
|
||||
: 0.0;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 22,
|
||||
|
@ -1,12 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/icon/icon_selector.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/shared/flowy_gradient_colors.dart';
|
||||
import 'package:appflowy/shared/google_fonts_extension.dart';
|
||||
@ -16,11 +18,13 @@ 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:auto_size_text_field/auto_size_text_field.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
double kDocumentCoverHeight = 98.0;
|
||||
double kDocumentTitlePadding = 20.0;
|
||||
@ -133,9 +137,11 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
|
||||
if (documentFontFamily != null && fontFamily != documentFontFamily) {
|
||||
fontFamily = getGoogleFontSafely(documentFontFamily).fontFamily;
|
||||
}
|
||||
return TextField(
|
||||
|
||||
return AutoSizeTextField(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
minFontSize: 18.0,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
@ -151,6 +157,7 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
|
||||
fontFamily: fontFamily,
|
||||
color:
|
||||
state.cover.isNone || state.cover.isPresets ? null : Colors.white,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onChanged: _rename,
|
||||
onSubmitted: _rename,
|
||||
@ -167,12 +174,40 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await context.push<EmojiPickerResult>(
|
||||
MobileEmojiPickerScreen.routeName,
|
||||
final pageStyleIconBloc = PageStyleIconBloc(view: widget.view)
|
||||
..add(const PageStyleIconEvent.initial());
|
||||
await showMobileBottomSheet(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
showDoneButton: true,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.titleBar_pageIcon.tr(),
|
||||
backgroundColor: AFThemeExtension.of(context).background,
|
||||
enableDraggableScrollable: true,
|
||||
minChildSize: 0.6,
|
||||
initialChildSize: 0.61,
|
||||
showRemoveButton: true,
|
||||
onRemove: () {
|
||||
pageStyleIconBloc.add(
|
||||
const PageStyleIconEvent.updateIcon('', true),
|
||||
);
|
||||
},
|
||||
scrollableWidgetBuilder: (_, controller) {
|
||||
return BlocProvider.value(
|
||||
value: pageStyleIconBloc,
|
||||
child: Expanded(
|
||||
child: Scrollbar(
|
||||
controller: controller,
|
||||
child: IconSelector(
|
||||
scrollController: controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
);
|
||||
if (result != null && context.mounted) {
|
||||
context.read<ViewBloc>().add(ViewEvent.updateIcon(result.emoji));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,159 @@
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
|
||||
class IconSelector extends StatefulWidget {
|
||||
const IconSelector({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<IconSelector> createState() => _IconSelectorState();
|
||||
}
|
||||
|
||||
class _IconSelectorState extends State<IconSelector> {
|
||||
EmojiData? emojiData;
|
||||
List<String> availableEmojis = [];
|
||||
|
||||
PageStyleIconBloc? pageStyleIconBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// load the emoji data from cache if it's available
|
||||
if (kCachedEmojiData != null) {
|
||||
emojiData = kCachedEmojiData;
|
||||
availableEmojis = _setupAvailableEmojis(emojiData!);
|
||||
} else {
|
||||
EmojiData.builtIn().then(
|
||||
(value) {
|
||||
kCachedEmojiData = value;
|
||||
setState(() {
|
||||
emojiData = value;
|
||||
availableEmojis = _setupAvailableEmojis(value);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pageStyleIconBloc = context.read<PageStyleIconBloc>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageStyleIconBloc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (emojiData == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return RepaintBoundary(
|
||||
child: BlocBuilder<PageStyleIconBloc, PageStyleIconState>(
|
||||
builder: (_, state) => Column(
|
||||
children: [
|
||||
_buildSearchBar(context),
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 7,
|
||||
controller: widget.scrollController,
|
||||
children: [
|
||||
for (final emoji in availableEmojis)
|
||||
_buildEmoji(context, emoji, state.icon),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmoji(
|
||||
BuildContext context,
|
||||
String emoji,
|
||||
String? selectedEmoji,
|
||||
) {
|
||||
Widget child = SizedBox.square(
|
||||
dimension: 24.0,
|
||||
child: Center(
|
||||
child: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (emoji == selectedEmoji) {
|
||||
child = Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
width: 1.40,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
color: Color(0xFF00BCF0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PageStyleIconBloc>().add(
|
||||
PageStyleIconEvent.updateIcon(emoji, true),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _setupAvailableEmojis(EmojiData emojiData) {
|
||||
final categories = emojiData.categories;
|
||||
availableEmojis = categories
|
||||
.map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e)))
|
||||
.expand((e) => e)
|
||||
.toList();
|
||||
return availableEmojis;
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
child: FlowyMobileSearchTextField(
|
||||
onChanged: (keyword) {
|
||||
if (emojiData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final filtered = emojiData!.filterByKeyword(keyword);
|
||||
final availableEmojis = _setupAvailableEmojis(filtered);
|
||||
|
||||
setState(() {
|
||||
this.availableEmojis = availableEmojis;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
@ -13,7 +15,6 @@ import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
@ -109,10 +110,7 @@ class CustomImageBlockComponentBuilder extends BlockComponentBuilder {
|
||||
node: node,
|
||||
showActions: showActions(node),
|
||||
configuration: configuration,
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
|
||||
showMenu: showMenu,
|
||||
menuBuilder: menuBuilder,
|
||||
);
|
||||
|
@ -112,7 +112,7 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _UnsplashImages extends StatelessWidget {
|
||||
class _UnsplashImages extends StatefulWidget {
|
||||
const _UnsplashImages({
|
||||
required this.type,
|
||||
required this.photos,
|
||||
@ -123,17 +123,24 @@ class _UnsplashImages extends StatelessWidget {
|
||||
final List<Photo> photos;
|
||||
final OnSelectUnsplashImage onSelectUnsplashImage;
|
||||
|
||||
@override
|
||||
State<_UnsplashImages> createState() => _UnsplashImagesState();
|
||||
}
|
||||
|
||||
class _UnsplashImagesState extends State<_UnsplashImages> {
|
||||
int _selectedPhotoIndex = -1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = switch (type) {
|
||||
final crossAxisCount = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 3,
|
||||
UnsplashImageType.fullScreen => 2,
|
||||
};
|
||||
final mainAxisSpacing = switch (type) {
|
||||
final mainAxisSpacing = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 16.0,
|
||||
UnsplashImageType.fullScreen => 16.0,
|
||||
};
|
||||
final crossAxisSpacing = switch (type) {
|
||||
final crossAxisSpacing = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 10.0,
|
||||
UnsplashImageType.fullScreen => 16.0,
|
||||
};
|
||||
@ -142,17 +149,23 @@ class _UnsplashImages extends StatelessWidget {
|
||||
mainAxisSpacing: mainAxisSpacing,
|
||||
crossAxisSpacing: crossAxisSpacing,
|
||||
childAspectRatio: 4 / 3,
|
||||
children: photos
|
||||
.map(
|
||||
(photo) => _UnsplashImage(
|
||||
type: type,
|
||||
photo: photo,
|
||||
onTap: () => onSelectUnsplashImage(
|
||||
photo.urls.full.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
children: widget.photos.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final photo = entry.value;
|
||||
return _UnsplashImage(
|
||||
type: widget.type,
|
||||
photo: photo,
|
||||
onTap: () {
|
||||
widget.onSelectUnsplashImage(
|
||||
photo.urls.regular.toString(),
|
||||
);
|
||||
setState(() {
|
||||
_selectedPhotoIndex = index;
|
||||
});
|
||||
},
|
||||
isSelected: index == _selectedPhotoIndex,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -162,11 +175,13 @@ class _UnsplashImage extends StatelessWidget {
|
||||
required this.type,
|
||||
required this.photo,
|
||||
required this.onTap,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
final UnsplashImageType type;
|
||||
final Photo photo;
|
||||
final VoidCallback onTap;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -177,7 +192,19 @@ class _UnsplashImage extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
child: isSelected
|
||||
? Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -197,6 +197,15 @@ class _AddBlockMenu extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
// video
|
||||
TypeOptionMenuItemValue(
|
||||
value: VideoBlockKeys.type,
|
||||
backgroundColor: colorMap[VideoBlockKeys.type]!,
|
||||
text: LocaleKeys.document_plugins_video_label.tr(),
|
||||
icon: FlowySvgs.m_add_block_video_s,
|
||||
onTap: (_, __) => _insertBlock(videoBlockNode()),
|
||||
),
|
||||
|
||||
// date
|
||||
TypeOptionMenuItemValue(
|
||||
value: ParagraphBlockKeys.type,
|
||||
@ -287,6 +296,7 @@ class _AddBlockMenu extends StatelessWidget {
|
||||
NumberedListBlockKeys.type: const Color(0xFFA35F94),
|
||||
ToggleListBlockKeys.type: const Color(0xFFA35F94),
|
||||
ImageBlockKeys.type: const Color(0xFFBAAC74),
|
||||
VideoBlockKeys.type: const Color(0xFFBAAC74),
|
||||
MentionBlockKeys.type: const Color(0xFF40AAB8),
|
||||
DividerBlockKeys.type: const Color(0xFF4BB299),
|
||||
CalloutBlockKeys.type: const Color(0xFF66599B),
|
||||
@ -303,6 +313,7 @@ class _AddBlockMenu extends StatelessWidget {
|
||||
NumberedListBlockKeys.type: const Color(0xFFFFB9EF),
|
||||
ToggleListBlockKeys.type: const Color(0xFFFFB9EF),
|
||||
ImageBlockKeys.type: const Color(0xFFFDEDA7),
|
||||
VideoBlockKeys.type: const Color(0xFFFDEDA7),
|
||||
MentionBlockKeys.type: const Color(0xFF91EAF5),
|
||||
DividerBlockKeys.type: const Color(0xFF98F4CD),
|
||||
CalloutBlockKeys.type: const Color(0xFFCABDFF),
|
||||
|
@ -1,8 +1,7 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/icon/icon_selector.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
@ -11,7 +10,6 @@ import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class PageStyleIcon extends StatefulWidget {
|
||||
@ -94,7 +92,7 @@ class _PageStyleIconState extends State<PageStyleIcon> {
|
||||
child: Expanded(
|
||||
child: Scrollbar(
|
||||
controller: controller,
|
||||
child: _IconSelector(
|
||||
child: IconSelector(
|
||||
scrollController: controller,
|
||||
),
|
||||
),
|
||||
@ -105,154 +103,3 @@ class _PageStyleIconState extends State<PageStyleIcon> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconSelector extends StatefulWidget {
|
||||
const _IconSelector({
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<_IconSelector> createState() => _IconSelectorState();
|
||||
}
|
||||
|
||||
class _IconSelectorState extends State<_IconSelector> {
|
||||
EmojiData? emojiData;
|
||||
List<String> availableEmojis = [];
|
||||
|
||||
PageStyleIconBloc? pageStyleIconBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// load the emoji data from cache if it's available
|
||||
if (kCachedEmojiData != null) {
|
||||
emojiData = kCachedEmojiData;
|
||||
availableEmojis = _setupAvailableEmojis(emojiData!);
|
||||
} else {
|
||||
EmojiData.builtIn().then(
|
||||
(value) {
|
||||
kCachedEmojiData = value;
|
||||
setState(() {
|
||||
emojiData = value;
|
||||
availableEmojis = _setupAvailableEmojis(value);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pageStyleIconBloc = context.read<PageStyleIconBloc>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageStyleIconBloc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (emojiData == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return RepaintBoundary(
|
||||
child: BlocBuilder<PageStyleIconBloc, PageStyleIconState>(
|
||||
builder: (_, state) => Column(
|
||||
children: [
|
||||
_buildSearchBar(context),
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 7,
|
||||
controller: widget.scrollController,
|
||||
children: [
|
||||
for (final emoji in availableEmojis)
|
||||
_buildEmoji(context, emoji, state.icon),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmoji(
|
||||
BuildContext context,
|
||||
String emoji,
|
||||
String? selectedEmoji,
|
||||
) {
|
||||
Widget child = SizedBox.square(
|
||||
dimension: 24.0,
|
||||
child: Center(
|
||||
child: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (emoji == selectedEmoji) {
|
||||
child = Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
width: 1.40,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
color: Color(0xFF00BCF0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PageStyleIconBloc>().add(
|
||||
PageStyleIconEvent.updateIcon(emoji, true),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _setupAvailableEmojis(EmojiData emojiData) {
|
||||
final categories = emojiData.categories;
|
||||
availableEmojis = categories
|
||||
.map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e)))
|
||||
.expand((e) => e)
|
||||
.toList();
|
||||
return availableEmojis;
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
child: FlowyMobileSearchTextField(
|
||||
onChanged: (keyword) {
|
||||
if (emojiData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final filtered = emojiData!.filterByKeyword(keyword);
|
||||
final availableEmojis = _setupAvailableEmojis(filtered);
|
||||
|
||||
setState(() {
|
||||
this.availableEmojis = availableEmojis;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,9 @@ class TodoListIcon extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconPadding = context.read<DocumentPageStyleBloc>().state.iconPadding;
|
||||
final iconPadding = PlatformExtension.isMobile
|
||||
? context.read<DocumentPageStyleBloc>().state.iconPadding
|
||||
: 0.0;
|
||||
final checked = node.attributes[TodoListBlockKeys.checked] ?? false;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
|
||||
class UploadVideoMenu extends StatefulWidget {
|
||||
const UploadVideoMenu({
|
||||
super.key,
|
||||
required this.onUrlSubmitted,
|
||||
this.onSelectedColor,
|
||||
});
|
||||
|
||||
final void Function(String url) onUrlSubmitted;
|
||||
final void Function(String color)? onSelectedColor;
|
||||
|
||||
@override
|
||||
State<UploadVideoMenu> createState() => _UploadVideoMenuState();
|
||||
}
|
||||
|
||||
class _UploadVideoMenuState extends State<UploadVideoMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final constraints =
|
||||
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
constraints: constraints,
|
||||
child: _EmbedUrl(onSubmit: widget.onUrlSubmitted),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmbedUrl extends StatefulWidget {
|
||||
const _EmbedUrl({required this.onSubmit});
|
||||
|
||||
final void Function(String url) onSubmit;
|
||||
|
||||
@override
|
||||
State<_EmbedUrl> createState() => _EmbedUrlState();
|
||||
}
|
||||
|
||||
class _EmbedUrlState extends State<_EmbedUrl> {
|
||||
bool isUrlValid = true;
|
||||
bool isYouTubeError = false;
|
||||
String inputText = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyTextField(
|
||||
hintText: LocaleKeys.document_plugins_video_placeholder.tr(),
|
||||
onChanged: (value) => inputText = value,
|
||||
onEditingComplete: submit,
|
||||
),
|
||||
if (!isUrlValid) ...[
|
||||
const VSpace(8),
|
||||
FlowyText(
|
||||
isYouTubeError
|
||||
? LocaleKeys.document_plugins_video_invalidVideoUrlYouTube.tr()
|
||||
: LocaleKeys.document_plugins_video_invalidVideoUrl.tr(),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
],
|
||||
const VSpace(8),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_video_supportedFormats.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const VSpace(8),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: FlowyButton(
|
||||
showDefaultBoxDecorationOnMobile: true,
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
text: FlowyText(
|
||||
LocaleKeys.document_plugins_video_insertVideo.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
onTap: submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void submit() {
|
||||
if (checkUrlValidity(inputText)) {
|
||||
return widget.onSubmit(inputText);
|
||||
}
|
||||
|
||||
isYouTubeError = youtubeUrlRegex.hasMatch(inputText);
|
||||
setState(() => isUrlValid = false);
|
||||
}
|
||||
|
||||
bool checkUrlValidity(String url) => videoUrlRegex.hasMatch(url);
|
||||
}
|
@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class VideoMenu extends StatefulWidget {
|
||||
const VideoMenu({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final VideoBlockComponentState state;
|
||||
|
||||
@override
|
||||
State<VideoMenu> createState() => _VideoMenuState();
|
||||
}
|
||||
|
||||
class _VideoMenuState extends State<VideoMenu> {
|
||||
late final String? url = widget.node.attributes[VideoBlockKeys.url];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: PlatformExtension.isMobile
|
||||
? MenuBlockButton(
|
||||
tooltip: LocaleKeys.button_edit.tr(),
|
||||
iconData: FlowySvgs.more_s,
|
||||
onTap: showMobileMenu,
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
const HSpace(4),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.editor_copyLink.tr(),
|
||||
iconData: FlowySvgs.copy_s,
|
||||
onTap: copyVideoLink,
|
||||
),
|
||||
const HSpace(4),
|
||||
_VideoAlignButton(
|
||||
node: widget.node,
|
||||
state: widget.state,
|
||||
),
|
||||
const _Divider(),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.button_delete.tr(),
|
||||
iconData: FlowySvgs.delete_s,
|
||||
onTap: deleteVideo,
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void copyVideoLink() {
|
||||
if (url != null) {
|
||||
Clipboard.setData(ClipboardData(text: url!));
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_video_copiedToPasteBoard.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showMobileMenu() {
|
||||
final editorState = context.read<EditorState>()
|
||||
..updateSelectionWithReason(null, extraInfo: {});
|
||||
final src = widget.node.attributes[VideoBlockKeys.url];
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
showCloseButton: true,
|
||||
showDragHandle: true,
|
||||
title: LocaleKeys.document_plugins_action.tr(),
|
||||
builder: (context) {
|
||||
return BlockActionBottomSheet(
|
||||
extendActionWidgets: [
|
||||
FlowyOptionTile.text(
|
||||
showTopBorder: false,
|
||||
text: LocaleKeys.editor_copyLink.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.m_field_copy_s,
|
||||
),
|
||||
onTap: () async {
|
||||
context.pop();
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_video_copiedToPasteBoard.tr(),
|
||||
);
|
||||
await getIt<ClipboardService>().setPlainText(src);
|
||||
},
|
||||
),
|
||||
],
|
||||
onAction: (action) async {
|
||||
context.pop();
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
switch (action) {
|
||||
case BlockActionBottomSheetType.delete:
|
||||
transaction.deleteNode(widget.node);
|
||||
break;
|
||||
case BlockActionBottomSheetType.duplicate:
|
||||
transaction.insertNode(
|
||||
widget.node.path.next,
|
||||
widget.node.copyWith(),
|
||||
);
|
||||
break;
|
||||
case BlockActionBottomSheetType.insertAbove:
|
||||
case BlockActionBottomSheetType.insertBelow:
|
||||
final path = action == BlockActionBottomSheetType.insertAbove
|
||||
? widget.node.path
|
||||
: widget.node.path.next;
|
||||
transaction
|
||||
..insertNode(path, paragraphNode())
|
||||
..afterSelection = Selection.collapsed(Position(path: path));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
if (transaction.operations.isNotEmpty) {
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteVideo() async {
|
||||
final node = widget.node;
|
||||
final editorState = context.read<EditorState>();
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNode(node);
|
||||
transaction.afterSelection = null;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoAlignButton extends StatefulWidget {
|
||||
const _VideoAlignButton({
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final VideoBlockComponentState state;
|
||||
|
||||
@override
|
||||
State<_VideoAlignButton> createState() => _VideoAlignButtonState();
|
||||
}
|
||||
|
||||
const interceptorKey = 'video-align';
|
||||
|
||||
class _VideoAlignButtonState extends State<_VideoAlignButton> {
|
||||
final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: interceptorKey,
|
||||
canTap: (_) => false,
|
||||
);
|
||||
|
||||
String get align =>
|
||||
widget.node.attributes[VideoBlockKeys.alignment] ?? centerAlignmentKey;
|
||||
final popoverController = PopoverController();
|
||||
late final EditorState editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
editorState = context.read<EditorState>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
allowMenuClose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnoreParentGestureWidget(
|
||||
child: AppFlowyPopover(
|
||||
onClose: allowMenuClose,
|
||||
controller: popoverController,
|
||||
windowPadding: const EdgeInsets.all(0),
|
||||
margin: const EdgeInsets.all(0),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 10),
|
||||
child: MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_plugins_optionAction_align.tr(),
|
||||
iconData: iconFor(align),
|
||||
),
|
||||
popupBuilder: (_) {
|
||||
preventMenuClose();
|
||||
return _AlignButtons(onAlignChanged: onAlignChanged);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onAlignChanged(String align) {
|
||||
popoverController.close();
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {VideoBlockKeys.alignment: align});
|
||||
editorState.apply(transaction);
|
||||
|
||||
allowMenuClose();
|
||||
}
|
||||
|
||||
void preventMenuClose() {
|
||||
widget.state.preventClose = true;
|
||||
editorState.service.selectionService
|
||||
.registerGestureInterceptor(gestureInterceptor);
|
||||
}
|
||||
|
||||
void allowMenuClose() {
|
||||
widget.state.preventClose = false;
|
||||
editorState.service.selectionService
|
||||
.unregisterGestureInterceptor(interceptorKey);
|
||||
}
|
||||
|
||||
FlowySvgData iconFor(String alignment) {
|
||||
switch (alignment) {
|
||||
case leftAlignmentKey:
|
||||
return FlowySvgs.align_left_s;
|
||||
case rightAlignmentKey:
|
||||
return FlowySvgs.align_right_s;
|
||||
case centerAlignmentKey:
|
||||
default:
|
||||
return FlowySvgs.align_center_s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AlignButtons extends StatelessWidget {
|
||||
const _AlignButtons({required this.onAlignChanged});
|
||||
|
||||
final Function(String align) onAlignChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(4),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_plugins_optionAction_left,
|
||||
iconData: FlowySvgs.align_left_s,
|
||||
onTap: () => onAlignChanged(leftAlignmentKey),
|
||||
),
|
||||
const _Divider(),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_plugins_optionAction_center,
|
||||
iconData: FlowySvgs.align_center_s,
|
||||
onTap: () => onAlignChanged(centerAlignmentKey),
|
||||
),
|
||||
const _Divider(),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_plugins_optionAction_right,
|
||||
iconData: FlowySvgs.align_right_s,
|
||||
onTap: () => onAlignChanged(rightAlignmentKey),
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(width: 1, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/upload_video_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class VideoPlaceholder extends StatefulWidget {
|
||||
const VideoPlaceholder({super.key, required this.node});
|
||||
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
State<VideoPlaceholder> createState() => VideoPlaceholderState();
|
||||
}
|
||||
|
||||
class VideoPlaceholderState extends State<VideoPlaceholder> {
|
||||
final controller = PopoverController();
|
||||
final documentService = DocumentService();
|
||||
late final editorState = context.read<EditorState>();
|
||||
|
||||
bool showLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FlowyHover(
|
||||
style: HoverStyle(borderRadius: BorderRadius.circular(4)),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(10),
|
||||
const Icon(Icons.featured_video_outlined, size: 24),
|
||||
const HSpace(10),
|
||||
FlowyText(LocaleKeys.document_plugins_video_emptyLabel.tr()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
return AppFlowyPopover(
|
||||
controller: controller,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 540,
|
||||
maxHeight: 360,
|
||||
minHeight: 80,
|
||||
),
|
||||
clickHandler: PopoverClickHandler.gestureDetector,
|
||||
popupBuilder: (_) => UploadVideoMenu(
|
||||
onUrlSubmitted: (url) {
|
||||
controller.close();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) async => updateSrc(url),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return MobileBlockActionButtons(
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadVideoMenu();
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showUploadVideoMenu() {
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
controller.show();
|
||||
} else {
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
title: LocaleKeys.document_plugins_video_label.tr(),
|
||||
showHeader: true,
|
||||
showCloseButton: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => Container(
|
||||
margin: const EdgeInsets.only(top: 12.0),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 340,
|
||||
minHeight: 80,
|
||||
),
|
||||
child: UploadVideoMenu(
|
||||
onUrlSubmitted: (url) async {
|
||||
context.pop();
|
||||
await updateSrc(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSrc(String url) async {
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
VideoBlockKeys.url: url,
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
@ -1,5 +1,3 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||
@ -60,6 +58,9 @@ class EditorStyleCustomizer {
|
||||
DefaultAppearanceSettings.getDefaultSelectionColor(context),
|
||||
defaultTextDirection: appearance.defaultTextDirection,
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
lineHeight: 1.2,
|
||||
applyHeightToFirstAscent: true,
|
||||
applyHeightToLastDescent: true,
|
||||
text: baseTextStyle(fontFamily).copyWith(
|
||||
fontSize: fontSize,
|
||||
color: afThemeExtension.onBackground,
|
||||
@ -80,7 +81,7 @@ class EditorStyleCustomizer {
|
||||
),
|
||||
code: GoogleFonts.robotoMono(
|
||||
textStyle: baseTextStyle(fontFamily).copyWith(
|
||||
fontSize: fontSize - 2,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.red,
|
||||
backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8),
|
||||
@ -105,7 +106,6 @@ class EditorStyleCustomizer {
|
||||
final textScaleFactor =
|
||||
context.read<AppearanceSettingsCubit>().state.textScaleFactor;
|
||||
final baseTextStyle = this.baseTextStyle(fontFamily);
|
||||
final codeFontSize = max(0.0, fontSize - 2);
|
||||
return EditorStyle.mobile(
|
||||
padding: padding,
|
||||
defaultTextDirection: defaultTextDirection,
|
||||
@ -127,7 +127,7 @@ class EditorStyleCustomizer {
|
||||
),
|
||||
code: GoogleFonts.robotoMono(
|
||||
textStyle: baseTextStyle.copyWith(
|
||||
fontSize: codeFontSize,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.red,
|
||||
@ -183,6 +183,14 @@ class EditorStyleCustomizer {
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle calloutBlockStyleBuilder() {
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
return baseTextStyle(null).copyWith(
|
||||
fontSize: fontSize,
|
||||
height: 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle outlineBlockPlaceholderStyleBuilder() {
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
return TextStyle(
|
||||
|
@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
|
||||
@ -20,6 +18,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// const _channel = "InlinePageReference";
|
||||
|
||||
@ -65,8 +64,11 @@ class InlinePageReferenceService extends InlineActionsDelegate {
|
||||
|
||||
_recentViewsInitialized = true;
|
||||
|
||||
final views =
|
||||
(await _recentService.recentViews()).reversed.toSet().toList();
|
||||
final views = (await _recentService.recentViews())
|
||||
.reversed
|
||||
.map((e) => e.item)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
// Filter by viewLayout
|
||||
views.retainWhere(
|
||||
|
@ -5,6 +5,7 @@ export "./src/trash_header.dart";
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -26,6 +27,9 @@ class TrashPluginBuilder extends PluginBuilder {
|
||||
|
||||
@override
|
||||
PluginType get pluginType => PluginType.trash;
|
||||
|
||||
@override
|
||||
ViewLayoutPB get layoutType => ViewLayoutPB.Document;
|
||||
}
|
||||
|
||||
class TrashPluginConfig implements PluginConfig {
|
||||
@ -59,7 +63,10 @@ class TrashPluginDisplay extends PluginWidgetBuilder {
|
||||
Widget? get rightBarItem => null;
|
||||
|
||||
@override
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) =>
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
}) =>
|
||||
const TrashPage(
|
||||
key: ValueKey('TrashPage'),
|
||||
);
|
||||
|
@ -13,6 +13,20 @@ const _imgUrlPattern =
|
||||
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?';
|
||||
final imgUrlRegex = RegExp(_imgUrlPattern);
|
||||
|
||||
/// This pattern allows for both HTTP and HTTPS Scheme
|
||||
/// It allows for query parameters
|
||||
/// It only allows the following video extensions:
|
||||
/// .mp4, .mov, .avi, .webm, .flv, .m4v (mpeg), .mpeg, .h264,
|
||||
///
|
||||
const _videoUrlPattern =
|
||||
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.mp4|.mov|.avi|.webm|.flv|.m4v|.mpeg|.h264)(\?[^\s[",><]*)?';
|
||||
final videoUrlRegex = RegExp(_videoUrlPattern);
|
||||
|
||||
/// This pattern matches both youtube.com and shortened youtu.be urls.
|
||||
///
|
||||
const _youtubeUrlPattern = r'^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/';
|
||||
final youtubeUrlRegex = RegExp(_youtubeUrlPattern);
|
||||
|
||||
const _appflowyCloudUrlPattern = r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)';
|
||||
final appflowyCloudUrlRegex = RegExp(_appflowyCloudUrlPattern);
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
library flowy_plugin;
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@ -18,6 +19,7 @@ enum PluginType {
|
||||
board,
|
||||
calendar,
|
||||
databaseDocument,
|
||||
chat,
|
||||
}
|
||||
|
||||
typedef PluginId = String;
|
||||
@ -57,7 +59,7 @@ abstract class PluginBuilder {
|
||||
|
||||
/// The layoutType is used in the backend to determine the layout of the view.
|
||||
/// Currently, AppFlowy supports 4 layout types: Document, Grid, Board, Calendar.
|
||||
ViewLayoutPB? get layoutType => ViewLayoutPB.Document;
|
||||
ViewLayoutPB? get layoutType;
|
||||
}
|
||||
|
||||
abstract class PluginConfig {
|
||||
@ -71,14 +73,21 @@ abstract class PluginWidgetBuilder with NavigationItem {
|
||||
EdgeInsets get contentPadding =>
|
||||
const EdgeInsets.symmetric(horizontal: 40, vertical: 28);
|
||||
|
||||
Widget buildWidget({PluginContext? context, required bool shrinkWrap});
|
||||
Widget buildWidget({
|
||||
required PluginContext context,
|
||||
required bool shrinkWrap,
|
||||
});
|
||||
}
|
||||
|
||||
class PluginContext {
|
||||
PluginContext({required this.onDeleted});
|
||||
PluginContext({
|
||||
this.userProfile,
|
||||
this.onDeleted,
|
||||
});
|
||||
|
||||
// calls when widget of the plugin get deleted
|
||||
final Function(ViewPB, int?) onDeleted;
|
||||
final Function(ViewPB, int?)? onDeleted;
|
||||
final UserProfilePB? userProfile;
|
||||
}
|
||||
|
||||
void registerPlugin({required PluginBuilder builder, PluginConfig? config}) {
|
||||
|
@ -253,9 +253,9 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
}
|
||||
|
||||
class AppGlobals {
|
||||
// static GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey = GlobalKey();
|
||||
static GlobalKey<NavigatorState> rootNavKey = GlobalKey();
|
||||
static NavigatorState get nav => rootNavKey.currentState!;
|
||||
static BuildContext get context => rootNavKey.currentContext!;
|
||||
}
|
||||
|
||||
class ApplicationBlocObserver extends BlocObserver {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/card/card.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart';
|
||||
@ -61,6 +62,7 @@ GoRouter generateRouter(Widget child) {
|
||||
_mobileGridScreenRoute(),
|
||||
_mobileBoardScreenRoute(),
|
||||
_mobileCalendarScreenRoute(),
|
||||
_mobileChatScreenRoute(),
|
||||
// card detail page
|
||||
_mobileCardDetailScreenRoute(),
|
||||
_mobileDateCellEditScreenRoute(),
|
||||
@ -488,6 +490,21 @@ GoRoute _mobileEditorScreenRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _mobileChatScreenRoute() {
|
||||
return GoRoute(
|
||||
path: MobileChatScreen.routeName,
|
||||
parentNavigatorKey: AppGlobals.rootNavKey,
|
||||
pageBuilder: (context, state) {
|
||||
final id = state.uri.queryParameters[MobileChatScreen.viewId]!;
|
||||
final title = state.uri.queryParameters[MobileChatScreen.viewTitle];
|
||||
|
||||
return MaterialExtendedPage(
|
||||
child: MobileChatScreen(id: id, title: title),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GoRoute _mobileGridScreenRoute() {
|
||||
return GoRoute(
|
||||
path: MobileGridScreen.routeName,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/ai_chat/chat.dart';
|
||||
import 'package:appflowy/plugins/database/calendar/calendar.dart';
|
||||
import 'package:appflowy/plugins/database/board/board.dart';
|
||||
import 'package:appflowy/plugins/database/grid/grid.dart';
|
||||
@ -29,6 +30,14 @@ class PluginLoadTask extends LaunchTask {
|
||||
builder: DatabaseDocumentPluginBuilder(),
|
||||
config: DatabaseDocumentPluginConfig(),
|
||||
);
|
||||
registerPlugin(
|
||||
builder: DatabaseDocumentPluginBuilder(),
|
||||
config: DatabaseDocumentPluginConfig(),
|
||||
);
|
||||
registerPlugin(
|
||||
builder: AIChatPluginBuilder(),
|
||||
config: AIChatPluginConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -40,17 +40,20 @@ class FavoriteBloc extends Bloc<FavoriteEvent, FavoriteState> {
|
||||
emit(
|
||||
result.fold(
|
||||
(favoriteViews) {
|
||||
final views = favoriteViews.items.map((v) => v.item).toList();
|
||||
final pinnedViews = views.where((v) => v.isPinned).toList();
|
||||
final views = favoriteViews.items.toList();
|
||||
final pinnedViews =
|
||||
views.where((v) => v.item.isPinned).toList();
|
||||
final unpinnedViews =
|
||||
views.where((v) => !v.isPinned).toList();
|
||||
views.where((v) => !v.item.isPinned).toList();
|
||||
return state.copyWith(
|
||||
isLoading: false,
|
||||
views: views,
|
||||
pinnedViews: pinnedViews,
|
||||
unpinnedViews: unpinnedViews,
|
||||
);
|
||||
},
|
||||
(error) => state.copyWith(
|
||||
isLoading: false,
|
||||
views: [],
|
||||
),
|
||||
),
|
||||
@ -105,12 +108,11 @@ class FavoriteEvent with _$FavoriteEvent {
|
||||
@freezed
|
||||
class FavoriteState with _$FavoriteState {
|
||||
const factory FavoriteState({
|
||||
required List<ViewPB> views,
|
||||
@Default([]) List<ViewPB> pinnedViews,
|
||||
@Default([]) List<ViewPB> unpinnedViews,
|
||||
@Default([]) List<SectionViewPB> views,
|
||||
@Default([]) List<SectionViewPB> pinnedViews,
|
||||
@Default([]) List<SectionViewPB> unpinnedViews,
|
||||
@Default(true) bool isLoading,
|
||||
}) = _FavoriteState;
|
||||
|
||||
factory FavoriteState.initial() => const FavoriteState(
|
||||
views: [],
|
||||
);
|
||||
factory FavoriteState.initial() => const FavoriteState();
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/recent/recent_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// This is a lazy-singleton to share recent views across the application.
|
||||
///
|
||||
@ -23,21 +22,23 @@ class CachedRecentService {
|
||||
|
||||
Completer<void> _completer = Completer();
|
||||
|
||||
ValueNotifier<List<ViewPB>> notifier = ValueNotifier(const []);
|
||||
ValueNotifier<List<SectionViewPB>> notifier = ValueNotifier(const []);
|
||||
|
||||
List<ViewPB> get _recentViews => notifier.value;
|
||||
set _recentViews(List<ViewPB> value) => notifier.value = value;
|
||||
List<SectionViewPB> get _recentViews => notifier.value;
|
||||
set _recentViews(List<SectionViewPB> value) => notifier.value = value;
|
||||
|
||||
final _listener = RecentViewsListener();
|
||||
|
||||
Future<List<ViewPB>> recentViews() async {
|
||||
Future<List<SectionViewPB>> recentViews() async {
|
||||
if (_isInitialized) return _recentViews;
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
_listener.start(recentViewsUpdated: _recentViewsUpdated);
|
||||
final result = await _readRecentViews();
|
||||
_recentViews = result.toNullable()?.items ?? const [];
|
||||
_recentViews = await _readRecentViews().fold(
|
||||
(s) => s.items,
|
||||
(_) => [],
|
||||
);
|
||||
_completer.complete();
|
||||
|
||||
return _recentViews;
|
||||
@ -47,15 +48,24 @@ class CachedRecentService {
|
||||
Future<FlowyResult<void, FlowyError>> updateRecentViews(
|
||||
List<String> viewIds,
|
||||
bool addInRecent,
|
||||
) async =>
|
||||
FolderEventUpdateRecentViews(
|
||||
UpdateRecentViewPayloadPB(
|
||||
viewIds: viewIds,
|
||||
addInRecent: addInRecent,
|
||||
),
|
||||
).send();
|
||||
) async {
|
||||
final List<String> duplicatedViewIds = [];
|
||||
for (final viewId in viewIds) {
|
||||
for (final view in _recentViews) {
|
||||
if (view.item.id == viewId) {
|
||||
duplicatedViewIds.add(viewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return FolderEventUpdateRecentViews(
|
||||
UpdateRecentViewPayloadPB(
|
||||
viewIds: addInRecent ? viewIds : duplicatedViewIds,
|
||||
addInRecent: addInRecent,
|
||||
),
|
||||
).send();
|
||||
}
|
||||
|
||||
Future<FlowyResult<RepeatedViewPB, FlowyError>> _readRecentViews() =>
|
||||
Future<FlowyResult<RepeatedRecentViewPB, FlowyError>> _readRecentViews() =>
|
||||
FolderEventReadRecentViews().send();
|
||||
|
||||
bool _isInitialized = false;
|
||||
@ -74,11 +84,12 @@ class CachedRecentService {
|
||||
|
||||
void _recentViewsUpdated(
|
||||
FlowyResult<RepeatedViewIdPB, FlowyError> result,
|
||||
) {
|
||||
) async {
|
||||
final viewIds = result.toNullable();
|
||||
if (viewIds != null) {
|
||||
_readRecentViews().then(
|
||||
(views) => _recentViews = views.toNullable()?.items ?? const [],
|
||||
_recentViews = await _readRecentViews().fold(
|
||||
(s) => s.items,
|
||||
(_) => [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,12 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
|
||||
await _service.updateRecentViews(e.viewIds, false);
|
||||
},
|
||||
fetchRecentViews: (e) async {
|
||||
emit(state.copyWith(views: await _service.recentViews()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
views: await _service.recentViews(),
|
||||
),
|
||||
);
|
||||
},
|
||||
resetRecentViews: (e) async {
|
||||
await _service.reset();
|
||||
@ -63,8 +68,10 @@ class RecentViewsEvent with _$RecentViewsEvent {
|
||||
|
||||
@freezed
|
||||
class RecentViewsState with _$RecentViewsState {
|
||||
const factory RecentViewsState({required List<ViewPB> views}) =
|
||||
_RecentViewsState;
|
||||
const factory RecentViewsState({
|
||||
required List<SectionViewPB> views,
|
||||
@Default(true) bool isLoading,
|
||||
}) = _RecentViewsState;
|
||||
|
||||
factory RecentViewsState.initial() => const RecentViewsState(views: []);
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_settings_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
|
||||
@ -10,23 +13,30 @@ part 'notification_settings_cubit.freezed.dart';
|
||||
|
||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
NotificationSettingsCubit() : super(NotificationSettingsState.initial()) {
|
||||
UserSettingsBackendService()
|
||||
.getNotificationSettings()
|
||||
.then((notificationSettings) {
|
||||
_notificationSettings = notificationSettings;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isNotificationsEnabled: _notificationSettings.notificationsEnabled,
|
||||
),
|
||||
);
|
||||
_initCompleter.complete();
|
||||
});
|
||||
_initialize();
|
||||
}
|
||||
|
||||
final Completer<void> _initCompleter = Completer();
|
||||
|
||||
late final NotificationSettingsPB _notificationSettings;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_notificationSettings =
|
||||
await UserSettingsBackendService().getNotificationSettings();
|
||||
|
||||
final showNotificationSetting = await getIt<KeyValueStorage>()
|
||||
.getWithFormat(KVKeys.showNotificationIcon, (v) => bool.parse(v));
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isNotificationsEnabled: _notificationSettings.notificationsEnabled,
|
||||
isShowNotificationsIconEnabled: showNotificationSetting ?? true,
|
||||
),
|
||||
);
|
||||
|
||||
_initCompleter.complete();
|
||||
}
|
||||
|
||||
Future<void> toggleNotificationsEnabled() async {
|
||||
await _initCompleter.future;
|
||||
|
||||
@ -41,9 +51,24 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
await _saveNotificationSettings();
|
||||
}
|
||||
|
||||
Future<void> toogleShowNotificationIconEnabled() async {
|
||||
await _initCompleter.future;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isShowNotificationsIconEnabled: !state.isShowNotificationsIconEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveNotificationSettings() async {
|
||||
await _initCompleter.future;
|
||||
|
||||
await getIt<KeyValueStorage>().set(
|
||||
KVKeys.showNotificationIcon,
|
||||
state.isShowNotificationsIconEnabled.toString(),
|
||||
);
|
||||
|
||||
final result = await UserSettingsBackendService()
|
||||
.setNotificationSettings(_notificationSettings);
|
||||
result.fold(
|
||||
@ -59,8 +84,12 @@ class NotificationSettingsState with _$NotificationSettingsState {
|
||||
|
||||
const factory NotificationSettingsState({
|
||||
required bool isNotificationsEnabled,
|
||||
required bool isShowNotificationsIconEnabled,
|
||||
}) = _NotificationSettingsState;
|
||||
|
||||
factory NotificationSettingsState.initial() =>
|
||||
const NotificationSettingsState(isNotificationsEnabled: true);
|
||||
const NotificationSettingsState(
|
||||
isNotificationsEnabled: true,
|
||||
isShowNotificationsIconEnabled: true,
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/user/application/user_listener.dart';
|
||||
@ -12,6 +10,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
@ -51,6 +50,7 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
|
||||
'init workspace, current workspace: ${currentWorkspace?.workspaceId}, '
|
||||
'workspaces: ${workspaces.map((e) => e.workspaceId)}, isCollabWorkspaceOn: $isCollabWorkspaceOn',
|
||||
);
|
||||
final members = await _fetchMembers(currentWorkspace?.workspaceId);
|
||||
if (currentWorkspace != null && result.$3 == true) {
|
||||
Log.info('init open workspace: ${currentWorkspace.workspaceId}');
|
||||
await _userService.openWorkspace(currentWorkspace.workspaceId);
|
||||
@ -61,6 +61,7 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
|
||||
workspaces: workspaces,
|
||||
isCollabWorkspaceOn: isCollabWorkspaceOn,
|
||||
actionResult: null,
|
||||
members: members,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -198,6 +199,7 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
|
||||
),
|
||||
(e) => state.currentWorkspace,
|
||||
);
|
||||
final members = await _fetchMembers(currentWorkspace?.workspaceId);
|
||||
|
||||
result
|
||||
..onSuccess((s) {
|
||||
@ -212,6 +214,7 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentWorkspace: currentWorkspace,
|
||||
members: members,
|
||||
actionResult: UserWorkspaceActionResult(
|
||||
actionType: UserWorkspaceActionType.open,
|
||||
isLoading: false,
|
||||
@ -415,6 +418,17 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
|
||||
..name = workspace.name
|
||||
..createdAtTimestamp = workspace.createTime;
|
||||
}
|
||||
|
||||
Future<List<WorkspaceMemberPB>> _fetchMembers(
|
||||
String? workspaceId,
|
||||
) async {
|
||||
if (workspaceId == null) {
|
||||
return [];
|
||||
}
|
||||
return _userService
|
||||
.getWorkspaceMembers(workspaceId)
|
||||
.fold((s) => s.items, (_) => []);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -477,6 +491,7 @@ class UserWorkspaceState with _$UserWorkspaceState {
|
||||
@Default([]) List<UserWorkspacePB> workspaces,
|
||||
@Default(null) UserWorkspaceActionResult? actionResult,
|
||||
@Default(false) bool isCollabWorkspaceOn,
|
||||
@Default([]) List<WorkspaceMemberPB> members,
|
||||
}) = _UserWorkspaceState;
|
||||
|
||||
factory UserWorkspaceState.initial() => const UserWorkspaceState();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user