refactor: mobile view page (#5288)

* refactor: mobile view page

* fix: provider not found

* chore: add page style integration tests

* fix: android title bar padding

* fix: unable to click mentioned page on Android

* fix: font family not available log
This commit is contained in:
Lucas.Xu 2024-05-09 13:32:35 +08:00 committed by GitHub
parent dbbdc13d96
commit 6edb184bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 818 additions and 383 deletions

View File

@ -88,6 +88,15 @@ jobs:
model: 'iPhone 15' model: 'iPhone 15'
shutdown_after_job: false shutdown_after_job: false
# - 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 # enable it again if the 12 mins timeout is fixed
# - name: Run integration tests # - name: Run integration tests
# working-directory: frontend/appflowy_flutter # working-directory: frontend/appflowy_flutter

View File

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

View File

@ -1,8 +1,10 @@
import 'package:integration_test/integration_test.dart'; 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; import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test;
Future<void> runIntegrationOnMobile() async { Future<void> runIntegrationOnMobile() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
anonymous_sign_in_test.main(); anonymous_sign_in_test.main();
create_new_page_test.main();
} }

View File

@ -1,13 +1,10 @@
import 'dart:io'; 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.dart';
import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.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:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.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 'package:flutter_test/flutter_test.dart';
import 'emoji.dart'; import 'emoji.dart';
@ -537,6 +538,30 @@ extension CommonOperations on WidgetTester {
await tapButtonWithName(LocaleKeys.button_ok.tr()); await tapButtonWithName(LocaleKeys.button_ok.tr());
} }
// For mobile platform to launch the app in anonymous mode
Future<void> 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<void> tapSvgButton(FlowySvgData svg) async {
final button = find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg.path == svg.path,
);
await tapButton(button);
}
} }
extension SettingsFinder on CommonFinders { extension SettingsFinder on CommonFinders {

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.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/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.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/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'util.dart'; import 'util.dart';
@ -183,6 +184,7 @@ extension Expectation on WidgetTester {
String? parentName, String? parentName,
ViewLayoutPB parentLayout = ViewLayoutPB.Document, ViewLayoutPB parentLayout = ViewLayoutPB.Document,
}) { }) {
if (PlatformExtension.isDesktop) {
if (parentName == null) { if (parentName == null) {
return find.byWidgetPredicate( return find.byWidgetPredicate(
(widget) => (widget) =>
@ -205,6 +207,15 @@ extension Expectation on WidgetTester {
); );
} }
return find.byWidgetPredicate(
(widget) =>
widget is SingleMobileInnerViewItem &&
widget.view.name == name &&
widget.view.layout == layout,
skipOffstage: false,
);
}
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) { void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
final pageName = findPageName( final pageName = findPageName(
name, name,

View File

@ -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<MobileViewPageEvent, MobileViewPageState> {
MobileViewPageBloc({
required this.viewId,
}) : _viewListener = ViewListener(viewId: viewId),
super(MobileViewPageState.initial()) {
on<MobileViewPageEvent>(
(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<void> 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<ViewPB, FlowyError>? result,
@Default(false) bool isImmersiveMode,
}) = _MobileViewPageState;
factory MobileViewPageState.initial() => const MobileViewPageState();
}

View File

@ -1,14 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/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/view_page/app_bar_buttons.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/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.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/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/plugins/shared/sync_indicator.dart';
import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.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/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/view_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_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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class MobileViewPage extends StatefulWidget { class MobileViewPage extends StatefulWidget {
const MobileViewPage({ const MobileViewPage({
@ -47,94 +39,33 @@ class MobileViewPage extends StatefulWidget {
} }
class _MobileViewPageState extends State<MobileViewPage> { class _MobileViewPageState extends State<MobileViewPage> {
late final Future<FlowyResult<ViewPB, FlowyError>> future;
// used to determine if the user has scrolled down and show the app bar in immersive mode // used to determine if the user has scrolled down and show the app bar in immersive mode
ScrollNotificationObserverState? _scrollNotificationObserver; ScrollNotificationObserverState? _scrollNotificationObserver;
// control the app bar opacity when in immersive mode // control the app bar opacity when in immersive mode
final ValueNotifier<double> _appBarOpacity = ValueNotifier(0.0); final ValueNotifier<double> _appBarOpacity = ValueNotifier(1.0);
// only enable immersive mode for document layout
final ValueNotifier<bool> _isImmersiveMode = ValueNotifier(false);
ViewListener? viewListener;
@override
void initState() {
super.initState();
future = ViewBackendService.getView(widget.id);
}
@override @override
void dispose() { void dispose() {
_appBarOpacity.dispose(); _appBarOpacity.dispose();
_isImmersiveMode.dispose();
viewListener?.stop();
_scrollNotificationObserver = null; _scrollNotificationObserver = null;
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final child = FutureBuilder( return BlocProvider(
future: future, create: (_) => MobileViewPageBloc(viewId: widget.id)
..add(const MobileViewPageEvent.initial()),
child: BlocBuilder<MobileViewPageBloc, MobileViewPageState>(
builder: (context, state) { builder: (context, state) {
Widget body; final view = state.result?.fold((s) => s, (f) => null);
ViewPB? viewPB; final body = _buildBody(context, state);
final actions = <Widget>[];
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,
);
actions.addAll([ if (view == null) {
if (FeatureFlag.syncDocument.isOn) ...[ return _buildApp(context, null, body);
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 (viewPB != null) {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider( BlocProvider(
@ -143,48 +74,166 @@ class _MobileViewPageState extends State<MobileViewPage> {
), ),
BlocProvider( BlocProvider(
create: (_) => create: (_) =>
ViewBloc(view: viewPB!)..add(const ViewEvent.initial()), ViewBloc(view: view)..add(const ViewEvent.initial()),
), ),
BlocProvider.value( BlocProvider.value(
value: getIt<ReminderBloc>() value: getIt<ReminderBloc>()
..add(const ReminderEvent.started()), ..add(const ReminderEvent.started()),
), ),
if (viewPB!.layout == ViewLayoutPB.Document) if (view.layout.isDocumentView)
BlocProvider( BlocProvider(
create: (_) => DocumentPageStyleBloc(view: viewPB!) create: (_) => DocumentPageStyleBloc(view: view)
..add( ..add(const DocumentPageStyleEvent.initial()),
const DocumentPageStyleEvent.initial(),
),
), ),
], ],
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final view = context.watch<ViewBloc>().state.view; final view = context.watch<ViewBloc>().state.view;
return _buildApp(view, actions, body); return _buildApp(context, view, body);
},
),
);
}, },
), ),
); );
} else {
return _buildApp(null, [], body);
} }
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(),
);
}, },
); );
return child;
} }
Widget _buildApp(ViewPB? view, List<Widget> actions, Widget child) { // Document:
// only enable immersive mode for document layout // - [ collaborators, sync_indicator, layout_button, more_button]
final isImmersive = view?.layout == ViewLayoutPB.Document; // Database:
// - [ sync_indicator, more_button]
List<Widget> _buildAppBarActions(BuildContext context, ViewPB? view) {
if (view == null) {
return [];
}
final isImmersiveMode =
context.read<MobileViewPageBloc>().state.isImmersiveMode;
final actions = <Widget>[];
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 icon = view?.icon.value;
final title = Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (icon != null && icon.isNotEmpty) if (icon != null && icon.isNotEmpty)
EmojiText( ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 34.0),
child: EmojiText(
emoji: '$icon ', emoji: '$icon ',
fontSize: 22.0, fontSize: 22.0,
), ),
),
Expanded( Expanded(
child: FlowyText.medium( child: FlowyText.medium(
view?.name ?? widget.title ?? '', view?.name ?? widget.title ?? '',
@ -194,55 +243,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
), ),
], ],
); );
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) { void _rebuildScrollNotificationObserver(BuildContext context) {
@ -251,163 +251,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
_scrollNotificationObserver?.addListener(_onScrollNotification); _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<DocumentPageStyleBloc>(),
child: PageStyleBottomSheet(
view: context.read<ViewBloc>().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<ViewBloc>().state.view;
return ViewPageBottomSheet(
view: view,
onAction: (action) {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
context.pop();
context.read<ViewBloc>().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<ViewBloc>().add(const ViewEvent.delete());
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
case MobileViewBottomSheetBodyAction.removeFromFavorites:
context.pop();
context.read<FavoriteBloc>().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<ViewBloc>().add(ViewEvent.rename(name));
}
context.pop();
},
);
}
// immersive mode related // immersive mode related
// auto show or hide the app bar based on the scroll position // auto show or hide the app bar based on the scroll position
void _onScrollNotification(ScrollNotification notification) { void _onScrollNotification(ScrollNotification notification) {
@ -418,7 +261,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
if (notification is ScrollUpdateNotification && if (notification is ScrollUpdateNotification &&
defaultScrollNotificationPredicate(notification)) { defaultScrollNotificationPredicate(notification)) {
final ScrollMetrics metrics = notification.metrics; 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); final progress = (metrics.pixels / height).clamp(0.0, 1.0);
// reduce the sensitivity of the app bar opacity change // reduce the sensitivity of the app bar opacity change
if ((progress - _appBarOpacity.value).abs() >= 0.1 || if ((progress - _appBarOpacity.value).abs() >= 0.1 ||
@ -428,16 +274,4 @@ class _MobileViewPageState extends State<MobileViewPage> {
} }
} }
} }
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;
}
}
} }

