diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png new file mode 100644 index 0000000000..fb72022287 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png new file mode 100644 index 0000000000..9ecf02d253 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png new file mode 100644 index 0000000000..97072b04f4 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png new file mode 100644 index 0000000000..00d26a0500 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png new file mode 100644 index 0000000000..3ecc9546c1 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png new file mode 100644 index 0000000000..0abd2700e8 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png differ diff --git a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart index 786b9e08ce..2d8f9ff766 100644 --- a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart @@ -1,9 +1,6 @@ import 'dart:async'; import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; @@ -17,6 +14,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embe import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -66,7 +65,7 @@ class EditorOperations { Future tapGettingStartedIcon() async { await tester.tapButton( find.descendant( - of: find.byType(DocumentHeaderNodeWidget), + of: find.byType(DocumentCoverWidget), matching: find.findTextInFlowyText('⭐️'), ), ); diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index da33f7ea91..671c9382d0 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -64,8 +64,6 @@ PODS: - Flutter - FlutterMacOS - ReachabilitySwift (5.0.0) - - rich_clipboard_ios (0.0.1): - - Flutter - SDWebImage (5.14.2): - SDWebImage/Core (= 5.14.2) - SDWebImage/Core (5.14.2) @@ -100,7 +98,6 @@ DEPENDENCIES: - keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - rich_clipboard_ios (from `.symlinks/plugins/rich_clipboard_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`) @@ -147,8 +144,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" - rich_clipboard_ios: - :path: ".symlinks/plugins/rich_clipboard_ios/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -164,10 +159,10 @@ SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 @@ -176,10 +171,9 @@ SPEC CHECKSUMS: integration_test: 13825b8a9334a850581300559b8839134b124670 irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 - package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - rich_clipboard_ios: 7588abe18f881a6d0e9ec0b12e51cae2761e8942 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 diff --git a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart new file mode 100644 index 0000000000..b08d62c9be --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_page_style_bloc.freezed.dart'; + +class DocumentPageStyleBloc + extends Bloc { + DocumentPageStyleBloc({ + required this.view, + }) : super(DocumentPageStyleState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + try { + final layoutObject = + await ViewBackendService.getView(view.id).fold( + (s) => jsonDecode(s.extra), + (f) => {}, + ); + final fontLayout = _getSelectedFontLayout(layoutObject); + final lineHeightLayout = _getSelectedLineHeightLayout( + layoutObject, + ); + final fontFamily = _getSelectedFontFamily(layoutObject); + final cover = _getSelectedCover(layoutObject); + final coverType = cover.$1; + final coverValue = cover.$2; + emit( + state.copyWith( + fontLayout: fontLayout, + fontFamily: fontFamily, + lineHeightLayout: lineHeightLayout, + coverImage: PageStyleCover( + type: coverType, + value: coverValue, + ), + iconPadding: calculateIconPadding( + fontLayout, + lineHeightLayout, + ), + ), + ); + } catch (e) { + Log.error('Failed to decode layout object: $e'); + } + }, + updateFont: (fontLayout) async { + emit( + state.copyWith( + fontLayout: fontLayout, + iconPadding: calculateIconPadding( + fontLayout, + state.lineHeightLayout, + ), + ), + ); + + unawaited(updateLayoutObject()); + }, + updateLineHeight: (lineHeightLayout) async { + emit( + state.copyWith( + lineHeightLayout: lineHeightLayout, + iconPadding: calculateIconPadding( + state.fontLayout, + lineHeightLayout, + ), + ), + ); + + unawaited(updateLayoutObject()); + }, + updateFontFamily: (fontFamily) async { + emit( + state.copyWith( + fontFamily: fontFamily, + ), + ); + + unawaited(updateLayoutObject()); + }, + updateCoverImage: (coverImage) async { + emit( + state.copyWith( + coverImage: coverImage, + ), + ); + + unawaited(updateLayoutObject()); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewBackendService viewBackendService = ViewBackendService(); + + Future updateLayoutObject() async { + final layoutObject = decodeLayoutObject(); + if (layoutObject != null) { + await ViewBackendService.updateView( + viewId: view.id, + extra: layoutObject, + ); + } + } + + String? decodeLayoutObject() { + Map oldValue = {}; + try { + final extra = view.extra; + oldValue = jsonDecode(extra); + } catch (e) { + Log.error('Failed to decode layout object: $e'); + } + final newValue = { + ViewExtKeys.fontLayoutKey: state.fontLayout.toString(), + ViewExtKeys.lineHeightLayoutKey: state.lineHeightLayout.toString(), + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: state.coverImage.type.toString(), + ViewExtKeys.coverValueKey: state.coverImage.value, + }, + ViewExtKeys.fontKey: state.fontFamily, + }; + final merged = mergeMaps(oldValue, newValue); + return jsonEncode(merged); + } + + // because the line height can not be calculated accurately, + // we need to adjust the icon padding manually. + double calculateIconPadding( + PageStyleFontLayout fontLayout, + PageStyleLineHeightLayout lineHeightLayout, + ) { + double padding = switch (fontLayout) { + PageStyleFontLayout.small => 1.0, + PageStyleFontLayout.normal => 2.0, + PageStyleFontLayout.large => 4.0, + }; + switch (lineHeightLayout) { + case PageStyleLineHeightLayout.small: + padding -= 1.0; + break; + case PageStyleLineHeightLayout.normal: + break; + case PageStyleLineHeightLayout.large: + padding += 3.0; + break; + } + return max(0, padding); + } + + PageStyleFontLayout _getSelectedFontLayout(Map layoutObject) { + final fontLayout = layoutObject[ViewExtKeys.fontLayoutKey] ?? + PageStyleFontLayout.normal.toString(); + return PageStyleFontLayout.values.firstWhere( + (e) => e.toString() == fontLayout, + ); + } + + PageStyleLineHeightLayout _getSelectedLineHeightLayout(Map layoutObject) { + final lineHeightLayout = layoutObject[ViewExtKeys.lineHeightLayoutKey] ?? + PageStyleLineHeightLayout.normal.toString(); + return PageStyleLineHeightLayout.values.firstWhere( + (e) => e.toString() == lineHeightLayout, + ); + } + + String _getSelectedFontFamily(Map layoutObject) { + return layoutObject[ViewExtKeys.fontKey] ?? builtInFontFamily(); + } + + (PageStyleCoverImageType, String colorValue) _getSelectedCover( + Map layoutObject, + ) { + final cover = layoutObject[ViewExtKeys.coverKey] ?? {}; + final coverType = cover[ViewExtKeys.coverTypeKey] ?? + PageStyleCoverImageType.none.toString(); + final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; + return ( + PageStyleCoverImageType.values.firstWhere( + (e) => e.toString() == coverType, + ), + coverValue, + ); + } +} + +@freezed +class DocumentPageStyleEvent with _$DocumentPageStyleEvent { + const factory DocumentPageStyleEvent.initial() = Initial; + const factory DocumentPageStyleEvent.updateFont( + PageStyleFontLayout fontLayout, + ) = UpdateFontSize; + const factory DocumentPageStyleEvent.updateLineHeight( + PageStyleLineHeightLayout lineHeightLayout, + ) = UpdateLineHeight; + const factory DocumentPageStyleEvent.updateFontFamily( + String? fontFamily, + ) = UpdateFontFamily; + const factory DocumentPageStyleEvent.updateCoverImage( + PageStyleCover coverImage, + ) = UpdateCoverImage; +} + +@freezed +class DocumentPageStyleState with _$DocumentPageStyleState { + const factory DocumentPageStyleState({ + @Default(PageStyleFontLayout.normal) PageStyleFontLayout fontLayout, + @Default(PageStyleLineHeightLayout.normal) + PageStyleLineHeightLayout lineHeightLayout, + // the default font family is null, which means the system font + @Default(null) String? fontFamily, + @Default(2.0) double iconPadding, + required PageStyleCover coverImage, + }) = _DocumentPageStyleState; + + factory DocumentPageStyleState.initial() => DocumentPageStyleState( + coverImage: PageStyleCover.none(), + ); +} + +enum PageStyleFontLayout { + small, + normal, + large; + + @override + String toString() { + switch (this) { + case PageStyleFontLayout.small: + return 'small'; + case PageStyleFontLayout.normal: + return 'normal'; + case PageStyleFontLayout.large: + return 'large'; + } + } + + static PageStyleFontLayout fromString(String value) { + return PageStyleFontLayout.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleFontLayout.normal; + } + + double get fontSize { + switch (this) { + case PageStyleFontLayout.small: + return 14.0; + case PageStyleFontLayout.normal: + return 16.0; + case PageStyleFontLayout.large: + return 18.0; + } + } + + List get headingFontSizes { + switch (this) { + case PageStyleFontLayout.small: + return [22.0, 18.0, 16.0, 16.0, 16.0, 16.0]; + case PageStyleFontLayout.normal: + return [24.0, 20.0, 18.0, 18.0, 18.0, 18.0]; + case PageStyleFontLayout.large: + return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; + } + } + + double get factor { + switch (this) { + case PageStyleFontLayout.small: + return PageStyleFontLayout.small.fontSize / + PageStyleFontLayout.normal.fontSize; + case PageStyleFontLayout.normal: + return 1.0; + case PageStyleFontLayout.large: + return PageStyleFontLayout.large.fontSize / + PageStyleFontLayout.normal.fontSize; + } + } +} + +enum PageStyleLineHeightLayout { + small, + normal, + large; + + @override + String toString() { + switch (this) { + case PageStyleLineHeightLayout.small: + return 'small'; + case PageStyleLineHeightLayout.normal: + return 'normal'; + case PageStyleLineHeightLayout.large: + return 'large'; + } + } + + static PageStyleLineHeightLayout fromString(String value) { + return PageStyleLineHeightLayout.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleLineHeightLayout.normal; + } + + double get lineHeight { + switch (this) { + case PageStyleLineHeightLayout.small: + return 1.4; + case PageStyleLineHeightLayout.normal: + return 1.5; + case PageStyleLineHeightLayout.large: + return 1.75; + } + } + + double get padding { + switch (this) { + case PageStyleLineHeightLayout.small: + return 6.0; + case PageStyleLineHeightLayout.normal: + return 8.0; + case PageStyleLineHeightLayout.large: + return 8.0; + } + } + + List get headingPaddings { + switch (this) { + case PageStyleLineHeightLayout.small: + return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; + case PageStyleLineHeightLayout.normal: + return [30.0, 24.0, 22.0, 22.0, 22.0, 22.0]; + case PageStyleLineHeightLayout.large: + return [34.0, 28.0, 26.0, 26.0, 26.0, 26.0]; + } + } +} + +// for the version above 0.5.5 +enum PageStyleCoverImageType { + none, + // normal color + pureColor, + // gradient color + gradientColor, + // built in images + builtInImage, + // custom images, uploaded by the user + customImage, + // local image + localImage, + // unsplash images + unsplashImage; + + @override + String toString() { + switch (this) { + case PageStyleCoverImageType.none: + return 'none'; + case PageStyleCoverImageType.pureColor: + return 'color'; + case PageStyleCoverImageType.gradientColor: + return 'gradient'; + case PageStyleCoverImageType.builtInImage: + return 'built_in'; + case PageStyleCoverImageType.customImage: + return 'custom'; + case PageStyleCoverImageType.localImage: + return 'local'; + case PageStyleCoverImageType.unsplashImage: + return 'unsplash'; + } + } + + static PageStyleCoverImageType fromString(String value) { + return PageStyleCoverImageType.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleCoverImageType.none; + } + + static String builtInImagePath(String value) { + return 'assets/images/built_in_cover_images/m_cover_image_$value.png'; + } +} + +class PageStyleCover { + const PageStyleCover({ + required this.type, + required this.value, + }); + + factory PageStyleCover.none() => const PageStyleCover( + type: PageStyleCoverImageType.none, + value: '', + ); + + final PageStyleCoverImageType type; + + // there're 4 types of values: + // 1. pure color: enum value + // 2. gradient color: enum value + // 3. built-in image: the image name, read from the assets + // 4. custom image or unsplash image: the image url + final String value; + + bool get isPresets => isPureColor || isGradient || isBuiltInImage; + bool get isPhoto => isCustomImage || isLocalImage; + + bool get isNone => type == PageStyleCoverImageType.none; + bool get isPureColor => type == PageStyleCoverImageType.pureColor; + bool get isGradient => type == PageStyleCoverImageType.gradientColor; + bool get isBuiltInImage => type == PageStyleCoverImageType.builtInImage; + bool get isCustomImage => type == PageStyleCoverImageType.customImage; + bool get isUnsplashImage => type == PageStyleCoverImageType.unsplashImage; + bool get isLocalImage => type == PageStyleCoverImageType.localImage; +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart index 109c6e54b4..410bc68c4e 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart @@ -1,8 +1,10 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -21,10 +23,14 @@ class RecentViewBloc extends Bloc { initial: () async { _documentListener.start( onDocEventUpdate: (docEvent) async { - final (coverType, coverValue) = await getCover(); + if (state.coverTypeV2 != null) { + return; + } + final (coverType, coverValue) = await getCoverV1(); add( RecentViewEvent.updateCover( coverType, + null, coverValue, ), ); @@ -38,17 +44,40 @@ class RecentViewBloc extends Bloc { view.icon.value, ), ); + + if (view.extra.isNotEmpty) { + final cover = view.cover; + add( + RecentViewEvent.updateCover( + CoverType.none, + cover?.type, + cover?.value, + ), + ); + } }, ); - final (coverType, coverValue) = await getCover(); - emit( - state.copyWith( - name: view.name, - icon: view.icon.value, - coverType: coverType, - coverValue: coverValue, - ), - ); + final cover = getCoverV2(); + if (cover != null) { + emit( + state.copyWith( + name: view.name, + icon: view.icon.value, + coverTypeV2: cover.type, + coverValue: cover.value, + ), + ); + } else { + final (coverTypeV1, coverValue) = await getCoverV1(); + emit( + state.copyWith( + name: view.name, + icon: view.icon.value, + coverTypeV1: coverTypeV1, + coverValue: coverValue, + ), + ); + } }, updateNameOrIcon: (name, icon) { emit( @@ -58,10 +87,11 @@ class RecentViewBloc extends Bloc { ), ); }, - updateCover: (coverType, coverValue) { + updateCover: (coverTypeV1, coverTypeV2, coverValue) { emit( state.copyWith( - coverType: coverType, + coverTypeV1: coverTypeV1, + coverTypeV2: coverTypeV2, coverValue: coverValue, ), ); @@ -76,7 +106,12 @@ class RecentViewBloc extends Bloc { final DocumentListener _documentListener; final ViewListener _viewListener; - Future<(CoverType, String?)> getCover() async { + PageStyleCover? getCoverV2() { + return view.cover; + } + + // for the version under 0.5.5 + Future<(CoverType, String?)> getCoverV1() async { final result = await _service.getDocument(documentId: view.id); final document = result.fold((s) => s.toDocument(), (f) => null); if (document != null) { @@ -102,7 +137,8 @@ class RecentViewBloc extends Bloc { class RecentViewEvent with _$RecentViewEvent { const factory RecentViewEvent.initial() = Initial; const factory RecentViewEvent.updateCover( - CoverType coverType, + CoverType coverTypeV1, // for the version under 0.5.5, including 0.5.5 + PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5 String? coverValue, ) = UpdateCover; const factory RecentViewEvent.updateNameOrIcon( @@ -116,7 +152,8 @@ class RecentViewState with _$RecentViewState { const factory RecentViewState({ required String name, required String icon, - @Default(CoverType.none) CoverType coverType, + @Default(CoverType.none) CoverType coverTypeV1, + PageStyleCoverImageType? coverTypeV2, @Default(null) String? coverValue, }) = _RecentViewState; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart similarity index 93% rename from frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart index 9e1e610599..7b69ed818e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -40,6 +40,7 @@ class FlowyAppBar extends AppBar { super.centerTitle, VoidCallback? onTapLeading, bool showDivider = true, + super.backgroundColor, }) : super( title: title ?? FlowyText( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart similarity index 93% rename from frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart index 2341513c25..b59c1e68cc 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart @@ -17,7 +17,7 @@ class AppBarBackButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - onTap: onTap ?? () => Navigator.pop(context), + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), padding: padding, child: const FlowySvg( FlowySvgs.m_app_bar_back_s, @@ -37,7 +37,7 @@ class AppBarCloseButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - onTap: onTap ?? () => Navigator.pop(context), + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), child: const FlowySvg( FlowySvgs.m_app_bar_close_s, ), @@ -56,7 +56,7 @@ class AppBarCancelButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - onTap: onTap ?? () => Navigator.pop(context), + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), child: FlowyText( LocaleKeys.button_cancel.tr(), overflow: TextOverflow.ellipsis, @@ -76,7 +76,7 @@ class AppBarDoneButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - onTap: onTap, + onTap: (_) => onTap(), padding: const EdgeInsets.all(12), child: FlowyText( LocaleKeys.button_done.tr(), @@ -103,7 +103,7 @@ class AppBarSaveButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - onTap: () { + onTap: (_) { if (enable) { onTap(); } @@ -166,7 +166,7 @@ class AppBarMoreButton extends StatelessWidget { Widget build(BuildContext context) { return AppBarButton( padding: const EdgeInsets.all(12), - onTap: () => onTap(context), + onTap: onTap, child: const FlowySvg(FlowySvgs.three_dots_s), ); } @@ -180,7 +180,7 @@ class AppBarButton extends StatelessWidget { this.padding, }); - final VoidCallback onTap; + final void Function(BuildContext context) onTap; final Widget child; final EdgeInsetsGeometry? padding; @@ -188,7 +188,7 @@ class AppBarButton extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onTap, + onTap: () => onTap(context), child: Padding( padding: padding ?? const EdgeInsets.all(12), child: child, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 666e632851..58c8244547 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,11 +1,14 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart'; import 'package:appflowy/plugins/shared/sync_indicator.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; @@ -45,15 +48,26 @@ class MobileViewPage extends StatefulWidget { class _MobileViewPageState extends State { late final Future> future; + // used to determine if the user has scrolled down and show the app bar in immersive mode + ScrollNotificationObserverState? _scrollNotificationObserver; + final ValueNotifier _appBarOpacity = ValueNotifier(0.0); + @override void initState() { super.initState(); future = ViewBackendService.getView(widget.id); } + @override + void dispose() { + _appBarOpacity.dispose(); + _scrollNotificationObserver = null; + super.dispose(); + } + @override Widget build(BuildContext context) { - return FutureBuilder( + final child = FutureBuilder( future: future, builder: (context, state) { Widget body; @@ -73,6 +87,7 @@ class _MobileViewPageState extends State { } else { body = state.data!.fold((view) { viewPB = view; + actions.addAll([ if (FeatureFlag.syncDocument.isOn) ...[ DocumentCollaborators( @@ -88,6 +103,7 @@ class _MobileViewPageState extends State { : DatabaseSyncIndicator(view: view), const HSpace(8.0), ], + _buildAppBarLayoutButton(view), _buildAppBarMoreButton(view), ]); final plugin = view.plugin(arguments: widget.arguments ?? const {}) @@ -118,6 +134,13 @@ class _MobileViewPageState extends State { value: getIt() ..add(const ReminderEvent.started()), ), + if (viewPB!.layout == ViewLayoutPB.Document) + BlocProvider( + create: (_) => DocumentPageStyleBloc(view: viewPB!) + ..add( + const DocumentPageStyleEvent.initial(), + ), + ), ], child: Builder( builder: (context) { @@ -131,37 +154,109 @@ class _MobileViewPageState extends State { } }, ); + + return child; } Widget _buildApp(ViewPB? view, List actions, Widget child) { + // only enable immersive mode for document layout + final isImmersive = view?.layout == ViewLayoutPB.Document; final icon = view?.icon.value; + final title = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null && icon.isNotEmpty) + EmojiText( + emoji: '$icon ', + fontSize: 22.0, + ), + Expanded( + child: FlowyText.medium( + view?.name ?? widget.title ?? '', + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + + if (isImmersive) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: Size( + double.infinity, + AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, + ), + child: ValueListenableBuilder( + valueListenable: _appBarOpacity, + builder: (_, opacity, __) => FlowyAppBar( + backgroundColor: + AppBarTheme.of(context).backgroundColor?.withOpacity(opacity), + showDivider: false, + title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title), + actions: actions, + ), + ), + ), + body: Builder( + builder: (context) { + _rebuildScrollNotificationObserver(context); + return child; + }, + ), + ); + } + return Scaffold( appBar: FlowyAppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null && icon.isNotEmpty) - EmojiText( - emoji: '$icon ', - fontSize: 22.0, - ), - Expanded( - child: FlowyText.medium( - view?.name ?? widget.title ?? '', - fontSize: 15.0, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + title: title, actions: actions, ), - body: SafeArea(child: child), + body: child, + ); + } + + void _rebuildScrollNotificationObserver(BuildContext context) { + _scrollNotificationObserver?.removeListener(_onScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_onScrollNotification); + } + + Widget _buildAppBarLayoutButton(ViewPB view) { + // only display the layout button if the view is a document + if (view.layout != ViewLayoutPB.Document) { + return const SizedBox.shrink(); + } + + return AppBarButton( + padding: const EdgeInsets.symmetric(horizontal: 8), + onTap: (context) { + EditorNotification.exitEditing().post(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + title: LocaleKeys.pageStyle_title.tr(), + backgroundColor: Theme.of(context).colorScheme.background, + builder: (_) => BlocProvider.value( + value: context.read(), + child: PageStyleBottomSheet( + view: context.read().state.view, + ), + ), + ); + }, + child: const FlowySvg(FlowySvgs.m_layout_s), ); } Widget _buildAppBarMoreButton(ViewPB view) { - return AppBarMoreButton( + return AppBarButton( + padding: const EdgeInsets.only(left: 8, right: 16), onTap: (context) { EditorNotification.exitEditing().post(); @@ -170,13 +265,14 @@ class _MobileViewPageState extends State { showDragHandle: true, showDivider: false, backgroundColor: Theme.of(context).colorScheme.background, - builder: (_) => _buildViewPageBottomSheet(context), + builder: (_) => _buildAppBarMoreBottomSheet(context), ); }, + child: const FlowySvg(FlowySvgs.m_app_bar_more_s), ); } - Widget _buildViewPageBottomSheet(BuildContext context) { + Widget _buildAppBarMoreBottomSheet(BuildContext context) { final view = context.read().state.view; return ViewPageBottomSheet( view: view, @@ -228,4 +324,24 @@ class _MobileViewPageState extends State { }, ); } + + // immersive mode related + // auto show or hide the app bar based on the scroll position + void _onScrollNotification(ScrollNotification notification) { + if (_scrollNotificationObserver == null) { + return; + } + if (notification is ScrollUpdateNotification && + defaultScrollNotificationPredicate(notification)) { + final ScrollMetrics metrics = notification.metrics; + final height = MediaQuery.of(context).padding.top; + final progress = (metrics.pixels / height).clamp(0.0, 1.0); + // reduce the sensitivity of the app bar opacity change + if ((progress - _appBarOpacity.value).abs() >= 0.1 || + progress == 0 || + progress == 1.0) { + _appBarOpacity.value = progress; + } + } + } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart index c9d8a48e6b..4d411b7957 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart @@ -55,6 +55,31 @@ class BottomSheetDoneButton extends StatelessWidget { } } +class BottomSheetRemoveButton extends StatelessWidget { + const BottomSheetRemoveButton({ + super.key, + required this.onRemove, + }); + + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onRemove, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), + child: FlowyText( + LocaleKeys.button_remove.tr(), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ), + ); + } +} + class BottomSheetBackButton extends StatelessWidget { const BottomSheetBackButton({ super.key, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart index 8a51a08176..718ac5c4e6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -32,6 +32,8 @@ Future showMobileBottomSheet( // this field is only used if showHeader is true bool showBackButton = false, bool showCloseButton = false, + bool showRemoveButton = false, + VoidCallback? onRemove, // this field is only used if showHeader is true String title = '', bool isScrollControlled = true, @@ -46,6 +48,8 @@ Future showMobileBottomSheet( double? elevation, bool showDoneButton = false, bool enableDraggableScrollable = false, + // this field is only used if showDragHandle is true + Widget Function(BuildContext, ScrollController)? scrollableWidgetBuilder, // only used when enableDraggableScrollable is true double minChildSize = 0.5, double maxChildSize = 0.8, @@ -102,7 +106,9 @@ Future showMobileBottomSheet( showCloseButton: showCloseButton, showBackButton: showBackButton, showDoneButton: showDoneButton, + showRemoveButton: showRemoveButton, title: title, + onRemove: onRemove, ), ); @@ -116,24 +122,30 @@ Future showMobileBottomSheet( // ----- header area ----- if (enableDraggableScrollable) { + final keyboardSize = + context.bottomSheetPadding() / MediaQuery.of(context).size.height; return DraggableScrollableSheet( expand: false, snap: true, - initialChildSize: initialChildSize, - minChildSize: minChildSize, - maxChildSize: maxChildSize, + initialChildSize: (initialChildSize + keyboardSize).clamp(0, 1), + minChildSize: (minChildSize + keyboardSize).clamp(0, 1.0), + maxChildSize: (maxChildSize + keyboardSize).clamp(0, 1.0), builder: (context, scrollController) { return Column( children: [ ...children, - Expanded( - child: Scrollbar( - child: SingleChildScrollView( - controller: scrollController, - child: child, + scrollableWidgetBuilder?.call( + context, + scrollController, + ) ?? + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + controller: scrollController, + child: child, + ), + ), ), - ), - ), ], ); }, @@ -175,14 +187,18 @@ class BottomSheetHeader extends StatelessWidget { super.key, required this.showBackButton, required this.showCloseButton, + required this.showRemoveButton, required this.title, required this.showDoneButton, + this.onRemove, }); final bool showBackButton; final bool showCloseButton; + final bool showRemoveButton; final String title; final bool showDoneButton; + final VoidCallback? onRemove; @override Widget build(BuildContext context) { @@ -202,6 +218,13 @@ class BottomSheetHeader extends StatelessWidget { alignment: Alignment.centerLeft, child: BottomSheetCloseButton(), ), + if (showRemoveButton) + Align( + alignment: Alignment.centerLeft, + child: BottomSheetRemoveButton( + onRemove: () => onRemove?.call(), + ), + ), Align( child: FlowyText( title, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart index d9b9127468..0ff2a6634a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart @@ -66,6 +66,7 @@ Future showTransitionMobileBottomSheet( showCloseButton: showCloseButton, showBackButton: showBackButton, showDoneButton: showDoneButton, + showRemoveButton: false, title: title, ), if (showDivider) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart index 8249b22a5f..5805dc957f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart index 587db41474..5d5774156b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart index c919d3de8f..0f7c758664 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart index d29116d50d..d794817339 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart index 9584e4cb33..f488608d87 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart index 3be308fe90..53889be311 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_empty.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart index 7270588c60..909018d1b1 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart index 4fd639c621..9ef8ddefb1 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 39c11298a4..964f9e5aa5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -1,7 +1,7 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart index cc3258ad5d..650d9755b4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -1,14 +1,17 @@ import 'dart:io'; 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/plugins/base/emoji/emoji_text.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/workspace/application/view/view_ext.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: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'; @@ -54,7 +57,8 @@ class MobileRecentView extends StatelessWidget { topRight: Radius.circular(8), ), child: _RecentCover( - coverType: state.coverType, + coverTypeV1: state.coverTypeV1, + coverTypeV2: state.coverTypeV2, value: state.coverValue, ), ), @@ -108,11 +112,13 @@ class MobileRecentView extends StatelessWidget { class _RecentCover extends StatelessWidget { const _RecentCover({ - required this.coverType, + required this.coverTypeV1, + this.coverTypeV2, this.value, }); - final CoverType coverType; + final CoverType coverTypeV1; + final PageStyleCoverImageType? coverTypeV2; final String? value; @override @@ -125,7 +131,59 @@ class _RecentCover extends StatelessWidget { if (value == null) { return placeholder; } - switch (coverType) { + if (coverTypeV2 != null) { + return _buildCoverV2(context, value, placeholder); + } + return _buildCoverV1(context, value, placeholder); + } + + Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) { + final type = coverTypeV2; + if (type == null) { + return placeholder; + } + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return Image.asset( + PageStyleCoverImageType.builtInImagePath(value), + fit: BoxFit.cover, + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + return ColoredBox( + color: FlowyTint.fromId(value).color(context), + ); + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return Image.file( + File(value), + fit: BoxFit.cover, + ); + } + + return placeholder; + } + + Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) { + switch (coverTypeV1) { case CoverType.file: if (isURL(value)) { final userProfilePB = Provider.of(context); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart index 10a1b5097a..3ce8e57b36 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart index f79bc3dd78..0ee5e159be 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart @@ -1,17 +1,20 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -final List _availableFonts = GoogleFonts.asMap().keys.toList(); +final List _availableFonts = [ + builtInFontFamily(), + ...GoogleFonts.asMap().keys, +]; class FontPickerScreen extends StatelessWidget { const FontPickerScreen({super.key}); @@ -25,7 +28,9 @@ class FontPickerScreen extends StatelessWidget { } class LanguagePickerPage extends StatefulWidget { - const LanguagePickerPage({super.key}); + const LanguagePickerPage({ + super.key, + }); @override State createState() => _LanguagePickerPageState(); @@ -51,47 +56,95 @@ class _LanguagePickerPageState extends State { ), body: SafeArea( child: Scrollbar( - child: ListView.builder( - itemCount: availableFonts.length + 1, // with search bar - itemBuilder: (context, index) { - if (index == 0) { - // search bar - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 12.0, - ), - child: FlowyMobileSearchTextField( - onChanged: (keyword) { - setState(() { - availableFonts = _availableFonts - .where( - (font) => font - .parseFontFamilyName() - .toLowerCase() - .contains(keyword.toLowerCase()), - ) - .toList(); - }); - }, - ), - ); - } - - final fontFamilyName = availableFonts[index - 1]; - final displayName = fontFamilyName.parseFontFamilyName(); - return FlowyOptionTile.checkbox( - text: displayName, - isSelected: selectedFontFamilyName == fontFamilyName, - showTopBorder: false, - onTap: () => context.pop(fontFamilyName), - fontFamily: GoogleFonts.getFont(fontFamilyName).fontFamily, - backgroundColor: Colors.transparent, - ); - }, + child: FontSelector( + selectedFontFamilyName: selectedFontFamilyName, + onFontFamilySelected: (fontFamilyName) => + context.pop(fontFamilyName), ), ), ), ); } } + +class FontSelector extends StatefulWidget { + const FontSelector({ + super.key, + this.scrollController, + required this.selectedFontFamilyName, + required this.onFontFamilySelected, + }); + + final ScrollController? scrollController; + final String selectedFontFamilyName; + final void Function(String fontFamilyName) onFontFamilySelected; + + @override + State createState() => _FontSelectorState(); +} + +class _FontSelectorState extends State { + late List availableFonts; + + @override + void initState() { + super.initState(); + + availableFonts = _availableFonts; + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: widget.scrollController, + itemCount: availableFonts.length + 1, // with search bar + itemBuilder: (context, index) { + if (index == 0) { + // search bar + return _buildSearchBar(context); + } + + final fontFamilyName = availableFonts[index - 1]; + final fontFamily = fontFamilyName != builtInFontFamily() + ? GoogleFonts.getFont(fontFamilyName).fontFamily + : TextStyle(fontFamily: builtInFontFamily()).fontFamily; + return FlowyOptionTile.checkbox( + // display the default font name if the font family name is empty + text: fontFamilyName.isNotEmpty + ? fontFamilyName.parseFontFamilyName() + : LocaleKeys.settings_appearance_fontFamily_defaultFont.tr(), + isSelected: widget.selectedFontFamilyName == fontFamilyName, + showTopBorder: false, + onTap: () => widget.onFontFamilySelected(fontFamilyName), + fontFamily: fontFamily, + backgroundColor: Colors.transparent, + ); + }, + ); + } + + Widget _buildSearchBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 12.0, + ), + child: FlowyMobileSearchTextField( + onChanged: (keyword) { + setState(() { + availableFonts = _availableFonts + .where( + (font) => + font.isEmpty || // keep the default one always + font + .parseFontFamilyName() + .toLowerCase() + .contains(keyword.toLowerCase()), + ) + .toList(); + }); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart index 23c31cc916..83ff994033 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart @@ -1,16 +1,14 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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:google_fonts/google_fonts.dart'; import '../setting.dart'; @@ -30,7 +28,7 @@ class FontSetting extends StatelessWidget { children: [ FlowyText( selectedFont, - fontFamily: GoogleFonts.getFont(selectedFont).fontFamily, + // fontFamily: GoogleFonts.getFont(selectedFont).fontFamily, color: theme.colorScheme.onSurface, ), const Icon(Icons.chevron_right), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart index 67eb9dbf6f..9b7d395b61 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/language.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart index b7e1d59db7..390b814d5c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart @@ -1,6 +1,6 @@ import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/self_host_setting_group.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart index 39839b407c..9ec953d6b6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart @@ -26,7 +26,7 @@ class FlowyMobileSearchTextField extends StatelessWidget { onSubmitted: onSubmitted, placeholder: hintText, prefixIcon: const FlowySvg(FlowySvgs.m_search_m), - prefixInsets: const EdgeInsets.only(left: 16.0), + prefixInsets: const EdgeInsets.only(left: 16.0, right: 2.0), suffixIcon: const Icon(Icons.close), suffixInsets: const EdgeInsets.only(right: 16.0), placeholderStyle: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -34,6 +34,11 @@ class FlowyMobileSearchTextField extends StatelessWidget { fontWeight: FontWeight.w400, fontSize: 14.0, ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).textTheme.bodyMedium?.color, + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart index d4b9304f08..1e7ce9c64e 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/base/color/color_picker.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart index 8c9c3d2049..1297bccc37 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -9,7 +9,7 @@ import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:google_fonts/google_fonts.dart'; // use a global value to store the selected emoji to prevent reloading every time. -EmojiData? _cachedEmojiData; +EmojiData? kCachedEmojiData; class FlowyEmojiPicker extends StatefulWidget { const FlowyEmojiPicker({ @@ -34,12 +34,12 @@ class _FlowyEmojiPickerState extends State { super.initState(); // load the emoji data from cache if it's available - if (_cachedEmojiData != null) { - emojiData = _cachedEmojiData; + if (kCachedEmojiData != null) { + emojiData = kCachedEmojiData; } else { EmojiData.builtIn().then( (value) { - _cachedEmojiData = value; + kCachedEmojiData = value; setState(() { emojiData = value; }); diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart index b63442f4e8..15cc8c59e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart index 81ecfe28e6..3750a9294b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart index c2c575f5cf..3a39f0ed56 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class DocumentAppearance { @@ -58,9 +57,9 @@ class DocumentAppearance { class DocumentAppearanceCubit extends Cubit { DocumentAppearanceCubit() : super( - const DocumentAppearance( + DocumentAppearance( fontSize: 16.0, - fontFamily: builtInFontFamily, + fontFamily: builtInFontFamily(), codeFontFamily: builtInCodeFontFamily, ), ); @@ -70,7 +69,7 @@ class DocumentAppearanceCubit extends Cubit { final fontSize = prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0; final fontFamily = prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? - builtInFontFamily; + builtInFontFamily(); final defaultTextDirection = prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection); diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index b5ecab33e3..038405c957 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/application/document_data_pb_extension import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; @@ -18,6 +19,7 @@ import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/util/throttle.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; @@ -112,6 +114,11 @@ class DocumentBloc extends Bloc { final result = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); + result.onSuccess((s) { + if (s != null) { + _migrateCover(s); + } + }); final newState = await result.fold( (s) async { final userProfilePB = @@ -396,6 +403,14 @@ class DocumentBloc extends Bloc { metadata: jsonEncode(metadata.toJson()), ); } + + // from version 0.5.5, the cover is stored in the view.ext + Future _migrateCover(EditorState editorState) async { + final view = await ViewBackendService.getView(documentId); + view.onSuccess((s) { + return EditorMigration.migrateCoverIfNeeded(s, editorState); + }); + } } @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index d4de8314a2..f1e88de628 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,8 +1,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; @@ -104,16 +106,35 @@ class _DocumentPageState extends State } Widget _buildEditorPage(BuildContext context, DocumentState state) { - final appflowyEditorPage = AppFlowyEditorPage( - editorState: state.editorState!, - styleCustomizer: EditorStyleCustomizer( - context: context, - // the 44 is the width of the left action list - padding: EditorStyleCustomizer.documentPadding, - ), - header: _buildCoverAndIcon(context, state.editorState!), - initialSelection: widget.initialSelection, - ); + final Widget child; + + if (PlatformExtension.isMobile) { + child = BlocBuilder( + builder: (context, styleState) { + return AppFlowyEditorPage( + editorState: state.editorState!, + styleCustomizer: EditorStyleCustomizer( + context: context, + // the 44 is the width of the left action list + padding: EditorStyleCustomizer.documentPadding, + ), + header: _buildCoverAndIcon(context, state), + initialSelection: widget.initialSelection, + ); + }, + ); + } else { + child = AppFlowyEditorPage( + editorState: state.editorState!, + styleCustomizer: EditorStyleCustomizer( + context: context, + // the 44 is the width of the left action list + padding: EditorStyleCustomizer.documentPadding, + ), + header: _buildCoverAndIcon(context, state), + initialSelection: widget.initialSelection, + ); + } return Column( children: [ @@ -122,7 +143,7 @@ class _DocumentPageState extends State // const DocumentSyncIndicator(), if (state.isDeleted) _buildBanner(context), - Expanded(child: appflowyEditorPage), + Expanded(child: child), ], ); } @@ -138,9 +159,22 @@ class _DocumentPageState extends State ); } - Widget _buildCoverAndIcon(BuildContext context, EditorState editorState) { + Widget _buildCoverAndIcon(BuildContext context, DocumentState state) { + final editorState = state.editorState; + final userProfilePB = state.userProfilePB; + if (editorState == null || userProfilePB == null) { + return const SizedBox.shrink(); + } + + if (PlatformExtension.isMobile) { + return DocumentImmersiveCover( + view: widget.view, + userProfilePB: userProfilePB, + ); + } + final page = editorState.document.root; - return DocumentHeaderNodeWidget( + return DocumentCoverWidget( node: page, editorState: editorState, view: widget.view, @@ -163,7 +197,9 @@ class _DocumentPageState extends State } else if (type == EditorNotificationType.redo) { redoCommand.execute(editorState); } else if (type == EditorNotificationType.exitEditing) { - editorState.selection = null; + if (editorState.selection != null) { + editorState.selection = null; + } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index fb7b9eeed4..1c860e5906 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart'; @@ -11,6 +12,7 @@ import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; Map getEditorBuilderMap({ required BuildContext context, @@ -30,10 +32,20 @@ Map getEditorBuilderMap({ ]; final calloutBGColor = AFThemeExtension.of(context).calloutBGColor; - final configuration = BlockComponentConfiguration( // use EdgeInsets.zero to remove the default padding. - padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), + padding: (node) { + if (PlatformExtension.isMobile) { + final pageStyle = context.read().state; + final factor = pageStyle.fontLayout.factor; + final padding = pageStyle.lineHeightLayout.padding * factor; + return EdgeInsets.only( + top: padding, + ); + } + + return const EdgeInsets.symmetric(vertical: 5.0); + }, indentPadding: (node, textDirection) => textDirection == TextDirection.ltr ? const EdgeInsets.only(left: 26.0) : const EdgeInsets.only(right: 26.0), @@ -49,6 +61,12 @@ Map getEditorBuilderMap({ configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(), ), + iconBuilder: PlatformExtension.isMobile + ? (context, node, onCheck) => TodoListIcon( + node: node, + onCheck: onCheck, + ) + : null, toggleChildrenTriggers: [ LogicalKeyboardKey.shift, LogicalKeyboardKey.shiftLeft, @@ -59,11 +77,22 @@ Map getEditorBuilderMap({ configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(), ), + iconBuilder: PlatformExtension.isMobile + ? (context, node) => BulletedListIcon( + node: node, + ) + : null, ), NumberedListBlockKeys.type: NumberedListBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(), ), + iconBuilder: PlatformExtension.isMobile + ? (context, node, textDirection) => NumberedListIcon( + node: node, + textDirection: textDirection, + ) + : null, ), QuoteBlockKeys.type: QuoteBlockComponentBuilder( configuration: configuration.copyWith( @@ -72,7 +101,20 @@ Map getEditorBuilderMap({ ), HeadingBlockKeys.type: HeadingBlockComponentBuilder( configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0), + padding: (node) { + if (PlatformExtension.isMobile) { + final pageStyle = context.read().state; + final factor = pageStyle.fontLayout.factor; + final headingPaddings = pageStyle.lineHeightLayout.headingPaddings + .map((e) => e * factor); + final level = node.attributes[HeadingBlockKeys.level] ?? 6; + return EdgeInsets.only( + top: headingPaddings.elementAt(level), + ); + } + + return const EdgeInsets.only(top: 12.0, bottom: 4.0); + }, placeholderText: (node) => LocaleKeys.blockPlaceholders_heading.tr( args: [node.attributes[HeadingBlockKeys.level].toString()], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 839831f220..fd7af4fd18 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -143,16 +143,6 @@ class _AppFlowyEditorPageState extends State { late final List slashMenuItems; - late final Map blockComponentBuilders = - getEditorBuilderMap( - slashMenuItems: slashMenuItems, - context: context, - editorState: widget.editorState, - styleCustomizer: widget.styleCustomizer, - showParagraphPlaceholder: widget.showParagraphPlaceholder, - placeholderText: widget.placeholderText, - ); - List get characterShortcutEvents => [ // code block ...codeBlockCharacterEvents, @@ -307,7 +297,14 @@ class _AppFlowyEditorPageState extends State { // setup the theme editorStyle: styleCustomizer.style(), // customize the block builders - blockComponentBuilders: blockComponentBuilders, + blockComponentBuilders: getEditorBuilderMap( + slashMenuItems: slashMenuItems, + context: context, + editorState: widget.editorState, + styleCustomizer: widget.styleCustomizer, + showParagraphPlaceholder: widget.showParagraphPlaceholder, + placeholderText: widget.placeholderText, + ), // customize the shortcuts characterShortcutEvents: characterShortcutEvents, commandShortcutEvents: commandShortcutEvents, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart index 2e0e40dfb4..06d51094c3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart @@ -12,4 +12,9 @@ extension BuildContextExtension on BuildContext { box.hitTest(result, position: box.globalToLocal(offset)); return result.path.any((entry) => entry.target == box); } + + double get appBarHeight => + AppBarTheme.of(this).toolbarHeight ?? kToolbarHeight; + double get statusBarHeight => statusBarAndAppBarHeight - appBarHeight; + double get statusBarAndAppBarHeight => MediaQuery.of(this).padding.top; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart new file mode 100644 index 0000000000..d0551b03af --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BulletedListIcon extends StatelessWidget { + const BulletedListIcon({ + super.key, + required this.node, + }); + + final Node node; + + static final bulletedListIcons = [ + FlowySvgs.bulleted_list_icon_1_s, + FlowySvgs.bulleted_list_icon_2_s, + FlowySvgs.bulleted_list_icon_3_s, + ]; + + int get level { + var level = 0; + var parent = node.parent; + while (parent != null) { + if (parent.type == BulletedListBlockKeys.type) { + level++; + } + parent = parent.parent; + } + return level; + } + + FlowySvg get icon { + final index = level % bulletedListIcons.length; + return FlowySvg(bulletedListIcons[index]); + } + + @override + Widget build(BuildContext context) { + final iconPadding = context.read().state.iconPadding; + return Container( + constraints: const BoxConstraints( + minWidth: 22, + minHeight: 22, + ), + margin: EdgeInsets.only(top: iconPadding, right: 8.0), + child: icon, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart index d08ef4cb39..79e6968171 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart new file mode 100644 index 0000000000..217d8781ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart @@ -0,0 +1,222 @@ +import 'dart:io'; + +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/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/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.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: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'; +import 'package:google_fonts/google_fonts.dart'; + +double kDocumentCoverHeight = 98.0; +double kDocumentTitlePadding = 20.0; + +class DocumentImmersiveCover extends StatefulWidget { + const DocumentImmersiveCover({ + super.key, + required this.view, + required this.userProfilePB, + }); + + final ViewPB view; + final UserProfilePB userProfilePB; + + @override + State createState() => _DocumentImmersiveCoverState(); +} + +class _DocumentImmersiveCoverState extends State { + final textEditingController = TextEditingController(); + final scrollController = ScrollController(); + + @override + void dispose() { + textEditingController.dispose(); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnoreParentGestureWidget( + child: BlocProvider( + create: (context) => DocumentImmersiveCoverBloc(view: widget.view) + ..add(const DocumentImmersiveCoverEvent.initial()), + child: BlocConsumer( + listener: (context, state) { + textEditingController.text = state.name; + }, + builder: (_, state) { + final iconAndTitle = _buildIconAndTitle(context, state); + if (state.cover.type == PageStyleCoverImageType.none) { + return Padding( + padding: EdgeInsets.only( + top: context.statusBarAndAppBarHeight + kDocumentTitlePadding, + ), + child: iconAndTitle, + ); + } + + return Stack( + children: [ + _buildCover(context, state), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: iconAndTitle, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildIconAndTitle( + BuildContext context, + DocumentImmersiveCoverState state, + ) { + final icon = state.icon; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + children: [ + if (icon != null && icon.isNotEmpty) ...[ + _buildIcon(context, icon), + const HSpace(8.0), + ], + Expanded(child: _buildTitle(context)), + ], + ), + ); + } + + Widget _buildTitle(BuildContext context) { + String? fontFamily = builtInFontFamily(); + final documentFontFamily = + context.read().state.fontFamily; + if (documentFontFamily != null && fontFamily != documentFontFamily) { + fontFamily = GoogleFonts.getFont(documentFontFamily).fontFamily; + } + return TextField( + controller: textEditingController, + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: '', + contentPadding: EdgeInsets.zero, + ), + scrollController: scrollController, + style: TextStyle( + fontSize: 28.0, + fontWeight: FontWeight.w700, + fontFamily: fontFamily, + ), + onSubmitted: (value) { + scrollController.position.jumpTo(0); + context.read().add(ViewEvent.rename(value)); + }, + ); + } + + Widget _buildIcon(BuildContext context, String icon) { + return GestureDetector( + child: EmojiIconWidget( + emoji: icon, + emojiSize: 26, + ), + onTap: () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null && context.mounted) { + context.read().add(ViewEvent.updateIcon(result.emoji)); + } + }, + ); + } + + Widget _buildCover(BuildContext context, DocumentImmersiveCoverState state) { + final cover = state.cover; + final type = cover.type; + final naviBarHeight = MediaQuery.of(context).padding.top; + final height = naviBarHeight + kDocumentCoverHeight; + + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + return SizedBox( + height: height, + width: double.infinity, + child: FlowyNetworkImage( + url: cover.value, + userProfilePB: widget.userProfilePB, + ), + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.value), + fit: BoxFit.cover, + ), + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + return Container( + height: height, + width: double.infinity, + color: FlowyTint.fromId(cover.value).color(context), + ); + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.file( + File(cover.value), + fit: BoxFit.cover, + ), + ); + } + + return SizedBox( + height: naviBarHeight, + width: double.infinity, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart new file mode 100644 index 0000000000..bd2692e172 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_immersive_cover_bloc.freezed.dart'; + +class DocumentImmersiveCoverBloc + extends Bloc { + DocumentImmersiveCoverBloc({ + required this.view, + }) : _viewListener = ViewListener(viewId: view.id), + super(DocumentImmersiveCoverState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + add( + DocumentImmersiveCoverEvent.updateCoverAndIcon( + view.cover, + view.icon.value, + view.name, + ), + ); + _viewListener?.start( + onViewUpdated: (view) { + add( + DocumentImmersiveCoverEvent.updateCoverAndIcon( + view.cover, + view.icon.value, + view.name, + ), + ); + }, + ); + }, + updateCoverAndIcon: (cover, icon, name) { + emit( + state.copyWith( + icon: icon, + cover: cover ?? state.cover, + name: name ?? state.name, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewListener? _viewListener; + + @override + Future close() { + _viewListener?.stop(); + return super.close(); + } +} + +@freezed +class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent { + const factory DocumentImmersiveCoverEvent.initial() = Initial; + const factory DocumentImmersiveCoverEvent.updateCoverAndIcon( + PageStyleCover? cover, + String? icon, + String? name, + ) = UpdateCoverAndIcon; +} + +@freezed +class DocumentImmersiveCoverState with _$DocumentImmersiveCoverState { + const factory DocumentImmersiveCoverState({ + @Default(null) String? icon, + required PageStyleCover cover, + @Default('') String name, + }) = _DocumentImmersiveCoverState; + + factory DocumentImmersiveCoverState.initial() => DocumentImmersiveCoverState( + cover: PageStyleCover.none(), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart new file mode 100644 index 0000000000..10f644d7cc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.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_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +/// This is a transitional component that can be removed once the desktop +/// supports immersive widgets, allowing for the exclusive use of the DocumentImmersiveCover component. +class DesktopCover extends StatefulWidget { + const DesktopCover({ + super.key, + required this.view, + required this.editorState, + required this.node, + required this.coverType, + this.coverDetails, + }); + + final ViewPB view; + final Node node; + final EditorState editorState; + final CoverType coverType; + final String? coverDetails; + + @override + State createState() => _DesktopCoverState(); +} + +class _DesktopCoverState extends State { + CoverType get coverType => CoverType.fromString( + widget.node.attributes[DocumentHeaderBlockKeys.coverType], + ); + String? get coverDetails => + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; + + @override + Widget build(BuildContext context) { + if (widget.view.extra.isEmpty) { + return _buildCoverImageV1(); + } + + return _buildCoverImageV2(); + } + + // version > 0.5.5 + Widget _buildCoverImageV2() { + return BlocProvider( + create: (context) => DocumentImmersiveCoverBloc(view: widget.view) + ..add(const DocumentImmersiveCoverEvent.initial()), + child: + BlocBuilder( + builder: (context, state) { + final cover = state.cover; + final type = state.cover.type; + const height = kCoverHeight; + + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = + context.read().state.userProfilePB; + return SizedBox( + height: height, + width: double.infinity, + child: FlowyNetworkImage( + url: cover.value, + userProfilePB: userProfilePB, + ), + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.value), + fit: BoxFit.cover, + ), + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + return Container( + height: height, + width: double.infinity, + color: FlowyTint.fromId(cover.value).color(context), + ); + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.file( + File(cover.value), + fit: BoxFit.cover, + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ); + } + + // version <= 0.5.5 + Widget _buildCoverImageV1() { + final detail = coverDetails; + if (detail == null) { + return const SizedBox.shrink(); + } + switch (widget.coverType) { + case CoverType.file: + if (isURL(detail)) { + final userProfilePB = + context.read().state.userProfilePB; + return FlowyNetworkImage( + url: detail, + userProfilePB: userProfilePB, + errorWidgetBuilder: (context, url, error) => + const SizedBox.shrink(), + ); + } + final imageFile = File(detail); + if (!imageFile.existsSync()) { + return const SizedBox.shrink(); + } + return Image.file( + imageFile, + fit: BoxFit.cover, + ); + case CoverType.asset: + return Image.asset( + widget.coverDetails!, + fit: BoxFit.cover, + ); + case CoverType.color: + final color = widget.coverDetails?.tryToColor() ?? Colors.white; + return Container(color: color); + case CoverType.none: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index d6688d48cf..9db00ac10f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -1,17 +1,17 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; @@ -21,6 +21,7 @@ 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/rounded_button.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; @@ -31,6 +32,7 @@ const double kCoverHeight = 250.0; const double kIconHeight = 60.0; const double kToolbarHeight = 40.0; // with padding to the top +// Remove this widget if the desktop support immersive cover. class DocumentHeaderBlockKeys { const DocumentHeaderBlockKeys._(); @@ -39,6 +41,7 @@ class DocumentHeaderBlockKeys { static const String icon = 'selected_icon'; } +// for the version under 0.5.5, including 0.5.5 enum CoverType { none, color, @@ -56,8 +59,8 @@ enum CoverType { } } -class DocumentHeaderNodeWidget extends StatefulWidget { - const DocumentHeaderNodeWidget({ +class DocumentCoverWidget extends StatefulWidget { + const DocumentCoverWidget({ super.key, required this.node, required this.editorState, @@ -71,11 +74,10 @@ class DocumentHeaderNodeWidget extends StatefulWidget { final ViewPB view; @override - State createState() => - _DocumentHeaderNodeWidgetState(); + State createState() => _DocumentCoverWidgetState(); } -class _DocumentHeaderNodeWidgetState extends State { +class _DocumentCoverWidgetState extends State { CoverType get coverType => CoverType.fromString( widget.node.attributes[DocumentHeaderBlockKeys.coverType], ); @@ -130,6 +132,7 @@ class _DocumentHeaderNodeWidgetState extends State { ), if (hasCover) DocumentCover( + view: widget.view, editorState: widget.editorState, node: widget.node, coverType: coverType, @@ -189,8 +192,16 @@ class _DocumentHeaderNodeWidgetState extends State { widget.onIconChanged(icon); } + // compatible with version <= 0.5.5. transaction.updateNode(widget.node, attributes); await widget.editorState.apply(transaction); + + // compatible with version > 0.5.5. + EditorMigration.migrateCoverIfNeeded( + widget.view, + widget.editorState, + overwrite: true, + ); } } @@ -366,6 +377,7 @@ class _DocumentHeaderToolbarState extends State { class DocumentCover extends StatefulWidget { const DocumentCover({ super.key, + required this.view, required this.node, required this.editorState, required this.coverType, @@ -373,6 +385,7 @@ class DocumentCover extends StatefulWidget { required this.onChangeCover, }); + final ViewPB view; final Node node; final EditorState editorState; final CoverType coverType; @@ -407,7 +420,13 @@ class DocumentCoverState extends State { SizedBox( height: double.infinity, width: double.infinity, - child: _buildCoverImage(), + child: DesktopCover( + view: widget.view, + editorState: widget.editorState, + node: widget.node, + coverType: widget.coverType, + coverDetails: widget.coverDetails, + ), ), if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context), ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart index 9e198cef5d..45f5b78507 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart index a9ab87b6cb..a7ec64e5ce 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart @@ -1,28 +1,42 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:unsplash_client/unsplash_client.dart'; +const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; +const _accessKeyB = '3ezkG2XchRFjhNTnK9TE'; +const _secretKeyA = '5z4EnxaXjWjWMnuBhc0Ku0u'; +const _secretKeyB = 'YW2bsYCZlO-REZaqmV6A'; + +enum UnsplashImageType { + // the creator name is under the image + halfScreen, + // the creator name is on the image + fullScreen, +} + +typedef OnSelectUnsplashImage = void Function(String url); + class UnsplashImageWidget extends StatefulWidget { const UnsplashImageWidget({ super.key, + this.type = UnsplashImageType.halfScreen, required this.onSelectUnsplashImage, }); - final void Function(String url) onSelectUnsplashImage; + final UnsplashImageType type; + final OnSelectUnsplashImage onSelectUnsplashImage; @override State createState() => _UnsplashImageWidgetState(); } class _UnsplashImageWidgetState extends State { - final client = UnsplashClient( + final unsplash = UnsplashClient( settings: const ClientSettings( credentials: AppCredentials( - // TODO: there're the demo keys, we should replace them with the production keys when releasing and inject them with env file. - accessKey: 'YyD-LbW5bVolHWZBq5fWRM_3ezkG2XchRFjhNTnK9TE', - secretKey: '5z4EnxaXjWjWMnuBhc0Ku0uYW2bsYCZlO-REZaqmV6A', + accessKey: _accessKeyA + _accessKeyB, + secretKey: _secretKeyA + _secretKeyB, ), ), ); @@ -35,14 +49,14 @@ class _UnsplashImageWidgetState extends State { void initState() { super.initState(); - randomPhotos = client.photos + randomPhotos = unsplash.photos .random(count: 18, orientation: PhotoOrientation.landscape) .goAndGet(); } @override void dispose() { - client.close(); + unsplash.close(); super.dispose(); } @@ -52,25 +66,12 @@ class _UnsplashImageWidgetState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: FlowyTextField( - hintText: LocaleKeys.document_imageBlock_searchForAnImage.tr(), - onChanged: (value) => query = value, - onEditingComplete: _search, - ), - ), - const HSpace(4.0), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.search_label.tr(), - ), - onTap: _search, - ), - ], + SizedBox( + height: 44, + child: FlowyMobileSearchTextField( + onChanged: (keyword) => query = keyword, + onSubmitted: (_) => _search(), + ), ), const VSpace(12.0), Expanded( @@ -86,21 +87,10 @@ class _UnsplashImageWidgetState extends State { child: CircularProgressIndicator.adaptive(), ); } - return GridView.count( - crossAxisCount: 3, - mainAxisSpacing: 16.0, - crossAxisSpacing: 10.0, - childAspectRatio: 4 / 3, - children: data - .map( - (photo) => _UnsplashImage( - photo: photo, - onTap: () => widget.onSelectUnsplashImage( - photo.urls.regular.toString(), - ), - ), - ) - .toList(), + return _UnsplashImages( + type: widget.type, + photos: data, + onSelectUnsplashImage: widget.onSelectUnsplashImage, ); }, ), @@ -111,7 +101,7 @@ class _UnsplashImageWidgetState extends State { void _search() { setState(() { - randomPhotos = client.photos + randomPhotos = unsplash.photos .random( count: 18, orientation: PhotoOrientation.landscape, @@ -122,35 +112,113 @@ class _UnsplashImageWidgetState extends State { } } +class _UnsplashImages extends StatelessWidget { + const _UnsplashImages({ + required this.type, + required this.photos, + required this.onSelectUnsplashImage, + }); + + final UnsplashImageType type; + final List photos; + final OnSelectUnsplashImage onSelectUnsplashImage; + + @override + Widget build(BuildContext context) { + final crossAxisCount = switch (type) { + UnsplashImageType.halfScreen => 3, + UnsplashImageType.fullScreen => 2, + }; + final mainAxisSpacing = switch (type) { + UnsplashImageType.halfScreen => 16.0, + UnsplashImageType.fullScreen => 8.0, + }; + return GridView.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: 10.0, + childAspectRatio: 4 / 3, + children: photos + .map( + (photo) => _UnsplashImage( + type: type, + photo: photo, + onTap: () => onSelectUnsplashImage( + photo.urls.regular.toString(), + ), + ), + ) + .toList(), + ); + } +} + class _UnsplashImage extends StatelessWidget { const _UnsplashImage({ + required this.type, required this.photo, required this.onTap, }); + final UnsplashImageType type; final Photo photo; final VoidCallback onTap; @override Widget build(BuildContext context) { + final child = switch (type) { + UnsplashImageType.halfScreen => _buildHalfScreenImage(context), + UnsplashImageType.fullScreen => _buildFullScreenImage(context), + }; + return GestureDetector( onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Image.network( + child: child, + ); + } + + Widget _buildHalfScreenImage(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Image.network( + photo.urls.thumb.toString(), + fit: BoxFit.cover, + ), + ), + const HSpace(2.0), + FlowyText( + 'by ${photo.name}', + fontSize: 10.0, + ), + ], + ); + } + + Widget _buildFullScreenImage(BuildContext context) { + return Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) { + return Image.network( photo.urls.thumb.toString(), fit: BoxFit.cover, - ), - ), - const HSpace(2.0), - FlowyText( - 'by ${photo.name}', + width: constraints.maxWidth, + height: constraints.maxHeight, + ); + }, + ), + Positioned( + bottom: 6, + left: 6, + child: FlowyText.medium( + photo.name, fontSize: 10.0, + color: Colors.white, ), - ], - ), + ), + ], ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart index 015541e0a9..1ff5ca6f51 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart @@ -1,9 +1,15 @@ import 'dart:convert'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; +import 'package:string_validator/string_validator.dart'; class EditorMigration { // AppFlowy 0.1.x -> 0.2 @@ -153,4 +159,80 @@ class EditorMigration { } return attributes; } + + // Before version 0.5.5, the cover is stored in the document root. + // Now, the cover is stored in the view.ext. + static void migrateCoverIfNeeded( + ViewPB view, + EditorState editorState, { + bool overwrite = false, + }) async { + if (view.extra.isNotEmpty && !overwrite) { + return; + } + + final root = editorState.document.root; + final coverType = CoverType.fromString( + root.attributes[DocumentHeaderBlockKeys.coverType], + ); + final coverDetails = root.attributes[DocumentHeaderBlockKeys.coverDetails]; + if (coverType == CoverType.none || + coverDetails == null || + coverDetails is! String) { + return; + } + + Map extra = {}; + switch (coverType) { + case CoverType.asset: + // The new version does not support the asset cover. + break; + case CoverType.color: + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.pureColor.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + break; + case CoverType.file: + if (isURL(coverDetails)) { + if (coverDetails.contains('unsplash')) { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.unsplashImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + } else { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.customImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + } + } + break; + default: + } + + if (extra.isEmpty) { + return; + } + + try { + final current = view.extra.isNotEmpty ? jsonDecode(view.extra) : {}; + final merged = mergeMaps(current, extra); + await ViewBackendService.updateView( + viewId: view.id, + extra: jsonEncode(merged), + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart index 293c5aefd8..faa5795d0a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart new file mode 100644 index 0000000000..8e63873641 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart @@ -0,0 +1,111 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:numerus/roman/roman.dart'; + +class NumberedListIcon extends StatelessWidget { + const NumberedListIcon({ + super.key, + required this.node, + required this.textDirection, + }); + + final Node node; + final TextDirection textDirection; + + @override + Widget build(BuildContext context) { + final textStyle = + context.read().editorStyle.textStyleConfiguration.text; + return Container( + constraints: const BoxConstraints( + minWidth: 22, + minHeight: 22, + ), + margin: const EdgeInsets.only(right: 8.0), + alignment: Alignment.center, + child: Center( + child: Text( + node.levelString, + style: textStyle, + textDirection: textDirection, + ), + ), + ); + } +} + +extension on Node { + String get levelString { + final builder = _NumberedListIconBuilder(node: this); + final indexInRootLevel = builder.indexInRootLevel; + final indexInSameLevel = builder.indexInSameLevel; + final level = indexInRootLevel % 3; + final levelString = switch (level) { + 1 => indexInSameLevel.latin, + 2 => indexInSameLevel.roman, + _ => '$indexInSameLevel', + }; + return '$levelString.'; + } +} + +class _NumberedListIconBuilder { + _NumberedListIconBuilder({ + required this.node, + }); + + final Node node; + + // the level of the current node + int get indexInRootLevel { + var level = 0; + var parent = node.parent; + while (parent != null) { + if (parent.type == NumberedListBlockKeys.type) { + level++; + } + parent = parent.parent; + } + return level; + } + + // the index of the current level + int get indexInSameLevel { + int level = 1; + Node? previous = node.previous; + + // if the previous one is not a numbered list, then it is the first one + if (previous == null || previous.type != NumberedListBlockKeys.type) { + return node.attributes[NumberedListBlockKeys.number] ?? level; + } + + int? startNumber; + while (previous != null && previous.type == NumberedListBlockKeys.type) { + startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; + level++; + previous = previous.previous; + } + if (startNumber != null) { + return startNumber + level - 1; + } + return level; + } +} + +extension on int { + String get latin { + String result = ''; + int number = this; + while (number > 0) { + final int remainder = (number - 1) % 26; + result = String.fromCharCode(remainder + 65) + result; + number = (number - 1) ~/ 26; + } + return result.toLowerCase(); + } + + String get roman { + return toRomanNumeralString() ?? '$this'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart new file mode 100644 index 0000000000..9b26daa3e3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart @@ -0,0 +1,287 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PageCoverBottomSheet extends StatelessWidget { + const PageCoverBottomSheet({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(8.0), + + // pure colors + FlowyText( + LocaleKeys.pageStyle_colors.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildPureColors(context, state), + const VSpace(20.0), + + // gradient colors + FlowyText( + LocaleKeys.pageStyle_gradient.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildGradientColors(context, state), + const VSpace(20.0), + + // built-in images + FlowyText( + LocaleKeys.pageStyle_backgroundImage.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildBuiltImages(context, state), + ], + ), + ); + }, + ); + } + + Widget _buildPureColors( + BuildContext context, + DocumentPageStyleState state, + ) { + return SizedBox( + height: 42.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: FlowyTint.values.length, + separatorBuilder: (context, index) => const HSpace(12.0), + itemBuilder: (context, index) => _buildColorButton( + context, + state, + FlowyTint.values[index], + ), + ), + ); + } + + Widget _buildGradientColors( + BuildContext context, + DocumentPageStyleState state, + ) { + return SizedBox( + height: 42.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: FlowyGradientColor.values.length, + separatorBuilder: (context, index) => const HSpace(12.0), + itemBuilder: (context, index) => _buildGradientButton( + context, + state, + FlowyGradientColor.values[index], + ), + ), + ); + } + + Widget _buildColorButton( + BuildContext context, + DocumentPageStyleState state, + FlowyTint tint, + ) { + final isSelected = + state.coverImage.isPureColor && state.coverImage.value == tint.id; + + final child = !isSelected + ? Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: tint.color(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + ), + ), + ) + : Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.50, + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(21), + ), + ), + alignment: Alignment.center, + child: Container( + width: 34, + height: 34, + decoration: ShapeDecoration( + color: tint.color(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(17), + ), + ), + ), + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.pureColor, + value: tint.id, + ), + ), + ); + }, + child: child, + ); + } + + Widget _buildGradientButton( + BuildContext context, + DocumentPageStyleState state, + FlowyGradientColor gradientColor, + ) { + final isSelected = state.coverImage.isGradient && + state.coverImage.value == gradientColor.id; + + final child = !isSelected + ? Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + gradient: gradientColor.linear, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + ), + ), + ) + : Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.50, + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(21), + ), + ), + alignment: Alignment.center, + child: Container( + width: 34, + height: 34, + decoration: ShapeDecoration( + gradient: gradientColor.linear, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(17), + ), + ), + ), + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.gradientColor, + value: gradientColor.id, + ), + ), + ); + }, + child: child, + ); + } + + Widget _buildBuiltImages( + BuildContext context, + DocumentPageStyleState state, + ) { + final imageNames = ['1', '2', '3', '4', '5', '6']; + return GridView.builder( + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 16.0 / 9.0, + ), + itemCount: imageNames.length, + itemBuilder: (context, index) => _buildBuiltInImage( + context, + state, + imageNames[index], + ), + ); + } + + Widget _buildBuiltInImage( + BuildContext context, + DocumentPageStyleState state, + String imageName, + ) { + final asset = PageStyleCoverImageType.builtInImagePath(imageName); + final isSelected = + state.coverImage.isBuiltInImage && state.coverImage.value == imageName; + final image = ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + asset, + fit: BoxFit.cover, + ), + ); + final child = !isSelected + ? image + : Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)), + borderRadius: BorderRadius.circular(6), + ), + ), + padding: const EdgeInsets.all(2.0), + child: image, + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.builtInImage, + value: imageName, + ), + ), + ); + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart new file mode 100644 index 0000000000..3e5cb5416a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -0,0 +1,310 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; + +class PageStyleCoverImage extends StatelessWidget { + PageStyleCoverImage({ + super.key, + }); + + late final ImagePicker _imagePicker = ImagePicker(); + + @override + Widget build(BuildContext context) { + final backgroundColor = context.pageStyleBackgroundColor; + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + _buildOptionGroup( + context, + backgroundColor, + state, + ), + ], + ); + }, + ); + } + + Widget _buildOptionGroup( + BuildContext context, + Color backgroundColor, + DocumentPageStyleState state, + ) { + return Expanded( + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12), + right: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + _CoverOptionButton( + showLeftCorner: true, + showRightCorner: false, + selected: state.coverImage.isPresets, + onTap: () => _showPresets(context), + child: const _PresetCover(), + ), + _CoverOptionButton( + showLeftCorner: false, + showRightCorner: false, + selected: state.coverImage.isPhoto, + onTap: () => _pickImage(context), + child: const _PhotoCover(), + ), + _CoverOptionButton( + showLeftCorner: false, + showRightCorner: true, + selected: state.coverImage.isUnsplashImage, + onTap: () => _showUnsplash(context), + child: const _UnsplashCover(), + ), + ], + ), + ), + ); + } + + void _showPresets(BuildContext context) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + showRemoveButton: true, + onRemove: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); + }, + title: LocaleKeys.pageStyle_pageCover.tr(), + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.background, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const PageCoverBottomSheet(), + ); + }, + ); + } + + Future _pickImage(BuildContext context) async { + final result = await _imagePicker.pickImage( + source: ImageSource.gallery, + ); + final path = result?.path; + if (path != null && context.mounted) { + final String? result; + final userProfile = await UserBackendService.getCurrentUserProfile().fold( + (s) => s, + (f) => null, + ); + final isAppFlowyCloud = + userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud; + final PageStyleCoverImageType type; + if (!isAppFlowyCloud) { + result = await saveImageToLocalStorage(path); + type = PageStyleCoverImageType.localImage; + } else { + // else we should save the image to cloud storage + (result, _) = await saveImageToCloudStorage(path); + type = PageStyleCoverImageType.customImage; + } + if (!context.mounted) { + return; + } + if (result == null) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageUploadFailed, + ); + return; + } + + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: type, + value: result, + ), + ), + ); + } + } + + void _showUnsplash(BuildContext context) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + showRemoveButton: true, + title: LocaleKeys.pageStyle_coverImage.tr(), + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.background, + onRemove: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); + }, + builder: (_) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + minHeight: 80, + ), + child: BlocProvider.value( + value: context.read(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: UnsplashImageWidget( + type: UnsplashImageType.fullScreen, + onSelectUnsplashImage: (url) { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.unsplashImage, + value: url, + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} + +class _UnsplashCover extends StatelessWidget { + const _UnsplashCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_page_style_unsplash_m), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_unsplash.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _PhotoCover extends StatelessWidget { + const _PhotoCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_page_style_photo_m), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_photo.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _PresetCover extends StatelessWidget { + const _PresetCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.m_page_style_presets_m, + blendMode: null, + ), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_presets.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _CoverOptionButton extends StatelessWidget { + const _CoverOptionButton({ + required this.showLeftCorner, + required this.showRightCorner, + required this.child, + required this.onTap, + required this.selected, + }); + + final Widget child; + final bool showLeftCorner; + final bool showRightCorner; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: FeedbackGestureDetector( + feedbackType: HapticFeedbackType.medium, + onTap: onTap, + child: AnimatedContainer( + height: 64, + duration: Durations.medium1, + decoration: selected + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + color: Color(0xFF1AC3F2), + ), + borderRadius: BorderRadius.circular(12), + ), + ) + : null, + alignment: Alignment.center, + child: child, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart new file mode 100644 index 0000000000..5dc49fa8ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart @@ -0,0 +1,238 @@ +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/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'; +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_emoji_mart/flutter_emoji_mart.dart'; + +class PageStyleIcon extends StatelessWidget { + const PageStyleIcon({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PageStyleIconBloc(view: view) + ..add(const PageStyleIconEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final icon = state.icon ?? ''; + return GestureDetector( + onTap: () => _showIconSelector(context, icon), + behavior: HitTestBehavior.opaque, + child: Container( + height: 52, + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: BorderRadius.circular(12.0), + ), + child: Row( + children: [ + const HSpace(16.0), + FlowyText(LocaleKeys.document_plugins_emoji.tr()), + const Spacer(), + FlowyText( + icon.isNotEmpty ? icon : LocaleKeys.pageStyle_none.tr(), + color: icon.isEmpty ? context.pageStyleTextColor : null, + fontSize: icon.isNotEmpty ? 22.0 : 16.0, + ), + const HSpace(6.0), + const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), + const HSpace(12.0), + ], + ), + ), + ); + }, + ), + ); + } + + void _showIconSelector(BuildContext context, String selectedIcon) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + title: LocaleKeys.titleBar_pageIcon.tr(), + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.background, + isScrollControlled: true, + enableDraggableScrollable: true, + minChildSize: 0.6, + initialChildSize: 0.61, + showRemoveButton: true, + onRemove: () { + context.read().add( + const PageStyleIconEvent.updateIcon('', true), + ); + }, + scrollableWidgetBuilder: (_, controller) { + return BlocProvider.value( + value: context.read(), + child: Expanded( + child: Scrollbar( + controller: controller, + child: _IconSelector( + scrollController: controller, + ), + ), + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ); + } +} + +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 availableEmojis = []; + + @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); + }); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + if (emojiData == null) { + return const Center(child: CircularProgressIndicator()); + } + + return RepaintBoundary( + child: BlocBuilder( + builder: (_, state) => Column( + children: [ + _buildSearchBar(context), + Expanded( + child: GridView.count( + crossAxisCount: _getEmojiPerLine(context), + controller: widget.scrollController, + children: [ + for (final emoji in availableEmojis) + _buildEmoji(context, emoji, state.icon), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmoji( + BuildContext context, + String emoji, + String? selectedEmoji, + ) { + Widget child = Center( + child: FlowyText.emoji( + emoji, + fontSize: 24, + ), + ); + + if (emoji == selectedEmoji) { + child = Container( + margin: const EdgeInsets.all(8.0), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0xFF00BCF0), + ), + borderRadius: BorderRadius.circular(9), + ), + ), + child: child, + ); + } + + return GestureDetector( + onTap: () { + context.read().add( + PageStyleIconEvent.updateIcon(emoji, true), + ); + }, + child: child, + ); + } + + List _setupAvailableEmojis(EmojiData emojiData) { + final categories = emojiData.categories; + availableEmojis = categories + .map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e))) + .expand((e) => e) + .toList(); + return availableEmojis; + } + + int _getEmojiPerLine(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width ~/ 48.0; // the size of the emoji + } + + 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; + }); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart new file mode 100644 index 0000000000..4d8b0ebf81 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part '_page_style_icon_bloc.freezed.dart'; + +class PageStyleIconBloc extends Bloc { + PageStyleIconBloc({ + required this.view, + }) : _viewListener = ViewListener(viewId: view.id), + super(PageStyleIconState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + add( + PageStyleIconEvent.updateIcon( + view.icon.value, + false, + ), + ); + _viewListener?.start( + onViewUpdated: (view) { + add( + PageStyleIconEvent.updateIcon( + view.icon.value, + false, + ), + ); + }, + ); + }, + updateIcon: (icon, shouldUpdateRemote) async { + emit( + state.copyWith( + icon: icon, + ), + ); + if (shouldUpdateRemote && icon != null) { + await ViewBackendService.updateViewIcon( + viewId: view.id, + viewIcon: icon, + ); + } + }, + ); + }, + ); + } + + final ViewPB view; + final ViewListener? _viewListener; + + @override + Future close() { + _viewListener?.stop(); + return super.close(); + } +} + +@freezed +class PageStyleIconEvent with _$PageStyleIconEvent { + const factory PageStyleIconEvent.initial() = Initial; + const factory PageStyleIconEvent.updateIcon( + String? icon, + bool shouldUpdateRemote, + ) = UpdateIconInner; +} + +@freezed +class PageStyleIconState with _$PageStyleIconState { + const factory PageStyleIconState({ + @Default(null) String? icon, + }) = _PageStyleIconState; + + factory PageStyleIconState.initial() => const PageStyleIconState(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart new file mode 100644 index 0000000000..cd0d3df000 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart @@ -0,0 +1,235 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const kPageStyleLayoutHeight = 52.0; + +class PageStyleLayout extends StatelessWidget { + const PageStyleLayout({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Row( + children: [ + _OptionGroup( + options: const [ + PageStyleFontLayout.small, + PageStyleFontLayout.normal, + PageStyleFontLayout.large, + ], + selectedOption: state.fontLayout, + onTap: (option) => context + .read() + .add(DocumentPageStyleEvent.updateFont(option)), + ), + const HSpace(14), + _OptionGroup( + options: const [ + PageStyleLineHeightLayout.small, + PageStyleLineHeightLayout.normal, + PageStyleLineHeightLayout.large, + ], + selectedOption: state.lineHeightLayout, + onTap: (option) => context + .read() + .add(DocumentPageStyleEvent.updateLineHeight(option)), + ), + ], + ), + const VSpace(12.0), + const _FontButton(), + ], + ); + }, + ); + } +} + +class _OptionGroup extends StatelessWidget { + const _OptionGroup({ + required this.options, + required this.selectedOption, + required this.onTap, + }); + + final List options; + final T selectedOption; + final void Function(T option) onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12), + right: Radius.circular(12), + ), + ), + child: Row( + children: options.map((option) { + final child = _buildSvg(option); + final showLeftCorner = option == options.first; + final showRightCorner = option == options.last; + return _buildOptionButton( + child, + showLeftCorner, + showRightCorner, + selectedOption == option, + () => onTap(option), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildOptionButton( + Widget child, + bool showLeftCorner, + bool showRightCorner, + bool selected, + VoidCallback onTap, + ) { + return Expanded( + child: FeedbackGestureDetector( + feedbackType: HapticFeedbackType.medium, + onTap: onTap, + child: AnimatedContainer( + height: kPageStyleLayoutHeight, + duration: Durations.medium1, + decoration: selected + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + color: Color(0xFF1AC3F2), + ), + borderRadius: BorderRadius.circular(12), + ), + ) + : null, + alignment: Alignment.center, + child: child, + ), + ), + ); + } + + Widget _buildSvg(dynamic option) { + if (option is PageStyleFontLayout) { + return switch (option) { + PageStyleFontLayout.small => + const FlowySvg(FlowySvgs.m_font_size_small_s), + PageStyleFontLayout.normal => + const FlowySvg(FlowySvgs.m_font_size_normal_s), + PageStyleFontLayout.large => + const FlowySvg(FlowySvgs.m_font_size_large_s), + }; + } else if (option is PageStyleLineHeightLayout) { + return switch (option) { + PageStyleLineHeightLayout.small => + const FlowySvg(FlowySvgs.m_layout_small_s), + PageStyleLineHeightLayout.normal => + const FlowySvg(FlowySvgs.m_layout_normal_s), + PageStyleLineHeightLayout.large => + const FlowySvg(FlowySvgs.m_layout_large_s), + }; + } + throw ArgumentError('Invalid option type'); + } +} + +class _FontButton extends StatelessWidget { + const _FontButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return GestureDetector( + onTap: () => _showFontSelector(context), + behavior: HitTestBehavior.opaque, + child: Container( + height: kPageStyleLayoutHeight, + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: BorderRadius.circular(12.0), + ), + child: Row( + children: [ + const HSpace(16.0), + FlowyText(LocaleKeys.titleBar_font.tr()), + const Spacer(), + FlowyText(state.fontFamily ?? builtInFontFamily()), + const HSpace(6.0), + const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), + const HSpace(12.0), + ], + ), + ), + ); + }, + ); + } + + void _showFontSelector(BuildContext context) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + title: LocaleKeys.titleBar_font.tr(), + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.background, + isScrollControlled: true, + enableDraggableScrollable: true, + minChildSize: 0.6, + initialChildSize: 0.61, + scrollableWidgetBuilder: (_, controller) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + return Expanded( + child: Scrollbar( + controller: controller, + child: FontSelector( + scrollController: controller, + selectedFontFamilyName: + state.fontFamily ?? builtInFontFamily(), + onFontFamilySelected: (fontFamilyName) { + context.read().add( + DocumentPageStyleEvent.updateFontFamily( + fontFamilyName, + ), + ); + }, + ), + ), + ); + }, + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart new file mode 100644 index 0000000000..101046fb93 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +extension PageStyleUtil on BuildContext { + Color get pageStyleBackgroundColor { + final themeMode = Theme.of(this).brightness; + return themeMode == Brightness.light + ? const Color(0xFFF5F5F8) + : const Color(0xFF303030); + } + + Color get pageStyleTextColor { + final themeMode = Theme.of(this).brightness; + return themeMode == Brightness.light + ? const Color(0x7F1F2225) + : Colors.white54; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart new file mode 100644 index 0000000000..18238ffc79 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.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'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class PageStyleBottomSheet extends StatelessWidget { + const PageStyleBottomSheet({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // cover image + FlowyText( + LocaleKeys.pageStyle_backgroundImage.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + PageStyleCoverImage(), + const VSpace(20.0), + // layout: font size, line height and font family. + FlowyText( + LocaleKeys.pageStyle_layout.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + const PageStyleLayout(), + const VSpace(20.0), + // icon + FlowyText( + LocaleKeys.pageStyle_pageIcon.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + PageStyleIcon( + view: view, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index bf092e08d6..8afef3ec0f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -2,6 +2,7 @@ export 'actions/block_action_list.dart'; export 'actions/option_action.dart'; export 'align_toolbar_item/align_toolbar_item.dart'; export 'base/toolbar_extension.dart'; +export 'bulleted_list/bulleted_list_icon.dart'; export 'callout/callout_block_component.dart'; export 'code_block/code_block_language_selector.dart'; export 'context_menu/custom_context_menu.dart'; @@ -40,6 +41,7 @@ export 'mobile_toolbar_v3/more_toolbar_item.dart'; export 'mobile_toolbar_v3/toolbar_item_builder.dart'; export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart'; export 'mobile_toolbar_v3/util.dart'; +export 'numbered_list/numbered_list_icon.dart'; export 'openai/widgets/auto_completion_node_widget.dart'; export 'openai/widgets/smart_edit_node_widget.dart'; export 'openai/widgets/smart_edit_toolbar_item.dart'; @@ -47,5 +49,6 @@ export 'outline/outline_block_component.dart'; export 'parsers/markdown_parsers.dart'; export 'table/table_menu.dart'; export 'table/table_option_action.dart'; +export 'todo_list/todo_list_icon.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcut_event.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart new file mode 100644 index 0000000000..deb45c2182 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TodoListIcon extends StatelessWidget { + const TodoListIcon({ + super.key, + required this.node, + required this.onCheck, + }); + + final Node node; + final VoidCallback onCheck; + + @override + Widget build(BuildContext context) { + final iconPadding = context.read().state.iconPadding; + final checked = node.attributes[TodoListBlockKeys.checked] ?? false; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.lightImpact(); + onCheck(); + }, + child: Container( + constraints: const BoxConstraints( + minWidth: 22, + minHeight: 22, + ), + margin: EdgeInsets.only(top: iconPadding, right: 8.0), + child: FlowySvg( + checked + ? FlowySvgs.m_todo_list_checked_s + : FlowySvgs.m_todo_list_unchecked_s, + blendMode: checked ? null : BlendMode.srcIn, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 83dde0036b..ef5031fa69 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,6 +1,7 @@ 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'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; @@ -37,7 +38,7 @@ class EditorStyleCustomizer { } static EdgeInsets get documentPadding => PlatformExtension.isMobile - ? const EdgeInsets.only(left: 20, right: 20) + ? const EdgeInsets.only(left: 24, right: 24) : const EdgeInsets.only(left: 40, right: 40 + 44); EditorStyle desktop() { @@ -91,39 +92,42 @@ class EditorStyleCustomizer { } EditorStyle mobile() { + final pageStyle = context.read().state; final theme = Theme.of(context); - final fontSize = context.read().state.fontSize; - final fontFamily = context.read().state.fontFamily; + final fontSize = pageStyle.fontLayout.fontSize; + final lineHeight = pageStyle.lineHeightLayout.lineHeight; + final fontFamily = pageStyle.fontFamily ?? builtInFontFamily(); final defaultTextDirection = context.read().state.defaultTextDirection; + final baseTextStyle = this.baseTextStyle(fontFamily); final codeFontSize = max(0.0, fontSize - 2); return EditorStyle.mobile( padding: padding, defaultTextDirection: defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( - text: baseTextStyle(fontFamily).copyWith( + text: baseTextStyle.copyWith( fontSize: fontSize, color: theme.colorScheme.onBackground, - height: 1.5, + height: lineHeight, ), - bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( + bold: baseTextStyle.copyWith( fontWeight: FontWeight.w600, ), - italic: baseTextStyle(fontFamily).copyWith( + italic: baseTextStyle.copyWith( fontStyle: FontStyle.italic, ), - underline: baseTextStyle(fontFamily).copyWith( + underline: baseTextStyle.copyWith( decoration: TextDecoration.underline, ), - strikethrough: baseTextStyle(fontFamily).copyWith( + strikethrough: baseTextStyle.copyWith( decoration: TextDecoration.lineThrough, ), - href: baseTextStyle(fontFamily).copyWith( + href: baseTextStyle.copyWith( color: theme.colorScheme.primary, decoration: TextDecoration.underline, ), code: GoogleFonts.robotoMono( - textStyle: baseTextStyle(fontFamily).copyWith( + textStyle: baseTextStyle.copyWith( fontSize: codeFontSize, fontWeight: FontWeight.normal, fontStyle: FontStyle.italic, @@ -131,6 +135,8 @@ class EditorStyleCustomizer { backgroundColor: Colors.grey.withOpacity(0.3), ), ), + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, ), textSpanDecorator: customizeAttributeDecorator, mobileDragHandleBallSize: const Size.square(12.0), @@ -141,18 +147,29 @@ class EditorStyleCustomizer { } TextStyle headingStyleBuilder(int level) { - final fontSize = context.read().state.fontSize; - final fontSizes = [ - fontSize + 16, - fontSize + 12, - fontSize + 8, - fontSize + 4, - fontSize + 2, - fontSize, - ]; - final fontFamily = context.read().state.fontFamily; - return baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( - fontWeight: FontWeight.w600, + final String? fontFamily; + final List fontSizes; + final double fontSize; + final FontWeight fontWeight = + level <= 2 ? FontWeight.w700 : FontWeight.w600; + if (PlatformExtension.isMobile) { + final state = context.read().state; + fontFamily = state.fontFamily; + fontSize = state.fontLayout.fontSize; + fontSizes = state.fontLayout.headingFontSizes; + } else { + fontFamily = context.read().state.fontFamily; + fontSize = context.read().state.fontSize; + fontSizes = [ + fontSize + 16, + fontSize + 12, + fontSize + 8, + fontSize + 4, + fontSize + 2, + fontSize, + ]; + } + return baseTextStyle(fontFamily, fontWeight: fontWeight).copyWith( fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, ); } @@ -173,7 +190,7 @@ class EditorStyleCustomizer { final theme = Theme.of(context); final fontSize = context.read().state.fontSize; return TextStyle( - fontFamily: builtInFontFamily, + fontFamily: builtInFontFamily(), fontSize: fontSize, height: 1.5, color: theme.colorScheme.onBackground.withOpacity(0.6), @@ -211,23 +228,30 @@ class EditorStyleCustomizer { } TextStyle baseTextStyle( - String fontFamily, { + String? fontFamily, { FontWeight? fontWeight, }) { + if (fontFamily == null) { + return TextStyle( + fontWeight: fontWeight, + ); + } try { return GoogleFonts.getFont( fontFamily, fontWeight: fontWeight, ); } on Exception { - if ([builtInFontFamily, builtInCodeFontFamily].contains(fontFamily)) { + if ([builtInFontFamily(), builtInCodeFontFamily].contains(fontFamily)) { return TextStyle( fontFamily: fontFamily, fontWeight: fontWeight, ); } - return GoogleFonts.getFont(builtInFontFamily); + return TextStyle( + fontWeight: fontWeight, + ); } } diff --git a/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart b/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart new file mode 100644 index 0000000000..0d482fb985 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +enum HapticFeedbackType { + light, + medium, + heavy, + selection, + vibrate; + + void call() { + switch (this) { + case HapticFeedbackType.light: + HapticFeedback.lightImpact(); + break; + case HapticFeedbackType.medium: + HapticFeedback.mediumImpact(); + break; + case HapticFeedbackType.heavy: + HapticFeedback.heavyImpact(); + break; + case HapticFeedbackType.selection: + HapticFeedback.selectionClick(); + break; + case HapticFeedbackType.vibrate: + HapticFeedback.vibrate(); + break; + } + } +} + +class FeedbackGestureDetector extends GestureDetector { + FeedbackGestureDetector({ + super.key, + HitTestBehavior behavior = HitTestBehavior.opaque, + HapticFeedbackType feedbackType = HapticFeedbackType.light, + required Widget child, + required VoidCallback onTap, + }) : super( + behavior: behavior, + onTap: () { + feedbackType.call(); + onTap(); + }, + child: child, + ); +} diff --git a/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart b/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart new file mode 100644 index 0000000000..836f3b37cb --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +enum FlowyGradientColor { + gradient1, + gradient2, + gradient3, + gradient4, + gradient5, + gradient6, + gradient7; + + static FlowyGradientColor fromId(String id) { + return FlowyGradientColor.values.firstWhere( + (element) => element.id == id, + orElse: () => FlowyGradientColor.gradient1, + ); + } + + String get id { + // DON'T change this name because it's saved in the database! + switch (this) { + case FlowyGradientColor.gradient1: + return 'appflowy_them_color_gradient1'; + case FlowyGradientColor.gradient2: + return 'appflowy_them_color_gradient2'; + case FlowyGradientColor.gradient3: + return 'appflowy_them_color_gradient3'; + case FlowyGradientColor.gradient4: + return 'appflowy_them_color_gradient4'; + case FlowyGradientColor.gradient5: + return 'appflowy_them_color_gradient5'; + case FlowyGradientColor.gradient6: + return 'appflowy_them_color_gradient6'; + case FlowyGradientColor.gradient7: + return 'appflowy_them_color_gradient7'; + } + } + + LinearGradient get linear { + switch (this) { + case FlowyGradientColor.gradient1: + return const LinearGradient( + begin: Alignment(-0.35, -0.94), + end: Alignment(0.35, 0.94), + colors: [Color(0xFF34BDAF), Color(0xFFB682D4)], + ); + case FlowyGradientColor.gradient2: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFF4CC2CC), Color(0xFFE17570)], + ); + case FlowyGradientColor.gradient3: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFFAF70E0), Color(0xFFED7196)], + ); + case FlowyGradientColor.gradient4: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFFA348D6), Color(0xFF44A7DE)], + ); + case FlowyGradientColor.gradient5: + return const LinearGradient( + begin: Alignment(0.38, -0.93), + end: Alignment(-0.38, 0.93), + colors: [Color(0xFF5749C9), Color(0xFFBB4997)], + ); + case FlowyGradientColor.gradient6: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFF036FFA), Color(0xFF00B8E5)], + ); + case FlowyGradientColor.gradient7: + return const LinearGradient( + begin: Alignment(0.62, -0.79), + end: Alignment(-0.62, 0.79), + colors: [Color(0xFFF0C6CF), Color(0xFFDECCE2), Color(0xFFCAD3F9)], + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart index e012f6e33d..0d74958606 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart index e7006b8a87..5d509d3ade 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart @@ -1,11 +1,28 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -const builtInFontFamily = 'Poppins'; +String builtInFontFamily() { + if (PlatformExtension.isDesktopOrWeb) { + return 'Poppins'; + } + + if (Platform.isIOS) { + return 'San Francisco'; + } + + if (Platform.isAndroid) { + return 'Roboto'; + } + + return 'Roboto'; +} + +// 'Poppins'; const builtInCodeFontFamily = 'RobotoMono'; abstract class BaseAppearance { @@ -35,13 +52,13 @@ abstract class BaseAppearance { fontSize: fontSize, color: fontColor, fontWeight: fontWeight, - fontFamilyFallback: const [builtInFontFamily], + fontFamilyFallback: [builtInFontFamily()], letterSpacing: letterSpacing, height: lineHeight, ); // we embed Poppins font in the app, so we can use it without GoogleFonts - if (fontFamily == builtInFontFamily) { + if (fontFamily == builtInFontFamily()) { return textStyle; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 2cbbf5476e..9eb424d4b0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -20,7 +20,6 @@ class MobileAppearance extends BaseAppearance { String fontFamily, String codeFontFamily, ) { - assert(fontFamily.isNotEmpty); assert(codeFontFamily.isNotEmpty); final fontStyle = getFontStyle( @@ -92,6 +91,7 @@ class MobileAppearance extends BaseAppearance { disabledColor: colorTheme.outline, scaffoldBackgroundColor: colorTheme.background, appBarTheme: AppBarTheme( + toolbarHeight: 44.0, foregroundColor: colorTheme.onBackground, backgroundColor: colorTheme.background, centerTitle: false, diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index d84def4c54..1cfb89c8f9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -8,6 +8,7 @@ import 'package:appflowy/workspace/application/recent/cached_recent_service.dart import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; @@ -215,6 +216,12 @@ class ViewBloc extends Bloc { value.isPublic, ); }, + updateIcon: (value) async { + await ViewBackendService.updateViewIcon( + viewId: view.id, + viewIcon: value.icon ?? '', + ); + }, ); }, ); @@ -376,8 +383,11 @@ class ViewEvent with _$ViewEvent { ) = ViewDidUpdate; const factory ViewEvent.viewUpdateChildView(ViewPB result) = ViewUpdateChildView; - const factory ViewEvent.updateViewVisibility(ViewPB view, bool isPublic) = - UpdateViewVisibility; + const factory ViewEvent.updateViewVisibility( + ViewPB view, + bool isPublic, + ) = UpdateViewVisibility; + const factory ViewEvent.updateIcon(String? icon) = UpdateIcon; } @freezed diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index ed762cdf67..49ba3cc4c2 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; @@ -15,6 +18,22 @@ class PluginArgumentKeys { static String rowId = "row_id"; } +class ViewExtKeys { + // used for customizing the font family. + static String fontKey = 'font'; + + // used for customizing the font layout. + static String fontLayoutKey = 'font_layout'; + + // used for customizing the line height layout. + static String lineHeightLayoutKey = 'line_height_layout'; + + // cover keys + static String coverKey = 'cover'; + static String coverTypeKey = 'type'; + static String coverValueKey = 'value'; +} + extension ViewExtension on ViewPB { Widget defaultIcon() => FlowySvg( switch (layout) { @@ -76,6 +95,51 @@ extension ViewExtension on ViewPB { }; FlowySvgData get iconData => layout.icon; + + PageStyleCover? get cover { + if (layout != ViewLayoutPB.Document) { + return null; + } + try { + final ext = jsonDecode(extra); + final cover = ext[ViewExtKeys.coverKey] ?? {}; + final coverType = cover[ViewExtKeys.coverTypeKey] ?? + PageStyleCoverImageType.none.toString(); + final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; + return PageStyleCover( + type: PageStyleCoverImageType.fromString(coverType), + value: coverValue, + ); + } catch (e) { + return null; + } + } + + PageStyleLineHeightLayout get lineHeightLayout { + if (layout != ViewLayoutPB.Document) { + return PageStyleLineHeightLayout.normal; + } + try { + final ext = jsonDecode(extra); + final lineHeight = ext[ViewExtKeys.lineHeightLayoutKey]; + return PageStyleLineHeightLayout.fromString(lineHeight); + } catch (e) { + return PageStyleLineHeightLayout.normal; + } + } + + PageStyleFontLayout get fontLayout { + if (layout != ViewLayoutPB.Document) { + return PageStyleFontLayout.normal; + } + try { + final ext = jsonDecode(extra); + final fontLayout = ext[ViewExtKeys.fontLayoutKey]; + return PageStyleFontLayout.fromString(fontLayout); + } catch (e) { + return PageStyleFontLayout.normal; + } + } } extension ViewLayoutExtension on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart index 754d7fdb6a..b85467d41f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -148,6 +148,7 @@ class ViewBackendService { required String viewId, String? name, bool? isFavorite, + String? extra, }) { final payload = UpdateViewPayloadPB.create()..viewId = viewId; @@ -159,6 +160,10 @@ class ViewBackendService { payload.isFavorite = isFavorite; } + if (extra != null) { + payload.extra = extra; + } + return FolderEventUpdateView(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart index c34591b2da..12714e04df 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart index 5fc9233a28..35d4d89531 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart index 69ae7582f9..5aadb7f27e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -182,6 +182,13 @@ enum FlowyTint { } } + static FlowyTint fromId(String id) { + return FlowyTint.values.firstWhere( + (element) => element.id == id, + orElse: () => FlowyTint.tint1, + ); + } + Color color(BuildContext context) { switch (this) { case FlowyTint.tint1: diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 7cc1b449ee..537cb5cc9f 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -1210,7 +1210,7 @@ packages: source: hosted version: "2.0.2" numerus: - dependency: transitive + dependency: "direct main" description: name: numerus sha256: "49cd96fe774dd1f574fc9117ed67e8a2b06a612f723e87ef3119456a7729d837" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 937d3d9369..c8afe133aa 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -131,6 +131,7 @@ dependencies: sheet: file: ^7.0.0 avatar_stack: ^1.2.0 + numerus: ^2.1.2 flutter_animate: ^4.5.0 dev_dependencies: @@ -236,6 +237,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/images/ + - assets/images/built_in_cover_images/ - assets/flowy_icons/ - assets/flowy_icons/16x/ - assets/flowy_icons/24x/ diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart index 179154b20b..d3faf00336 100644 --- a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart @@ -36,7 +36,7 @@ void main() { AppTheme.fallback, ), verify: (bloc) { - expect(bloc.state.font, builtInFontFamily); + expect(bloc.state.font, builtInFontFamily()); expect(bloc.state.monospaceFont, 'SF Mono'); expect(bloc.state.themeMode, ThemeMode.system); }, diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart index b8f6140c29..d2c7102b0d 100644 --- a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart @@ -1,8 +1,7 @@ -import 'package:flutter/widgets.dart'; - import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -28,7 +27,7 @@ void main() { test('Initial state', () { expect(cubit.state.fontSize, 16.0); - expect(cubit.state.fontFamily, builtInFontFamily); + expect(cubit.state.fontFamily, builtInFontFamily()); }); test('Fetch document appearance from SharedPreferences', () async { diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart index 0f189cbee5..4da21cc4a8 100644 --- a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart @@ -1,10 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; -import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:mocktail/mocktail.dart'; class MockDocumentAppearanceCubit extends Mock @@ -34,14 +31,14 @@ void main() { }); test( - 'baseTextStyle should return the default TextStyle when an exception occurs', + 'baseTextStyle should return the null TextStyle when an exception occurs', () { const garbage = 'Garbage'; final result = editorStyleCustomizer.baseTextStyle(garbage); expect(result, isA()); expect( result.fontFamily, - GoogleFonts.getFont(builtInFontFamily).fontFamily, + null, ); }); }); diff --git a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart index 12b2a8e151..26fac55ecf 100644 --- a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart +++ b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.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_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -57,9 +56,9 @@ void main() { value: documentAppearanceCubit, ), ], - child: const Scaffold( + child: Scaffold( body: ThemeFontFamilySetting( - currentFontFamily: builtInFontFamily, + currentFontFamily: builtInFontFamily(), ), ), ), @@ -72,7 +71,7 @@ void main() { await tester.pumpAndSettle(); // Verify the initial font family - expect(find.text(builtInFontFamily), findsAtLeastNWidgets(1)); + expect(find.text(builtInFontFamily()), findsAtLeastNWidgets(1)); when(() => appearanceSettingsCubit.setFontFamily(any())) .thenAnswer((_) async {}); verifyNever(() => appearanceSettingsCubit.setFontFamily(any())); diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index b74cd9e39e..16ca5be3f9 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -860,7 +860,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -884,7 +884,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -914,7 +914,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "bytes", @@ -948,7 +948,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "chrono", @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-stream", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index c7affc29d6..69aec2e226 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -97,10 +97,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e2f # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index 73b02280e6..36ae635072 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -65,10 +65,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e2f # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index 975fdea8ed..0177b5fba3 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -897,7 +897,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", @@ -916,7 +916,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "bytes", @@ -931,7 +931,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "chrono", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-stream", @@ -1047,7 +1047,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 1acb930f16..2c353ce1c5 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -96,10 +96,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e2f # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } diff --git a/frontend/resources/flowy_icons/16x/bulleted_list_icon_1.svg b/frontend/resources/flowy_icons/16x/bulleted_list_icon_1.svg new file mode 100644 index 0000000000..ce98736a7b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/bulleted_list_icon_1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/bulleted_list_icon_2.svg b/frontend/resources/flowy_icons/16x/bulleted_list_icon_2.svg new file mode 100644 index 0000000000..df8c868b53 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/bulleted_list_icon_2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/bulleted_list_icon_3.svg b/frontend/resources/flowy_icons/16x/bulleted_list_icon_3.svg new file mode 100644 index 0000000000..c1d880916c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/bulleted_list_icon_3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_app_bar_more.svg b/frontend/resources/flowy_icons/16x/m_app_bar_more.svg new file mode 100644 index 0000000000..1cbaa2a541 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_app_bar_more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_font_size_large.svg b/frontend/resources/flowy_icons/16x/m_font_size_large.svg new file mode 100644 index 0000000000..e504077295 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_font_size_large.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_font_size_normal.svg b/frontend/resources/flowy_icons/16x/m_font_size_normal.svg new file mode 100644 index 0000000000..e618592a53 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_font_size_normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_font_size_small.svg b/frontend/resources/flowy_icons/16x/m_font_size_small.svg new file mode 100644 index 0000000000..dfb9b31223 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_font_size_small.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_layout.svg b/frontend/resources/flowy_icons/16x/m_layout.svg new file mode 100644 index 0000000000..7d9ba0d9dd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_layout.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_layout_large.svg b/frontend/resources/flowy_icons/16x/m_layout_large.svg new file mode 100644 index 0000000000..fbe673de63 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_layout_large.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_layout_normal.svg b/frontend/resources/flowy_icons/16x/m_layout_normal.svg new file mode 100644 index 0000000000..0f19cfbf92 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_layout_normal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_layout_small.svg b/frontend/resources/flowy_icons/16x/m_layout_small.svg new file mode 100644 index 0000000000..3a39e41589 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_layout_small.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_page_style_arrow_right.svg b/frontend/resources/flowy_icons/16x/m_page_style_arrow_right.svg new file mode 100644 index 0000000000..dc64040fcd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_page_style_arrow_right.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_todo_list_checked.svg b/frontend/resources/flowy_icons/16x/m_todo_list_checked.svg new file mode 100644 index 0000000000..303fb53ff2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_todo_list_checked.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_todo_list_unchecked.svg b/frontend/resources/flowy_icons/16x/m_todo_list_unchecked.svg new file mode 100644 index 0000000000..806c1dc5d5 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_todo_list_unchecked.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_page_style_photo.svg b/frontend/resources/flowy_icons/24x/m_page_style_photo.svg new file mode 100644 index 0000000000..91a0bac74d --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_page_style_photo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_page_style_presets.svg b/frontend/resources/flowy_icons/24x/m_page_style_presets.svg new file mode 100644 index 0000000000..9cf69728c6 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_page_style_presets.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_page_style_unsplash.svg b/frontend/resources/flowy_icons/24x/m_page_style_unsplash.svg new file mode 100644 index 0000000000..853a04eef6 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_page_style_unsplash.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 327e7c82ed..ba5fa165ab 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -380,7 +380,8 @@ "resetSetting": "Reset", "fontFamily": { "label": "Font Family", - "search": "Search" + "search": "Search", + "defaultFont": "Default Font" }, "themeMode": { "label": "Theme Mode", @@ -1478,6 +1479,20 @@ "noNetworkConnected": "No network connected" } }, + "pageStyle": { + "title": "Page style", + "layout": "Layout", + "coverImage": "Cover image", + "pageIcon": "Page icon", + "colors": "Colors", + "gradient": "Gradient", + "backgroundImage": "Background image", + "presets": "Presets", + "photo": "Photo", + "unsplash": "Unsplash", + "pageCover": "Page cover", + "none": "None" + }, "commandPalette": { "placeholder": "Type to search for views...", "bestMatches": "Best matches", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index c2511160cb..cc1d91b2b8 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -785,7 +785,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-trait", @@ -839,7 +839,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", @@ -858,7 +858,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "bytes", @@ -873,7 +873,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "chrono", @@ -911,7 +911,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "async-stream", @@ -989,7 +989,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=85580a5c0e95b5dae4787336faa751da44365760#85580a5c0e95b5dae4787336faa751da44365760" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dda9ad4#dda9ad49b78ef4e2cae7b5cbe41806b982ca6a44" dependencies = [ "anyhow", "collab", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index f9387610d6..c9a7b6377e 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -125,10 +125,10 @@ client-api = { git = " https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e2 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "85580a5c0e95b5dae4787336faa751da44365760" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dda9ad4" } diff --git a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs b/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs index 4817dcbc70..20604b0018 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs @@ -141,6 +141,7 @@ impl ViewBuilder { .collect(), ), last_edited_by: Some(self.uid), + extra: None, }; ParentChildViews { parent_view: view, diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 50257229f6..004f793e11 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -57,6 +57,9 @@ pub struct ViewPB { #[pb(index = 8)] pub is_favorite: bool, + + #[pb(index = 9, one_of)] + pub extra: Option, } pub fn view_pb_without_child_views(view: View) -> ViewPB { @@ -69,6 +72,7 @@ pub fn view_pb_without_child_views(view: View) -> ViewPB { layout: view.layout.into(), icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, + extra: view.extra, } } @@ -82,6 +86,7 @@ pub fn view_pb_without_child_views_from_arc(view: Arc) -> ViewPB { layout: view.layout.clone().into(), icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, + extra: view.extra.clone(), } } @@ -99,6 +104,7 @@ pub fn view_pb_with_child_views(view: Arc, child_views: Vec>) -> layout: view.layout.clone().into(), icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, + extra: view.extra.clone(), } } @@ -353,6 +359,20 @@ pub struct UpdateViewPayloadPB { #[pb(index = 6, one_of)] pub is_favorite: Option, + + #[pb(index = 7, one_of)] + // this value used to store the extra data with JSON format + // for document: + // - cover: { type: "", value: "" } + // - type: "0" represents normal color, + // "1" represents gradient color, + // "2" represents built-in image, + // "3" represents custom image, + // "4" represents local image, + // "5" represents unsplash image + // - line_height_layout: "small" or "normal" or "large" + // - font_layout: "small", or "normal", or "large" + pub extra: Option, } #[derive(Clone, Debug)] @@ -363,6 +383,7 @@ pub struct UpdateViewParams { pub thumbnail: Option, pub layout: Option, pub is_favorite: Option, + pub extra: Option, } impl TryInto for UpdateViewPayloadPB { @@ -390,6 +411,7 @@ impl TryInto for UpdateViewPayloadPB { thumbnail, is_favorite, layout: self.layout.map(|ty| ty.into()), + extra: self.extra, }) } } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 5a3f10dce3..2433a181a5 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -714,6 +714,7 @@ impl FolderManager { .set_desc_if_not_none(params.desc) .set_layout_if_not_none(params.layout) .set_favorite_if_not_none(params.is_favorite) + .set_extra_if_not_none(params.extra) .done() }) .await @@ -1275,7 +1276,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder } /// The MutexFolder is a wrapper of the [Folder] that is used to share the folder between different -/// threads. +/// threads. #[derive(Clone, Default)] pub struct MutexFolder(Arc>>); impl Deref for MutexFolder { diff --git a/frontend/rust-lib/flowy-folder/src/view_operation.rs b/frontend/rust-lib/flowy-folder/src/view_operation.rs index 8a58d843d2..5a296b659d 100644 --- a/frontend/rust-lib/flowy-folder/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder/src/view_operation.rs @@ -130,5 +130,6 @@ pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout created_by: Some(uid), last_edited_time: 0, last_edited_by: Some(uid), + extra: None, } }