diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index e6b6b741fd..c32a7f93c7 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -88,7 +88,16 @@ jobs: model: 'iPhone 15' shutdown_after_job: false - # enable it again if the 12 mins timeout is fixed - # - name: Run integration tests - # working-directory: frontend/appflowy_flutter - # run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} + # - name: Run AppFlowy on simulator + # working-directory: frontend/appflowy_flutter + # run: | + # flutter run -d ${{ steps.simulator-action.outputs.udid }} & + # pid=$! + # sleep 500 + # kill $pid + # continue-on-error: true + + # enable it again if the 12 mins timeout is fixed + # - name: Run integration tests + # working-directory: frontend/appflowy_flutter + # run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} diff --git a/frontend/appflowy_flutter/integration_test/mobile/page_style/document_page_style_test.dart b/frontend/appflowy_flutter/integration_test/mobile/page_style/document_page_style_test.dart new file mode 100644 index 0000000000..c915ebadfd --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/page_style/document_page_style_test.dart @@ -0,0 +1,139 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; +import 'package:appflowy/mobile/presentation/home/home.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/cover/document_immersive_cover_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/dir.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document page style', () { + double getCurrentEditorFontSize() { + final editorPage = find + .byType(AppFlowyEditorPage) + .evaluate() + .single + .widget as AppFlowyEditorPage; + return editorPage.styleCustomizer + .style() + .textStyleConfiguration + .text + .fontSize!; + } + + double getCurrentEditorLineHeight() { + final editorPage = find + .byType(AppFlowyEditorPage) + .evaluate() + .single + .widget as AppFlowyEditorPage; + return editorPage.styleCustomizer + .style() + .textStyleConfiguration + .text + .height!; + } + + testWidgets('change font size in page style settings', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.normal.fontSize); + // change font size from normal to large + await tester.tapSvgButton(FlowySvgs.m_font_size_large_s); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.large.fontSize); + // change font size from large to small + await tester.tapSvgButton(FlowySvgs.m_font_size_small_s); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.small.fontSize); + }); + + testWidgets('change line height in page style settings', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + expect( + getCurrentEditorLineHeight(), + PageStyleLineHeightLayout.normal.lineHeight, + ); + // change line height from normal to large + await tester.tapSvgButton(FlowySvgs.m_layout_large_s); + expect( + getCurrentEditorLineHeight(), + PageStyleLineHeightLayout.large.lineHeight, + ); + // change line height from large to small + await tester.tapSvgButton(FlowySvgs.m_layout_small_s); + expect( + getCurrentEditorLineHeight(), + PageStyleLineHeightLayout.small.lineHeight, + ); + }); + + testWidgets('use built-in image as cover', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + // toggle the preset button + await tester.tapSvgButton(FlowySvgs.m_page_style_presets_m); + + // select the first preset + final firstBuiltInImage = find.byWidgetPredicate( + (widget) => + widget is Image && + widget.image is AssetImage && + (widget.image as AssetImage).assetName == + PageStyleCoverImageType.builtInImagePath('1'), + ); + await tester.tap(firstBuiltInImage); + + // click done button to exit the page style settings + await tester.tapButton(find.byType(BottomSheetDoneButton).first); + await tester.tapButton(find.byType(BottomSheetDoneButton).first); + + // check the cover + final builtInCover = find.descendant( + of: find.byType(DocumentImmersiveCover), + matching: firstBuiltInImage, + ); + expect(builtInCover, findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile_runner.dart b/frontend/appflowy_flutter/integration_test/mobile_runner.dart index ca1a7ae0d3..9ebc2dcd97 100644 --- a/frontend/appflowy_flutter/integration_test/mobile_runner.dart +++ b/frontend/appflowy_flutter/integration_test/mobile_runner.dart @@ -1,8 +1,10 @@ import 'package:integration_test/integration_test.dart'; +import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test; import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; Future runIntegrationOnMobile() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); anonymous_sign_in_test.main(); + create_new_page_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 0a0858beec..af3cb7d44e 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -1,13 +1,10 @@ import 'dart:io'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/shared/feature_flags.dart'; @@ -31,6 +28,10 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'emoji.dart'; @@ -537,6 +538,30 @@ extension CommonOperations on WidgetTester { await tapButtonWithName(LocaleKeys.button_ok.tr()); } + + // For mobile platform to launch the app in anonymous mode + Future launchInAnonymousMode() async { + assert( + [TargetPlatform.android, TargetPlatform.iOS] + .contains(defaultTargetPlatform), + 'This method is only supported on mobile platforms', + ); + + await initializeAppFlowy(); + + final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); + expect(anonymousSignInButton, findsOneWidget); + await tapButton(anonymousSignInButton); + + await pumpUntilFound(find.byType(MobileHomeScreen)); + } + + Future tapSvgButton(FlowySvgData svg) async { + final button = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg.path == svg.path, + ); + await tapButton(button); + } } extension SettingsFinder on CommonFinders { diff --git a/frontend/appflowy_flutter/integration_test/shared/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart index 5f831f3d28..aeb3f04cb8 100644 --- a/frontend/appflowy_flutter/integration_test/shared/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/expectation.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; @@ -12,8 +11,10 @@ import 'package:appflowy/workspace/presentation/notifications/widgets/notificati import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; @@ -183,25 +184,35 @@ extension Expectation on WidgetTester { String? parentName, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) { - if (parentName == null) { - return find.byWidgetPredicate( - (widget) => - widget is SingleInnerViewItem && - widget.view.name == name && - widget.view.layout == layout, - skipOffstage: false, + if (PlatformExtension.isDesktop) { + if (parentName == null) { + return find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, + ); + } + + return find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is InnerViewItem && + widget.view.name == parentName && + widget.view.layout == parentLayout, + skipOffstage: false, + ), + matching: findPageName(name, layout: layout), ); } - return find.descendant( - of: find.byWidgetPredicate( - (widget) => - widget is InnerViewItem && - widget.view.name == parentName && - widget.view.layout == parentLayout, - skipOffstage: false, - ), - matching: findPageName(name, layout: layout), + return find.byWidgetPredicate( + (widget) => + widget is SingleMobileInnerViewItem && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, ); } diff --git a/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart new file mode 100644 index 0000000000..c9a6b8a8f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart @@ -0,0 +1,99 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mobile_view_page_bloc.freezed.dart'; + +class MobileViewPageBloc + extends Bloc { + MobileViewPageBloc({ + required this.viewId, + }) : _viewListener = ViewListener(viewId: viewId), + super(MobileViewPageState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _registerListeners(); + + final result = await ViewBackendService.getView(viewId); + final isImmersiveMode = + _isImmersiveMode(result.fold((s) => s, (f) => null)); + emit( + state.copyWith( + isLoading: false, + result: result, + isImmersiveMode: isImmersiveMode, + ), + ); + }, + updateImmersionMode: (isImmersiveMode) { + emit( + state.copyWith( + isImmersiveMode: isImmersiveMode, + ), + ); + }, + ); + }, + ); + } + + final String viewId; + final ViewListener _viewListener; + + @override + Future close() { + _viewListener.stop(); + return super.close(); + } + + void _registerListeners() { + _viewListener.start( + onViewUpdated: (view) { + final isImmersiveMode = _isImmersiveMode(view); + add(MobileViewPageEvent.updateImmersionMode(isImmersiveMode)); + }, + ); + } + + // only the document page supports immersive mode (version 0.5.6) + bool _isImmersiveMode(ViewPB? view) { + if (view == null) { + return false; + } + + final cover = view.cover; + if (cover == null || cover.type == PageStyleCoverImageType.none) { + return false; + } else if (view.layout == ViewLayoutPB.Document) { + // only support immersive mode for document layout + return true; + } + + return false; + } +} + +@freezed +class MobileViewPageEvent with _$MobileViewPageEvent { + const factory MobileViewPageEvent.initial() = Initial; + const factory MobileViewPageEvent.updateImmersionMode(bool isImmersiveMode) = + UpdateImmersionMode; +} + +@freezed +class MobileViewPageState with _$MobileViewPageState { + const factory MobileViewPageState({ + @Default(true) bool isLoading, + @Default(null) FlowyResult? result, + @Default(false) bool isImmersiveMode, + }) = _MobileViewPageState; + + factory MobileViewPageState.initial() => const MobileViewPageState(); +} 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 35b3ece7c5..14073e6dc8 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,14 +1,10 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; -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/base/view_page/app_bar_buttons.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'; @@ -16,16 +12,12 @@ import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/application/view/view_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/view.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; class MobileViewPage extends StatefulWidget { const MobileViewPage({ @@ -47,94 +39,33 @@ 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; // control the app bar opacity when in immersive mode - final ValueNotifier _appBarOpacity = ValueNotifier(0.0); - - // only enable immersive mode for document layout - final ValueNotifier _isImmersiveMode = ValueNotifier(false); - ViewListener? viewListener; - - @override - void initState() { - super.initState(); - future = ViewBackendService.getView(widget.id); - } + final ValueNotifier _appBarOpacity = ValueNotifier(1.0); @override void dispose() { _appBarOpacity.dispose(); - _isImmersiveMode.dispose(); - viewListener?.stop(); _scrollNotificationObserver = null; super.dispose(); } @override Widget build(BuildContext context) { - final child = FutureBuilder( - future: future, - builder: (context, state) { - Widget body; - ViewPB? viewPB; - final actions = []; - if (state.connectionState != ConnectionState.done) { - body = const Center( - child: CircularProgressIndicator(), - ); - } else if (!state.hasData) { - body = FlowyMobileStateContainer.error( - emoji: '😔', - title: LocaleKeys.error_weAreSorry.tr(), - description: LocaleKeys.error_loadingViewError.tr(), - errorMsg: state.error.toString(), - ); - } else { - body = state.data!.fold((view) { - viewPB = view; - _updateImmersiveMode(view); - viewListener?.stop(); - viewListener = ViewListener(viewId: view.id) - ..start( - onViewUpdated: _updateImmersiveMode, - ); + return BlocProvider( + create: (_) => MobileViewPageBloc(viewId: widget.id) + ..add(const MobileViewPageEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final view = state.result?.fold((s) => s, (f) => null); + final body = _buildBody(context, state); - actions.addAll([ - if (FeatureFlag.syncDocument.isOn) ...[ - DocumentCollaborators( - width: 60, - height: 44, - fontSize: 14, - padding: const EdgeInsets.symmetric(vertical: 8), - view: view, - ), - const HSpace(16.0), - view.layout == ViewLayoutPB.Document - ? DocumentSyncIndicator(view: view) - : DatabaseSyncIndicator(view: view), - const HSpace(8.0), - ], - _buildAppBarLayoutButton(view), - _buildAppBarMoreButton(view), - ]); - final plugin = view.plugin(arguments: widget.arguments ?? const {}) - ..init(); - return plugin.widgetBuilder.buildWidget(shrinkWrap: false); - }, (error) { - return FlowyMobileStateContainer.error( - emoji: '😔', - title: LocaleKeys.error_weAreSorry.tr(), - description: LocaleKeys.error_loadingViewError.tr(), - errorMsg: error.toString(), - ); - }); - } + if (view == null) { + return _buildApp(context, null, body); + } - if (viewPB != null) { return MultiBlocProvider( providers: [ BlocProvider( @@ -143,47 +74,165 @@ class _MobileViewPageState extends State { ), BlocProvider( create: (_) => - ViewBloc(view: viewPB!)..add(const ViewEvent.initial()), + ViewBloc(view: view)..add(const ViewEvent.initial()), ), BlocProvider.value( value: getIt() ..add(const ReminderEvent.started()), ), - if (viewPB!.layout == ViewLayoutPB.Document) + if (view.layout.isDocumentView) BlocProvider( - create: (_) => DocumentPageStyleBloc(view: viewPB!) - ..add( - const DocumentPageStyleEvent.initial(), - ), + create: (_) => DocumentPageStyleBloc(view: view) + ..add(const DocumentPageStyleEvent.initial()), ), ], child: Builder( builder: (context) { final view = context.watch().state.view; - return _buildApp(view, actions, body); + return _buildApp(context, view, body); }, ), ); - } else { - return _buildApp(null, [], body); - } - }, + }, + ), ); - - return child; } - Widget _buildApp(ViewPB? view, List actions, Widget child) { - // only enable immersive mode for document layout - final isImmersive = view?.layout == ViewLayoutPB.Document; + Widget _buildApp( + BuildContext context, + ViewPB? view, + Widget child, + ) { + final isImmersiveMode = view?.layout.isDocumentView ?? false; + final title = _buildTitle(context, view); + final appBar = MobileViewPageImmersiveAppBar( + preferredSize: Size( + double.infinity, + AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, + ), + title: title, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + actions: _buildAppBarActions(context, view), + ); + final body = isImmersiveMode + ? Builder( + builder: (context) { + _rebuildScrollNotificationObserver(context); + return child; + }, + ) + : child; + return Scaffold( + extendBodyBehindAppBar: isImmersiveMode, + appBar: appBar, + body: body, + ); + } + + Widget _buildBody(BuildContext context, MobileViewPageState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final result = state.result; + if (result == null) { + return FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: '', + ); + } + + return result.fold( + (view) { + final plugin = view.plugin(arguments: widget.arguments ?? const {}) + ..init(); + return plugin.widgetBuilder.buildWidget(shrinkWrap: false); + }, + (error) { + return FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: error.toString(), + ); + }, + ); + } + + // Document: + // - [ collaborators, sync_indicator, layout_button, more_button] + // Database: + // - [ sync_indicator, more_button] + List _buildAppBarActions(BuildContext context, ViewPB? view) { + if (view == null) { + return []; + } + + final isImmersiveMode = + context.read().state.isImmersiveMode; + final actions = []; + + if (FeatureFlag.syncDocument.isOn) { + // only document supports displaying collaborators. + if (view.layout.isDocumentView) { + actions.addAll([ + DocumentCollaborators( + width: 60, + height: 44, + fontSize: 14, + padding: const EdgeInsets.symmetric(vertical: 8), + view: view, + ), + const HSpace(16.0), + DocumentSyncIndicator(view: view), + const HSpace(8.0), + ]); + } else { + actions.addAll([ + DatabaseSyncIndicator(view: view), + const HSpace(8.0), + ]); + } + } + + if (view.layout.isDocumentView) { + actions.addAll([ + MobileViewPageLayoutButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + ), + ]); + } + + actions.addAll([ + MobileViewPageMoreButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + ), + ]); + + return actions; + } + + Widget _buildTitle(BuildContext context, ViewPB? view) { final icon = view?.icon.value; - final title = Row( + return Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null && icon.isNotEmpty) - EmojiText( - emoji: '$icon ', - fontSize: 22.0, + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 34.0), + child: EmojiText( + emoji: '$icon ', + fontSize: 22.0, + ), ), Expanded( child: FlowyText.medium( @@ -194,55 +243,6 @@ class _MobileViewPageState extends State { ), ], ); - - 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), - leading: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 2.0, vertical: 4.0), - child: AppBarButton( - padding: EdgeInsets.zero, - onTap: (context) => context.pop(), - child: _buildImmersiveAppBarIcon( - FlowySvgs.m_app_bar_back_s, - 30.0, - iconPadding: 6.0, - ), - ), - ), - actions: actions, - ), - ), - ), - body: Builder( - builder: (context) { - _rebuildScrollNotificationObserver(context); - return child; - }, - ), - ); - } - - return Scaffold( - appBar: FlowyAppBar( - title: title, - actions: actions, - ), - body: child, - ); } void _rebuildScrollNotificationObserver(BuildContext context) { @@ -251,163 +251,6 @@ class _MobileViewPageState extends State { _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(vertical: 2.0), - 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: _buildImmersiveAppBarIcon(FlowySvgs.m_layout_s, 30.0), - ); - } - - Widget _buildAppBarMoreButton(ViewPB view) { - return AppBarButton( - padding: const EdgeInsets.only(left: 8, right: 16), - onTap: (context) { - EditorNotification.exitEditing().post(); - - showMobileBottomSheet( - context, - showDragHandle: true, - showDivider: false, - backgroundColor: Theme.of(context).colorScheme.background, - builder: (_) => _buildAppBarMoreBottomSheet(context), - ); - }, - child: _buildImmersiveAppBarIcon(FlowySvgs.m_app_bar_more_s, 30.0), - ); - } - - Widget _buildImmersiveAppBarIcon( - FlowySvgData icon, - double dimension, { - double iconPadding = 5.0, - }) { - assert( - dimension > 0.0 && dimension <= kToolbarHeight, - 'dimension must be greater than 0, and less than or equal to kToolbarHeight', - ); - return UnconstrainedBox( - child: SizedBox.square( - dimension: dimension, - child: ValueListenableBuilder( - valueListenable: _isImmersiveMode, - builder: (context, isImmersiveMode, child) { - return ValueListenableBuilder( - valueListenable: _appBarOpacity, - builder: (context, appBarOpacity, child) { - Color? color; - - // if there's no cover or the cover is not immersive, - // make sure the app bar is always visible - if (!isImmersiveMode) { - color = null; - } else if (appBarOpacity < 0.99) { - color = Colors.white; - } - - Widget child = Container( - margin: EdgeInsets.all(iconPadding), - child: FlowySvg( - icon, - color: color, - ), - ); - - if (isImmersiveMode && appBarOpacity <= 0.99) { - child = DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(dimension / 2.0), - color: Colors.black.withOpacity(0.2), - ), - child: child, - ); - } - - return child; - }, - ); - }, - ), - ), - ); - } - - Widget _buildAppBarMoreBottomSheet(BuildContext context) { - final view = context.read().state.view; - return ViewPageBottomSheet( - view: view, - onAction: (action) { - switch (action) { - case MobileViewBottomSheetBodyAction.duplicate: - context.pop(); - context.read().add(const ViewEvent.duplicate()); - // show toast - break; - case MobileViewBottomSheetBodyAction.share: - // unimplemented - context.pop(); - break; - case MobileViewBottomSheetBodyAction.delete: - // pop to home page - context - ..pop() - ..pop(); - context.read().add(const ViewEvent.delete()); - break; - case MobileViewBottomSheetBodyAction.addToFavorites: - case MobileViewBottomSheetBodyAction.removeFromFavorites: - context.pop(); - context.read().add(FavoriteEvent.toggle(view)); - break; - case MobileViewBottomSheetBodyAction.undo: - EditorNotification.undo().post(); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.redo: - EditorNotification.redo().post(); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.helpCenter: - // unimplemented - context.pop(); - break; - case MobileViewBottomSheetBodyAction.rename: - // no need to implement, rename is handled by the onRename callback. - throw UnimplementedError(); - } - }, - onRename: (name) { - if (name != view.name) { - context.read().add(ViewEvent.rename(name)); - } - context.pop(); - }, - ); - } - // immersive mode related // auto show or hide the app bar based on the scroll position void _onScrollNotification(ScrollNotification notification) { @@ -418,7 +261,10 @@ class _MobileViewPageState extends State { if (notification is ScrollUpdateNotification && defaultScrollNotificationPredicate(notification)) { final ScrollMetrics metrics = notification.metrics; - final height = MediaQuery.of(context).padding.top; + double height = MediaQuery.of(context).padding.top; + if (defaultTargetPlatform == TargetPlatform.android) { + height += AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight; + } 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 || @@ -428,16 +274,4 @@ class _MobileViewPageState extends State { } } } - - void _updateImmersiveMode(ViewPB view) { - final cover = view.cover; - if (cover == null || cover.type == PageStyleCoverImageType.none) { - _isImmersiveMode.value = false; - } else if (view.layout != ViewLayoutPB.Document) { - // only support immersive mode for document layout - _isImmersiveMode.value = false; - } else { - _isImmersiveMode.value = true; - } - } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart new file mode 100644 index 0000000000..72bb36ad3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart @@ -0,0 +1,234 @@ +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/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/more_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.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/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileViewPageImmersiveAppBar extends StatelessWidget + implements PreferredSizeWidget { + const MobileViewPageImmersiveAppBar({ + super.key, + required this.preferredSize, + required this.isImmersiveMode, + required this.appBarOpacity, + required this.title, + required this.actions, + }); + + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + final Widget title; + final List actions; + + @override + final Size preferredSize; + + @override + Widget build(BuildContext context) { + if (!isImmersiveMode) { + FlowyAppBar( + title: title, + actions: actions, + ); + } + + return 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), + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0, vertical: 4.0), + child: _buildAppBarBackButton(context), + ), + actions: actions, + ), + ); + } + + Widget _buildAppBarBackButton(BuildContext context) { + return AppBarButton( + padding: EdgeInsets.zero, + onTap: (context) => context.pop(), + child: _ImmersiveAppBarButton( + icon: FlowySvgs.m_app_bar_back_s, + dimension: 30.0, + iconPadding: 6.0, + isImmersiveMode: isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class MobileViewPageMoreButton extends StatelessWidget { + const MobileViewPageMoreButton({ + super.key, + required this.view, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final ViewPB view; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + return AppBarButton( + padding: const EdgeInsets.only(left: 8, right: 16), + onTap: (context) { + EditorNotification.exitEditing().post(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + backgroundColor: Theme.of(context).colorScheme.background, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + ], + child: MobileViewPageMoreBottomSheet(view: view), + ), + ); + }, + child: _ImmersiveAppBarButton( + icon: FlowySvgs.m_app_bar_more_s, + dimension: 30.0, + iconPadding: 5.0, + isImmersiveMode: isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class MobileViewPageLayoutButton extends StatelessWidget { + const MobileViewPageLayoutButton({ + super.key, + required this.view, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final ViewPB view; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + // 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(vertical: 2.0), + 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: _ImmersiveAppBarButton( + icon: FlowySvgs.m_layout_s, + dimension: 30.0, + iconPadding: 5.0, + isImmersiveMode: isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class _ImmersiveAppBarButton extends StatelessWidget { + const _ImmersiveAppBarButton({ + required this.icon, + required this.dimension, + required this.iconPadding, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final FlowySvgData icon; + final double dimension; + final double iconPadding; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + assert( + dimension > 0.0 && dimension <= kToolbarHeight, + 'dimension must be greater than 0, and less than or equal to kToolbarHeight', + ); + + // if the immersive mode is on, the icon should be white and add a black background + // also, the icon opacity will change based on the app bar opacity + return UnconstrainedBox( + child: SizedBox.square( + dimension: dimension, + child: ValueListenableBuilder( + valueListenable: appBarOpacity, + builder: (context, appBarOpacity, child) { + Color? color; + + // if there's no cover or the cover is not immersive, + // make sure the app bar is always visible + if (!isImmersiveMode) { + color = null; + } else if (appBarOpacity < 0.99) { + color = Colors.white; + } + + Widget child = Container( + margin: EdgeInsets.all(iconPadding), + child: FlowySvg(icon, color: color), + ); + + if (isImmersiveMode && appBarOpacity <= 0.99) { + child = DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(dimension / 2.0), + color: Colors.black.withOpacity(0.2), + ), + child: child, + ); + } + + return child; + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart new file mode 100644 index 0000000000..b94242eceb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileViewPageMoreBottomSheet extends StatelessWidget { + const MobileViewPageMoreBottomSheet({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return ViewPageBottomSheet( + view: view, + onAction: (action) { + switch (action) { + case MobileViewBottomSheetBodyAction.duplicate: + context.pop(); + context.read().add(const ViewEvent.duplicate()); + // show toast + break; + case MobileViewBottomSheetBodyAction.share: + // unimplemented + context.pop(); + break; + case MobileViewBottomSheetBodyAction.delete: + // pop to home page + context + ..pop() + ..pop(); + context.read().add(const ViewEvent.delete()); + break; + case MobileViewBottomSheetBodyAction.addToFavorites: + case MobileViewBottomSheetBodyAction.removeFromFavorites: + context.pop(); + context.read().add(FavoriteEvent.toggle(view)); + break; + case MobileViewBottomSheetBodyAction.undo: + EditorNotification.undo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.redo: + EditorNotification.redo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.helpCenter: + // unimplemented + context.pop(); + break; + case MobileViewBottomSheetBodyAction.rename: + // no need to implement, rename is handled by the onRename callback. + throw UnimplementedError(); + } + }, + onRename: (name) { + if (name != view.name) { + context.read().add(ViewEvent.rename(name)); + } + context.pop(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index af58ce48af..6755be9690 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; @@ -25,6 +23,7 @@ import 'package:appflowy_editor/appflowy_editor.dart' import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; @@ -110,40 +109,46 @@ class _MentionPageBlockState extends State { } final iconSize = widget.textStyle?.fontSize ?? 16.0; + final child = GestureDetector( + onTap: handleTap, + onDoubleTap: handleDoubleTap, + behavior: HitTestBehavior.translucent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + view.icon.value.isNotEmpty + ? EmojiText( + emoji: view.icon.value, + fontSize: 12, + textAlign: TextAlign.center, + lineHeight: 1.3, + ) + : FlowySvg( + view.layout.icon, + size: Size.square(iconSize + 2.0), + ), + const HSpace(2), + FlowyText( + view.name, + decoration: TextDecoration.underline, + fontSize: widget.textStyle?.fontSize, + fontWeight: widget.textStyle?.fontWeight, + ), + const HSpace(2), + ], + ), + ); + + if (PlatformExtension.isMobile) { + return child; + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: FlowyHover( cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: handleTap, - onDoubleTap: handleDoubleTap, - behavior: HitTestBehavior.translucent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const HSpace(4), - view.icon.value.isNotEmpty - ? EmojiText( - emoji: view.icon.value, - fontSize: 12, - textAlign: TextAlign.center, - lineHeight: 1.3, - ) - : FlowySvg( - view.layout.icon, - size: Size.square(iconSize + 2.0), - ), - const HSpace(2), - FlowyText( - view.name, - decoration: TextDecoration.underline, - fontSize: widget.textStyle?.fontSize, - fontWeight: widget.textStyle?.fontWeight, - ), - const HSpace(2), - ], - ), - ), + child: child, ), ); }, 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 cdf2dcfffd..4aba56c15a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -95,6 +95,8 @@ class EditorStyleCustomizer { final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; final defaultTextDirection = context.read().state.defaultTextDirection; + final textScaleFactor = + context.read().state.textScaleFactor; final baseTextStyle = this.baseTextStyle(fontFamily); final codeFontSize = max(0.0, fontSize - 2); return EditorStyle.mobile( @@ -131,8 +133,7 @@ class EditorStyleCustomizer { textSpanDecorator: customizeAttributeDecorator, mobileDragHandleBallSize: const Size.square(12.0), magnifierSize: const Size(144, 96), - textScaleFactor: - context.watch().state.textScaleFactor, + textScaleFactor: textScaleFactor, ); } @@ -213,7 +214,7 @@ class EditorStyleCustomizer { ); TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) { - if (fontFamily == null) { + if (fontFamily == null || fontFamily == defaultFontFamily) { return TextStyle(fontWeight: fontWeight); } try { 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 49ba3cc4c2..ca3bb62fbb 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -151,6 +151,15 @@ extension ViewLayoutExtension on ViewLayoutPB { _ => throw Exception('Unknown layout type'), }; + bool get isDocumentView => switch (this) { + ViewLayoutPB.Document => true, + ViewLayoutPB.Grid || + ViewLayoutPB.Board || + ViewLayoutPB.Calendar => + false, + _ => throw Exception('Unknown layout type'), + }; + bool get isDatabaseView => switch (this) { ViewLayoutPB.Grid || ViewLayoutPB.Board ||