mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
dbbdc13d96
commit
6edb184bfb
9
.github/workflows/ios_ci.yaml
vendored
9
.github/workflows/ios_ci.yaml
vendored
@ -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
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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();
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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 {
|
||||||
|
@ -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 ||
|
||||||
|
Loading…
Reference in New Issue
Block a user