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
This commit is contained in:
Lucas.Xu 2024-03-21 12:26:48 +07:00 committed by GitHub
parent 37f521ae57
commit 5f8ef3856a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 429 additions and 74 deletions

View File

@ -26,7 +26,7 @@ extension on ViewPB {
String get routeName { String get routeName {
switch (layout) { switch (layout) {
case ViewLayoutPB.Document: case ViewLayoutPB.Document:
return MobileEditorScreen.routeName; return MobileDocumentScreen.routeName;
case ViewLayoutPB.Grid: case ViewLayoutPB.Grid:
return MobileGridScreen.routeName; return MobileGridScreen.routeName;
case ViewLayoutPB.Calendar: case ViewLayoutPB.Calendar:
@ -42,8 +42,8 @@ extension on ViewPB {
switch (layout) { switch (layout) {
case ViewLayoutPB.Document: case ViewLayoutPB.Document:
return { return {
MobileEditorScreen.viewId: id, MobileDocumentScreen.viewId: id,
MobileEditorScreen.viewTitle: name, MobileDocumentScreen.viewTitle: name,
}; };
case ViewLayoutPB.Grid: case ViewLayoutPB.Grid:
return { return {

View File

@ -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/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_sync_indicator.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.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/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; 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';
@ -70,7 +72,13 @@ class _MobileViewPageState extends State<MobileViewPage> {
} else { } else {
body = state.data!.fold((view) { body = state.data!.fold((view) {
viewPB = 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 {}) final plugin = view.plugin(arguments: widget.arguments ?? const {})
..init(); ..init();
return plugin.widgetBuilder.buildWidget(shrinkWrap: false); return plugin.widgetBuilder.buildWidget(shrinkWrap: false);

View File

@ -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:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MobileEditorScreen extends StatelessWidget { class MobileDocumentScreen extends StatelessWidget {
const MobileEditorScreen({ const MobileDocumentScreen({
super.key, super.key,
required this.id, required this.id,
this.title, this.title,

View File

@ -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<EditorState?> 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<void> 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<void> 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<void> _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<String> {
bool get isTextDeltaChangeset {
return length == 3 && this[0] == 'meta' && this[1] == 'text_map';
}
bool get isBlockChangeset {
return length == 2 && this[0] == 'blocks';
}
}

View File

@ -1,15 +1,17 @@
import 'dart:async'; 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/doc_service.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.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/document/application/editor_transaction_adapter.dart';
import 'package:appflowy/plugins/trash/application/trash_service.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/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.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/doc_listener.dart';
import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; import 'package:appflowy/workspace/application/doc/sync_state_listener.dart';
import 'package:appflowy/workspace/application/view/view_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-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.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';
@ -48,6 +50,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final DocumentService _documentService = DocumentService(); final DocumentService _documentService = DocumentService();
final TrashService _trashService = TrashService(); final TrashService _trashService = TrashService();
late CollabDocumentAdapter _collabDocumentAdapter;
late final TransactionAdapter _transactionAdapter = TransactionAdapter( late final TransactionAdapter _transactionAdapter = TransactionAdapter(
documentId: view.id, documentId: view.id,
documentService: _documentService, documentService: _documentService,
@ -79,10 +83,10 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
) async { ) async {
await event.when( await event.when(
initial: () async { initial: () async {
final editorState = await _fetchDocumentState(); final result = await _fetchDocumentState();
_onViewChanged(); _onViewChanged();
_onDocumentChanged(); _onDocumentChanged();
final newState = await editorState.fold( final newState = await result.fold(
(s) async { (s) async {
final userProfilePB = final userProfilePB =
await getIt<AuthService>().getUser().toNullable(); await getIt<AuthService>().getUser().toNullable();
@ -117,8 +121,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final isDeleted = result.fold((l) => false, (r) => true); final isDeleted = result.fold((l) => false, (r) => true);
emit(state.copyWith(isDeleted: isDeleted)); emit(state.copyWith(isDeleted: isDeleted));
}, },
syncStateChanged: (isSyncing) { syncStateChanged: (syncState) {
emit(state.copyWith(isSyncing: isSyncing)); emit(state.copyWith(syncState: syncState.value));
}, },
); );
} }
@ -145,7 +149,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
_syncStateListener.start( _syncStateListener.start(
didReceiveSyncState: (syncState) { didReceiveSyncState: (syncState) {
if (!isClosed) { if (!isClosed) {
add(DocumentEvent.syncStateChanged(syncState.isSyncing)); add(DocumentEvent.syncStateChanged(syncState));
} }
}, },
); );
@ -169,6 +173,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final editorState = EditorState(document: document); final editorState = EditorState(document: document);
_collabDocumentAdapter = CollabDocumentAdapter(editorState, view.id);
// subscribe to the document change from the editor // subscribe to the document change from the editor
_subscription = editorState.transactionStream.listen((event) async { _subscription = editorState.transactionStream.listen((event) async {
final time = event.$1; final time = event.$1;
@ -236,21 +242,12 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
} }
} }
void syncDocumentDataPB(DocEventPB docEvent) { Future<void> syncDocumentDataPB(DocEventPB docEvent) async {
prettyPrintJson(docEvent.toProto3Json()); if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) {
for (final event in docEvent.events) { return;
for (final blockEvent in event.event) {
switch (blockEvent.command) {
case DeltaTypePB.Inserted:
break;
case DeltaTypePB.Updated:
break;
case DeltaTypePB.Removed:
break;
default:
}
}
} }
await _collabDocumentAdapter.syncV3();
} }
} }
@ -261,17 +258,18 @@ class DocumentEvent with _$DocumentEvent {
const factory DocumentEvent.restore() = Restore; const factory DocumentEvent.restore() = Restore;
const factory DocumentEvent.restorePage() = RestorePage; const factory DocumentEvent.restorePage() = RestorePage;
const factory DocumentEvent.deletePermanently() = DeletePermanently; const factory DocumentEvent.deletePermanently() = DeletePermanently;
const factory DocumentEvent.syncStateChanged(bool isSyncing) = const factory DocumentEvent.syncStateChanged(
syncStateChanged; final DocumentSyncStatePB syncState,
) = syncStateChanged;
} }
@freezed @freezed
class DocumentState with _$DocumentState { class DocumentState with _$DocumentState {
const factory DocumentState({ const factory DocumentState({
required bool isDeleted, required final bool isDeleted,
required bool forceClose, required final bool forceClose,
required bool isLoading, required final bool isLoading,
required bool isSyncing, required final DocumentSyncState syncState,
bool? isDocumentEmpty, bool? isDocumentEmpty,
UserProfilePB? userProfilePB, UserProfilePB? userProfilePB,
EditorState? editorState, EditorState? editorState,
@ -282,6 +280,6 @@ class DocumentState with _$DocumentState {
isDeleted: false, isDeleted: false,
forceClose: false, forceClose: false,
isLoading: true, isLoading: true,
isSyncing: false, syncState: DocumentSyncState.Syncing,
); );
} }

View File

@ -26,6 +26,14 @@ class DocumentService {
return result; return result;
} }
Future<FlowyResult<DocumentDataPB, FlowyError>> getDocument({
required String viewId,
}) async {
final payload = OpenDocumentPayloadPB()..documentId = viewId;
final result = await DocumentEventGetDocumentData(payload).send();
return result;
}
Future<FlowyResult<BlockPB, FlowyError>> getBlockFromDocument({ Future<FlowyResult<BlockPB, FlowyError>> getBlockFromDocument({
required DocumentDataPB document, required DocumentDataPB document,
required String blockId, required String blockId,

View File

@ -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<DocumentSyncEvent, DocumentSyncBlocState> {
DocumentSyncBloc({
required this.view,
}) : _syncStateListener = DocumentSyncStateListener(id: view.id),
super(DocumentSyncBlocState.initial()) {
on<DocumentSyncEvent>(
(event, emit) async {
await event.when(
initial: () async {
final userProfile = await getIt<AuthService>().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<void> 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,
);
}

View File

@ -101,10 +101,16 @@ extension DocumentDataPBFromTo on DocumentDataPB {
children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull());
} }
return block?.toNode( final node = block?.toNode(
children: children, children: children,
meta: meta, meta: meta,
); );
for (final element in children) {
element.parent = node;
}
return node;
} }
} }

View File

@ -1,13 +1,13 @@
library document_plugin; library document_plugin;
import 'package:flutter/material.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/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/document_page.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/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.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: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_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class DocumentPluginBuilder extends PluginBuilder { class DocumentPluginBuilder extends PluginBuilder {
@ -137,9 +138,22 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
return BlocProvider<ViewInfoBloc>.value( return BlocProvider<ViewInfoBloc>.value(
value: bloc, value: bloc,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
DocumentShareButton(key: ValueKey(view.id), view: view), DocumentShareButton(
const HSpace(4), 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( ViewFavoriteButton(
key: ValueKey('favorite_button_${view.id}'), key: ValueKey('favorite_button_${view.id}'),
view: view, view: view,

View File

@ -175,20 +175,3 @@ class _DocumentPageState extends State<DocumentPage> {
} }
} }
} }
class DocumentSyncIndicator extends StatelessWidget {
const DocumentSyncIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
if (state.isSyncing) {
return const SizedBox(height: 1, child: LinearProgressIndicator());
} else {
return const SizedBox(height: 1);
}
},
);
}
}

View File

@ -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<DocumentSyncBloc, DocumentSyncBlocState>(
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,
),
);
},
),
);
}
}

View File

@ -21,6 +21,10 @@ enum FeatureFlag {
// if it's on, you can see the members settings in the settings page // if it's on, you can see the members settings in the settings page
membersSettings, 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 // used for ignore the conflicted feature flag
unknown; unknown;
@ -82,6 +86,8 @@ enum FeatureFlag {
return false; return false;
case FeatureFlag.membersSettings: case FeatureFlag.membersSettings:
return false; return false;
case FeatureFlag.syncDocument:
return false;
case FeatureFlag.unknown: case FeatureFlag.unknown:
return false; 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'; 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: case FeatureFlag.membersSettings:
return 'if it\'s on, you can see the members settings in the settings page'; 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: case FeatureFlag.unknown:
return ''; return '';
} }

View File

@ -463,14 +463,14 @@ GoRoute _signInScreenRoute() {
GoRoute _mobileEditorScreenRoute() { GoRoute _mobileEditorScreenRoute() {
return GoRoute( return GoRoute(
path: MobileEditorScreen.routeName, path: MobileDocumentScreen.routeName,
parentNavigatorKey: AppGlobals.rootNavKey, parentNavigatorKey: AppGlobals.rootNavKey,
pageBuilder: (context, state) { pageBuilder: (context, state) {
final id = state.uri.queryParameters[MobileEditorScreen.viewId]!; final id = state.uri.queryParameters[MobileDocumentScreen.viewId]!;
final title = state.uri.queryParameters[MobileEditorScreen.viewTitle]; final title = state.uri.queryParameters[MobileDocumentScreen.viewTitle];
return MaterialExtendedPage( return MaterialExtendedPage(
child: MobileEditorScreen(id: id, title: title), child: MobileDocumentScreen(id: id, title: title),
); );
}, },
); );

View File

@ -12,26 +12,26 @@ Future<String> getDeviceId() async {
return "test_device_id"; return "test_device_id";
} }
String deviceId = ""; String? deviceId;
try { try {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.device; deviceId = androidInfo.device;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final IosDeviceInfo iosInfo = await deviceInfo.iosInfo; final IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? ""; deviceId = iosInfo.identifierForVendor;
} else if (Platform.isMacOS) { } else if (Platform.isMacOS) {
final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo;
deviceId = macInfo.systemGUID ?? ""; deviceId = macInfo.systemGUID;
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.computerName; deviceId = windowsInfo.computerName;
} else if (Platform.isLinux) { } else if (Platform.isLinux) {
final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
deviceId = linuxInfo.machineId ?? ""; deviceId = linuxInfo.machineId;
} }
} on PlatformException { } on PlatformException {
Log.error('Failed to get platform version'); Log.error('Failed to get platform version');
} }
return deviceId; return deviceId ?? '';
} }

View File

@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:flutter/material.dart';
const JsonEncoder _encoder = JsonEncoder.withIndent(' '); const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
void prettyPrintJson(Object? object) { void prettyPrintJson(Object? object) {
Log.trace(_encoder.convert(object)); Log.trace(_encoder.convert(object));
debugPrint(_encoder.convert(object));
} }

View File

@ -1415,6 +1415,11 @@
}, },
"language": "Language" "language": "Language"
} }
},
"syncState": {
"syncing": "Syncing",
"synced": "Everything is up to date",
"noNetworkConnected": "No network connected"
} }
} }
} }

View File

@ -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::user_event::user_localhost_af_cloud;
use event_integration::EventIntegrationTest; use event_integration::EventIntegrationTest;
use flowy_core::DEFAULT_NAME; 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}; 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 // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| {
pb.value != DocumentSyncState::Syncing
});
let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let _ = receive_with_timeout(rx, Duration::from_secs(30)).await;
let document_data = test.get_document_data(&document_id).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 // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| {
pb.value != DocumentSyncState::Syncing
});
let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let _ = receive_with_timeout(rx, Duration::from_secs(30)).await;
let doc_state = test.get_document_doc_state(&document_id).await; let doc_state = test.get_document_doc_state(&document_id).await;

