chore: merge branch 'upstream/main' into feat/billing-client

This commit is contained in:
Mathias Mogensen
2024-06-03 13:49:15 +02:00
428 changed files with 17125 additions and 4162 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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: |

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -66,5 +66,10 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

@ -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';
}

View File

@ -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

View File

@ -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();
}

View File

@ -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',

View File

@ -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(

View File

@ -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(

View File

@ -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),

View File

@ -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,
),
],
),
);
}
}

View File

@ -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);
}
}
}

View File

@ -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,
],
);
},
),

View File

@ -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,
);
}
}

View File

@ -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),
],

View File

@ -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,
),
),
],

View File

@ -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,
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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),
),
);
}
}

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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.

View File

@ -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,
),
);
}
}

View File

@ -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,
]);
},
),
),
],

View File

@ -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,
),

View File

@ -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(),
};
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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,
),
);
}
}

View File

@ -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();
}
}

View File

@ -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();
}

View File

@ -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');
});
},
);
},
);
}
}

View File

@ -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,
],
);
},
),
);
},
);
}
}

View File

@ -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,
);

View File

@ -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,
),
),

View File

@ -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,
),
],
),
);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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);
}

View 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];
}

View 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));
}
}

View File

@ -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());
},
),
);
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -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();
}
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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,
),
);
}
}

View File

@ -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,
});
}

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -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: () {},
),
);
}
}

View File

@ -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();
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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);
}
});

View File

@ -54,6 +54,7 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
valueListenable: _layoutTypeChangeNotifier,
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
shrinkWrap: widget.shrinkWrap,
context: PluginContext(),
),
);
}

View File

@ -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),

View File

@ -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,
),
),

View File

@ -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 = {

View File

@ -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()),
];
}

View File

@ -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,

View File

@ -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));
}
},
);
}

View File

@ -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;
});
},
),
);
}
}

View File

@ -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,
);

View File

@ -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,
);
}

View File

@ -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),

View File

@ -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;
});
},
),
);
}
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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),
);
}
}

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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'),
);

View File

@ -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);

View File

@ -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}) {

View File

@ -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 {

View File

@ -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,

View File

@ -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

View File

@ -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();
}

View File

@ -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,
(_) => [],
);
}
}

View File

@ -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: []);
}

View File

@ -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,
);
}

View File

@ -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