mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
37f521ae57
commit
5f8ef3856a
@ -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 {
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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 '';
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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 ?? '';
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
|
@ -1415,6 +1415,11 @@
|
|||||||
},
|
},
|
||||||
"language": "Language"
|
"language": "Language"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"syncState": {
|
||||||
|
"syncing": "Syncing",
|
||||||
|
"synced": "Everything is up to date",
|
||||||
|
"noNetworkConnected": "No network connected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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;
|
||||||
|
@ -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();
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user