View File

@ -1,7 +1,7 @@
use std::time::Duration; use std::time::Duration;
use event_integration::document_event::assert_document_data_equal; 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::document::supabase_test::helper::FlowySupabaseDocumentTest;
use crate::util::receive_with_timeout; 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 // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| {
pb.value != DocumentSyncState::Syncing
});
receive_with_timeout(rx, Duration::from_secs(30)) receive_with_timeout(rx, Duration::from_secs(30))
.await .await
.unwrap(); .unwrap();
@ -49,7 +51,9 @@ async fn supabase_document_edit_sync_test2() {
// wait all update are send to the remote // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| {
pb.value != DocumentSyncState::Syncing
});
receive_with_timeout(rx, Duration::from_secs(30)) receive_with_timeout(rx, Duration::from_secs(30))
.await .await
.unwrap(); .unwrap();

View File

@ -445,14 +445,27 @@ pub struct DocumentSnapshotStatePB {
#[derive(Debug, Default, ProtoBuf)] #[derive(Debug, Default, ProtoBuf)]
pub struct DocumentSyncStatePB { pub struct DocumentSyncStatePB {
#[pb(index = 1)] #[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<SyncState> for DocumentSyncStatePB { impl From<SyncState> for DocumentSyncStatePB {
fn from(value: SyncState) -> Self { fn from(value: SyncState) -> Self {
Self { let value = match value {
is_syncing: value.is_syncing(), SyncState::InitSyncBegin => DocumentSyncState::InitSyncBegin,
} SyncState::InitSyncEnd => DocumentSyncState::InitSyncEnd,
SyncState::Syncing => DocumentSyncState::Syncing,
SyncState::SyncFinished => DocumentSyncState::SyncFinished,
};
Self { value }
} }
} }