View File

@ -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<Widget> 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<ViewBloc>()),
BlocProvider.value(value: context.read<FavoriteBloc>()),
],
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<DocumentPageStyleBloc>(),
child: PageStyleBottomSheet(
view: context.read<ViewBloc>().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;
},
),
),
);
}
}

View File

@ -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<ViewBloc>().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<ViewBloc>().add(const ViewEvent.delete());
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
case MobileViewBottomSheetBodyAction.removeFromFavorites:
context.pop();
context.read<FavoriteBloc>().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<ViewBloc>().add(ViewEvent.rename(name));
}
context.pop();
},
);
}
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.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:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -110,11 +109,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
} }
final iconSize = widget.textStyle?.fontSize ?? 16.0; final iconSize = widget.textStyle?.fontSize ?? 16.0;
return Padding( final child = GestureDetector(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: FlowyHover(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: handleTap, onTap: handleTap,
onDoubleTap: handleDoubleTap, onDoubleTap: handleDoubleTap,
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@ -143,7 +138,17 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
const HSpace(2), const HSpace(2),
], ],
), ),
), );
if (PlatformExtension.isMobile) {
return child;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: FlowyHover(
cursor: SystemMouseCursors.click,
child: child,
), ),
); );
}, },

View File

@ -95,6 +95,8 @@ class EditorStyleCustomizer {
final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; final fontFamily = pageStyle.fontFamily ?? defaultFontFamily;
final defaultTextDirection = final defaultTextDirection =
context.read<DocumentAppearanceCubit>().state.defaultTextDirection; context.read<DocumentAppearanceCubit>().state.defaultTextDirection;
final textScaleFactor =
context.read<AppearanceSettingsCubit>().state.textScaleFactor;
final baseTextStyle = this.baseTextStyle(fontFamily); final baseTextStyle = this.baseTextStyle(fontFamily);
final codeFontSize = max(0.0, fontSize - 2); final codeFontSize = max(0.0, fontSize - 2);
return EditorStyle.mobile( return EditorStyle.mobile(
@ -131,8 +133,7 @@ class EditorStyleCustomizer {
textSpanDecorator: customizeAttributeDecorator, textSpanDecorator: customizeAttributeDecorator,
mobileDragHandleBallSize: const Size.square(12.0), mobileDragHandleBallSize: const Size.square(12.0),
magnifierSize: const Size(144, 96), magnifierSize: const Size(144, 96),
textScaleFactor: textScaleFactor: textScaleFactor,
context.watch<AppearanceSettingsCubit>().state.textScaleFactor,
); );
} }
@ -213,7 +214,7 @@ class EditorStyleCustomizer {
); );
TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) { TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) {
if (fontFamily == null) { if (fontFamily == null || fontFamily == defaultFontFamily) {
return TextStyle(fontWeight: fontWeight); return TextStyle(fontWeight: fontWeight);
} }
try { try {

View File

@ -151,6 +151,15 @@ extension ViewLayoutExtension on ViewLayoutPB {
_ => throw Exception('Unknown layout type'), _ => 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) { bool get isDatabaseView => switch (this) {
ViewLayoutPB.Grid || ViewLayoutPB.Grid ||
ViewLayoutPB.Board || ViewLayoutPB.Board ||