From 5f8ef3856a75a34b947ab36588a3144ecb04de73 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Mar 2024 12:26:48 +0700 Subject: [PATCH] feat: initial version for collab document (#4937) * feat: initial version for collab document * feat: show sync indicator * feat: add sync document feature flag * fix: rust ci * chore: remove unused code * chore: update doc_bloc.dart --- .../lib/mobile/application/mobile_router.dart | 6 +- .../presentation/base/mobile_view_page.dart | 10 +- .../editor/mobile_editor_screen.dart | 4 +- .../application/collab_document_adapter.dart | 135 ++++++++++++++++++ .../document/application/doc_bloc.dart | 52 ++++--- .../document/application/doc_service.dart | 8 ++ .../document/application/doc_sync_bloc.dart | 101 +++++++++++++ .../document_data_pb_extension.dart | 8 +- .../lib/plugins/document/document.dart | 22 ++- .../lib/plugins/document/document_page.dart | 17 --- .../presentation/document_sync_indicator.dart | 66 +++++++++ .../lib/shared/feature_flags.dart | 8 ++ .../lib/startup/tasks/generate_router.dart | 8 +- .../lib/user/application/auth/device_id.dart | 10 +- .../appflowy_flutter/lib/util/json_print.dart | 2 + frontend/resources/translations/en.json | 5 + .../tests/document/af_cloud_test/edit_test.rs | 10 +- .../tests/document/supabase_test/edit_test.rs | 10 +- .../rust-lib/flowy-document/src/entities.rs | 21 ++- 19 files changed, 429 insertions(+), 74 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index db4540f3c0..3a2c9a83fa 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -26,7 +26,7 @@ extension on ViewPB { String get routeName { switch (layout) { case ViewLayoutPB.Document: - return MobileEditorScreen.routeName; + return MobileDocumentScreen.routeName; case ViewLayoutPB.Grid: return MobileGridScreen.routeName; case ViewLayoutPB.Calendar: @@ -42,8 +42,8 @@ extension on ViewPB { switch (layout) { case ViewLayoutPB.Document: return { - MobileEditorScreen.viewId: id, - MobileEditorScreen.viewTitle: name, + MobileDocumentScreen.viewId: id, + MobileDocumentScreen.viewTitle: name, }; case ViewLayoutPB.Grid: return { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index c7c9758ed9..038da31ebf 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -4,7 +4,9 @@ import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; @@ -70,7 +72,13 @@ class _MobileViewPageState extends State { } else { body = state.data!.fold((view) { viewPB = view; - actions.add(_buildAppBarMoreButton(view)); + actions.addAll([ + if (FeatureFlag.syncDocument.isOn) ...[ + DocumentSyncIndicator(view: view), + const HSpace(8.0), + ], + _buildAppBarMoreButton(view), + ]); final plugin = view.plugin(arguments: widget.arguments ?? const {}) ..init(); return plugin.widgetBuilder.buildWidget(shrinkWrap: false); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart index fe482f57c4..14c4e022ae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart @@ -2,8 +2,8 @@ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; -class MobileEditorScreen extends StatelessWidget { - const MobileEditorScreen({ +class MobileDocumentScreen extends StatelessWidget { + const MobileDocumentScreen({ super.key, required this.id, this.title, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart new file mode 100644 index 0000000000..6c971ffdeb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/util/json_print.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:collection/collection.dart'; + +class CollabDocumentAdapter { + CollabDocumentAdapter(this.editorState, this.docId); + + final EditorState editorState; + final String docId; + + final _service = DocumentService(); + + /// Sync version 1 + /// + /// Force to reload the document + /// + /// Only use in development + Future syncV1() async { + final result = await _service.getDocument(viewId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return null; + } + return EditorState(document: document); + } + + /// Sync version 2 + /// + /// Translate the [docEvent] from yrs to [Operation]s and apply it to the [editorState] + /// + /// Not fully implemented yet + Future syncV2(DocEventPB docEvent) async { + prettyPrintJson(docEvent.toProto3Json()); + + final transaction = editorState.transaction; + + for (final event in docEvent.events) { + for (final blockEvent in event.event) { + switch (blockEvent.command) { + case DeltaTypePB.Inserted: + break; + case DeltaTypePB.Updated: + await _syncUpdated(blockEvent, transaction); + break; + case DeltaTypePB.Removed: + break; + default: + } + } + } + + await editorState.apply(transaction, isRemote: true); + } + + /// Sync version 3 + /// + /// Diff the local document with the remote document and apply the changes + Future syncV3() async { + final result = await _service.getDocument(viewId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return; + } + + final ops = diffNodes(editorState.document.root, document.root); + if (ops.isEmpty) { + return; + } + + final transaction = editorState.transaction; + for (final op in ops) { + transaction.add(op); + } + await editorState.apply(transaction, isRemote: true); + } + + Future _syncUpdated( + BlockEventPayloadPB payload, + Transaction transaction, + ) async { + assert(payload.command == DeltaTypePB.Updated); + + final path = payload.path; + final id = payload.id; + final value = jsonDecode(payload.value); + + final nodes = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ).toList(); + + // 1. meta -> text_map = text delta change + if (path.isTextDeltaChangeset) { + // find the 'text' block and apply the delta + // ⚠️ not completed yet. + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = Delta.fromJson(jsonDecode(value)); + transaction.insertTextDelta(target, 0, delta); + } catch (e) { + Log.error('Failed to apply delta: $value, error: $e'); + } + } + } else if (path.isBlockChangeset) { + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = jsonDecode(value['data'])['delta']; + transaction.updateNode(target, { + 'delta': Delta.fromJson(delta).toJson(), + }); + } catch (e) { + Log.error('Failed to update $value, error: $e'); + } + } + } + } +} + +extension on List { + bool get isTextDeltaChangeset { + return length == 3 && this[0] == 'meta' && this[1] == 'text_map'; + } + + bool get isBlockChangeset { + return length == 2 && this[0] == 'blocks'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 37dbdfefe2..560397a33d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -1,15 +1,17 @@ import 'dart:async'; +import 'package:appflowy/plugins/document/application/collab_document_adapter.dart'; import 'package:appflowy/plugins/document/application/doc_service.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/util/json_print.dart'; import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -48,6 +50,8 @@ class DocumentBloc extends Bloc { final DocumentService _documentService = DocumentService(); final TrashService _trashService = TrashService(); + late CollabDocumentAdapter _collabDocumentAdapter; + late final TransactionAdapter _transactionAdapter = TransactionAdapter( documentId: view.id, documentService: _documentService, @@ -79,10 +83,10 @@ class DocumentBloc extends Bloc { ) async { await event.when( initial: () async { - final editorState = await _fetchDocumentState(); + final result = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); - final newState = await editorState.fold( + final newState = await result.fold( (s) async { final userProfilePB = await getIt().getUser().toNullable(); @@ -117,8 +121,8 @@ class DocumentBloc extends Bloc { final isDeleted = result.fold((l) => false, (r) => true); emit(state.copyWith(isDeleted: isDeleted)); }, - syncStateChanged: (isSyncing) { - emit(state.copyWith(isSyncing: isSyncing)); + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); }, ); } @@ -145,7 +149,7 @@ class DocumentBloc extends Bloc { _syncStateListener.start( didReceiveSyncState: (syncState) { if (!isClosed) { - add(DocumentEvent.syncStateChanged(syncState.isSyncing)); + add(DocumentEvent.syncStateChanged(syncState)); } }, ); @@ -169,6 +173,8 @@ class DocumentBloc extends Bloc { final editorState = EditorState(document: document); + _collabDocumentAdapter = CollabDocumentAdapter(editorState, view.id); + // subscribe to the document change from the editor _subscription = editorState.transactionStream.listen((event) async { final time = event.$1; @@ -236,21 +242,12 @@ class DocumentBloc extends Bloc { } } - void syncDocumentDataPB(DocEventPB docEvent) { - prettyPrintJson(docEvent.toProto3Json()); - for (final event in docEvent.events) { - for (final blockEvent in event.event) { - switch (blockEvent.command) { - case DeltaTypePB.Inserted: - break; - case DeltaTypePB.Updated: - break; - case DeltaTypePB.Removed: - break; - default: - } - } + Future syncDocumentDataPB(DocEventPB docEvent) async { + if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { + return; } + + await _collabDocumentAdapter.syncV3(); } } @@ -261,17 +258,18 @@ class DocumentEvent with _$DocumentEvent { const factory DocumentEvent.restore() = Restore; const factory DocumentEvent.restorePage() = RestorePage; const factory DocumentEvent.deletePermanently() = DeletePermanently; - const factory DocumentEvent.syncStateChanged(bool isSyncing) = - syncStateChanged; + const factory DocumentEvent.syncStateChanged( + final DocumentSyncStatePB syncState, + ) = syncStateChanged; } @freezed class DocumentState with _$DocumentState { const factory DocumentState({ - required bool isDeleted, - required bool forceClose, - required bool isLoading, - required bool isSyncing, + required final bool isDeleted, + required final bool forceClose, + required final bool isLoading, + required final DocumentSyncState syncState, bool? isDocumentEmpty, UserProfilePB? userProfilePB, EditorState? editorState, @@ -282,6 +280,6 @@ class DocumentState with _$DocumentState { isDeleted: false, forceClose: false, isLoading: true, - isSyncing: false, + syncState: DocumentSyncState.Syncing, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart index 31497e85ac..0db7751703 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart @@ -26,6 +26,14 @@ class DocumentService { return result; } + Future> getDocument({ + required String viewId, + }) async { + final payload = OpenDocumentPayloadPB()..documentId = viewId; + final result = await DocumentEventGetDocumentData(payload).send(); + return result; + } + Future> getBlockFromDocument({ required DocumentDataPB document, required String blockId, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart new file mode 100644 index 0000000000..078727bc94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'doc_sync_bloc.freezed.dart'; + +class DocumentSyncBloc extends Bloc { + DocumentSyncBloc({ + required this.view, + }) : _syncStateListener = DocumentSyncStateListener(id: view.id), + super(DocumentSyncBlocState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final userProfile = await getIt().getUser().then( + (result) => result.fold( + (l) => l, + (r) => null, + ), + ); + emit( + state.copyWith( + shouldShowIndicator: + userProfile?.authenticator != AuthenticatorPB.Local, + ), + ); + _syncStateListener.start( + didReceiveSyncState: (syncState) { + if (!isClosed) { + add(DocumentSyncEvent.syncStateChanged(syncState)); + } + }, + ); + + final isNetworkConnected = await _connectivity + .checkConnectivity() + .then((value) => value != ConnectivityResult.none); + emit(state.copyWith(isNetworkConnected: isNetworkConnected)); + + connectivityStream = + _connectivity.onConnectivityChanged.listen((result) { + if (!isClosed) {} + emit( + state.copyWith( + isNetworkConnected: result != ConnectivityResult.none, + ), + ); + }); + }, + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentSyncStateListener _syncStateListener; + final _connectivity = Connectivity(); + + StreamSubscription? connectivityStream; + + @override + Future close() async { + await connectivityStream?.cancel(); + await _syncStateListener.stop(); + return super.close(); + } +} + +@freezed +class DocumentSyncEvent with _$DocumentSyncEvent { + const factory DocumentSyncEvent.initial() = Initial; + const factory DocumentSyncEvent.syncStateChanged( + DocumentSyncStatePB syncState, + ) = syncStateChanged; +} + +@freezed +class DocumentSyncBlocState with _$DocumentSyncBlocState { + const factory DocumentSyncBlocState({ + required DocumentSyncState syncState, + @Default(true) bool isNetworkConnected, + @Default(false) bool shouldShowIndicator, + }) = _DocumentSyncState; + + factory DocumentSyncBlocState.initial() => const DocumentSyncBlocState( + syncState: DocumentSyncState.Syncing, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart index a6d8974921..9762ae7020 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -101,10 +101,16 @@ extension DocumentDataPBFromTo on DocumentDataPB { children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); } - return block?.toNode( + final node = block?.toNode( children: children, meta: meta, ); + + for (final element in children) { + element.parent = node; + } + + return node; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 6a35fb29bc..352258cc9e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -1,13 +1,13 @@ library document_plugin; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; @@ -19,6 +19,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DocumentPluginBuilder extends PluginBuilder { @@ -137,9 +138,22 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder return BlocProvider.value( value: bloc, child: Row( + mainAxisSize: MainAxisSize.min, children: [ - DocumentShareButton(key: ValueKey(view.id), view: view), - const HSpace(4), + DocumentShareButton( + key: ValueKey('share_button_${view.id}'), + view: view, + ), + ...FeatureFlag.syncDocument.isOn + ? [ + const HSpace(20), + DocumentSyncIndicator( + key: ValueKey('sync_state_${view.id}'), + view: view, + ), + const HSpace(12), + ] + : [const HSpace(8)], ViewFavoriteButton( key: ValueKey('favorite_button_${view.id}'), view: view, diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 4c9b3267dc..98ca75ac45 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -175,20 +175,3 @@ class _DocumentPageState extends State { } } } - -class DocumentSyncIndicator extends StatelessWidget { - const DocumentSyncIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state.isSyncing) { - return const SizedBox(height: 1, child: LinearProgressIndicator()); - } else { - return const SizedBox(height: 1); - } - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart new file mode 100644 index 0000000000..5092438217 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/doc_sync_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentSyncIndicator extends StatelessWidget { + const DocumentSyncIndicator({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + DocumentSyncBloc(view: view)..add(const DocumentSyncEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // don't show indicator if user is local + if (!state.shouldShowIndicator) { + return const SizedBox.shrink(); + } + final Color color; + final String hintText; + + if (!state.isNetworkConnected) { + color = Colors.grey; + hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); + } else { + switch (state.syncState) { + case DocumentSyncState.SyncFinished: + color = Colors.green; + hintText = LocaleKeys.newSettings_syncState_synced.tr(); + break; + case DocumentSyncState.Syncing: + case DocumentSyncState.InitSyncBegin: + color = Colors.yellow; + hintText = LocaleKeys.newSettings_syncState_syncing.tr(); + break; + default: + return const SizedBox.shrink(); + } + } + + return FlowyTooltip( + message: hintText, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + width: 8, + height: 8, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 91914c679e..f05b9b06ed 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -21,6 +21,10 @@ enum FeatureFlag { // if it's on, you can see the members settings in the settings page membersSettings, + // used to control the sync feature of the document + // if it's on, the document will be synced the events from server in real-time + syncDocument, + // used for ignore the conflicted feature flag unknown; @@ -82,6 +86,8 @@ enum FeatureFlag { return false; case FeatureFlag.membersSettings: return false; + case FeatureFlag.syncDocument: + return false; case FeatureFlag.unknown: return false; } @@ -93,6 +99,8 @@ enum FeatureFlag { return 'if it\'s on, you can see the workspace list and the workspace settings in the top-left corner of the app'; case FeatureFlag.membersSettings: return 'if it\'s on, you can see the members settings in the settings page'; + case FeatureFlag.syncDocument: + return 'if it\'s on, the document will be synced the events from server in real-time'; case FeatureFlag.unknown: return ''; } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 05de151b7a..e9d7e13d2e 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -463,14 +463,14 @@ GoRoute _signInScreenRoute() { GoRoute _mobileEditorScreenRoute() { return GoRoute( - path: MobileEditorScreen.routeName, + path: MobileDocumentScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { - final id = state.uri.queryParameters[MobileEditorScreen.viewId]!; - final title = state.uri.queryParameters[MobileEditorScreen.viewTitle]; + final id = state.uri.queryParameters[MobileDocumentScreen.viewId]!; + final title = state.uri.queryParameters[MobileDocumentScreen.viewTitle]; return MaterialExtendedPage( - child: MobileEditorScreen(id: id, title: title), + child: MobileDocumentScreen(id: id, title: title), ); }, ); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart index a8758d2c9f..ba8161d5a0 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart @@ -12,26 +12,26 @@ Future getDeviceId() async { return "test_device_id"; } - String deviceId = ""; + String? deviceId; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; deviceId = androidInfo.device; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - deviceId = iosInfo.identifierForVendor ?? ""; + deviceId = iosInfo.identifierForVendor; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; - deviceId = macInfo.systemGUID ?? ""; + deviceId = macInfo.systemGUID; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; deviceId = windowsInfo.computerName; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; - deviceId = linuxInfo.machineId ?? ""; + deviceId = linuxInfo.machineId; } } on PlatformException { Log.error('Failed to get platform version'); } - return deviceId; + return deviceId ?? ''; } diff --git a/frontend/appflowy_flutter/lib/util/json_print.dart b/frontend/appflowy_flutter/lib/util/json_print.dart index b73a740249..35824b8212 100644 --- a/frontend/appflowy_flutter/lib/util/json_print.dart +++ b/frontend/appflowy_flutter/lib/util/json_print.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; const JsonEncoder _encoder = JsonEncoder.withIndent(' '); void prettyPrintJson(Object? object) { Log.trace(_encoder.convert(object)); + debugPrint(_encoder.convert(object)); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 54076b1bff..67c80237e3 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1415,6 +1415,11 @@ }, "language": "Language" } + }, + "syncState": { + "syncing": "Syncing", + "synced": "Everything is up to date", + "noNetworkConnected": "No network connected" } } } \ No newline at end of file diff --git a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs index 8c94fceab0..c0165bd8ca 100644 --- a/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs +++ b/frontend/rust-lib/event-integration/tests/document/af_cloud_test/edit_test.rs @@ -6,7 +6,7 @@ use event_integration::document_event::assert_document_data_equal; use event_integration::user_event::user_localhost_af_cloud; use event_integration::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_document::entities::DocumentSyncStatePB; +use flowy_document::entities::{DocumentSyncState, DocumentSyncStatePB}; use crate::util::{receive_with_timeout, unzip_history_user_db}; @@ -30,7 +30,9 @@ async fn af_cloud_edit_document_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let document_data = test.get_document_data(&document_id).await; @@ -61,7 +63,9 @@ async fn af_cloud_sync_anon_user_document_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let doc_state = test.get_document_doc_state(&document_id).await; diff --git a/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs b/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs index f11b4acb7c..ba761d347d 100644 --- a/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs +++ b/frontend/rust-lib/event-integration/tests/document/supabase_test/edit_test.rs @@ -1,7 +1,7 @@ use std::time::Duration; use event_integration::document_event::assert_document_data_equal; -use flowy_document::entities::DocumentSyncStatePB; +use flowy_document::entities::{DocumentSyncState, DocumentSyncStatePB}; use crate::document::supabase_test::helper::FlowySupabaseDocumentTest; use crate::util::receive_with_timeout; @@ -23,7 +23,9 @@ async fn supabase_document_edit_sync_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); @@ -49,7 +51,9 @@ async fn supabase_document_edit_sync_test2() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 8fc07c1597..9c6b318706 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -445,14 +445,27 @@ pub struct DocumentSnapshotStatePB { #[derive(Debug, Default, ProtoBuf)] pub struct DocumentSyncStatePB { #[pb(index = 1)] - pub is_syncing: bool, + pub value: DocumentSyncState, +} + +#[derive(Debug, Default, ProtoBuf_Enum, PartialEq, Eq, Clone, Copy)] +pub enum DocumentSyncState { + #[default] + InitSyncBegin = 0, + InitSyncEnd = 1, + Syncing = 2, + SyncFinished = 3, } impl From for DocumentSyncStatePB { fn from(value: SyncState) -> Self { - Self { - is_syncing: value.is_syncing(), - } + let value = match value { + SyncState::InitSyncBegin => DocumentSyncState::InitSyncBegin, + SyncState::InitSyncEnd => DocumentSyncState::InitSyncEnd, + SyncState::Syncing => DocumentSyncState::Syncing, + SyncState::SyncFinished => DocumentSyncState::SyncFinished, + }; + Self { value } } }