feat: added offline collaboration example using appflowy_editor_sync_plugin (#1103)
* Adding offline collaboration example using appflowy_editor_sync_plugin * Adapting example to a new appflowy_editor_sync_plugin version. * Revert "Adapting example to a new appflowy_editor_sync_plugin version." This reverts commit 7ea235f6f6ceb34d00a365fd05e82cdb6c5a8c67. * increase flutter-version for testing workflow * fix: auto_scrollable_widget adding lint ignore: invalide_use_of_protected_member.
This commit is contained in:
parent
470c4e77c7
commit
6721018df0
9 changed files with 320 additions and 6 deletions
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.27.4"
|
flutter-version: "3.29.3"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
|
||||||
@UIApplicationMain
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:example/pages/animated_markdown_page.dart';
|
||||||
import 'package:example/pages/auto_complete_editor.dart';
|
import 'package:example/pages/auto_complete_editor.dart';
|
||||||
import 'package:example/pages/auto_expand_editor.dart';
|
import 'package:example/pages/auto_expand_editor.dart';
|
||||||
import 'package:example/pages/collab_editor.dart';
|
import 'package:example/pages/collab_editor.dart';
|
||||||
|
import 'package:example/pages/collab_editor_offline.dart';
|
||||||
import 'package:example/pages/collab_selection_editor.dart';
|
import 'package:example/pages/collab_selection_editor.dart';
|
||||||
import 'package:example/pages/customize_theme_for_editor.dart';
|
import 'package:example/pages/customize_theme_for_editor.dart';
|
||||||
import 'package:example/pages/drag_to_reorder_editor.dart';
|
import 'package:example/pages/drag_to_reorder_editor.dart';
|
||||||
|
|
@ -218,6 +219,14 @@ class _HomePageState extends State<HomePage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
_buildListTile(context, 'Collab Offline', () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const CollabEditorOffline(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
_buildListTile(context, 'Custom Theme', () {
|
_buildListTile(context, 'Custom Theme', () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor_sync_plugin/appflowy_editor_sync_utility_functions.dart';
|
||||||
|
import 'package:example/home_page.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
|
||||||
import 'package:example/home_page.dart';
|
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
|
@ -15,6 +15,8 @@ void main() {
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Important for Offline Collaboration
|
||||||
|
await AppflowyEditorSyncUtilityFunctions.initAppFlowyEditorSync();
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
296
example/lib/pages/collab_editor_offline.dart
Normal file
296
example/lib/pages/collab_editor_offline.dart
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor_sync_plugin/appflowy_editor_sync_utility_functions.dart';
|
||||||
|
import 'package:appflowy_editor_sync_plugin/editor_state_sync_wrapper.dart';
|
||||||
|
import 'package:appflowy_editor_sync_plugin/types/sync_db_attributes.dart';
|
||||||
|
import 'package:appflowy_editor_sync_plugin/types/update_types.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Manages changes and updates for an editor in a collaborative setting
|
||||||
|
class EditorChanges {
|
||||||
|
final _updatesStreamController = StreamController<List<DbUpdate>>.broadcast();
|
||||||
|
final List<DbUpdate> updates = [];
|
||||||
|
final List<DbUpdate> pendingUpdates = [];
|
||||||
|
|
||||||
|
/// Updates length Notifier
|
||||||
|
final ValueNotifier<int> updatesLengthNotifier = ValueNotifier(0);
|
||||||
|
final ValueNotifier<int> pendingUpdatesLengthNotifier = ValueNotifier(0);
|
||||||
|
|
||||||
|
/// Stream of updates that can be subscribed to
|
||||||
|
Stream<List<DbUpdate>> get updatesStream => _updatesStreamController.stream;
|
||||||
|
|
||||||
|
/// Add an update and broadcast it to listeners
|
||||||
|
void addUpdate(DbUpdate update) {
|
||||||
|
updates.add(update);
|
||||||
|
_updateStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a pending update (for offline mode)
|
||||||
|
void addPending(DbUpdate update) {
|
||||||
|
pendingUpdates.add(update);
|
||||||
|
pendingUpdatesLengthNotifier.value = pendingUpdates.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply all pending updates and broadcast them
|
||||||
|
void applyAllPending() {
|
||||||
|
if (pendingUpdates.isNotEmpty) {
|
||||||
|
updates.addAll(pendingUpdates);
|
||||||
|
pendingUpdates.clear();
|
||||||
|
_updateStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateStream() async {
|
||||||
|
// This function is not used in the current implementation
|
||||||
|
// but can be used to update the stream with new data
|
||||||
|
_updatesStreamController.add(updates);
|
||||||
|
updatesLengthNotifier.value = updates.length;
|
||||||
|
pendingUpdatesLengthNotifier.value = pendingUpdates.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all updates as a list
|
||||||
|
List<DbUpdate> getAllUpdates() {
|
||||||
|
return List<DbUpdate>.from(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose of resources
|
||||||
|
void dispose() {
|
||||||
|
_updatesStreamController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollabEditorOffline extends StatefulWidget {
|
||||||
|
const CollabEditorOffline({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CollabEditorOffline> createState() => _CollabEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CollabEditorState extends State<CollabEditorOffline> {
|
||||||
|
late EditorState editorStateA;
|
||||||
|
late EditorState editorStateB;
|
||||||
|
late EditorStateSyncWrapper wrapperA;
|
||||||
|
late EditorStateSyncWrapper wrapperB;
|
||||||
|
|
||||||
|
late EditorChanges changesA;
|
||||||
|
late EditorChanges changesB;
|
||||||
|
|
||||||
|
bool isOnline = true;
|
||||||
|
bool isInitialized = false;
|
||||||
|
List<Uint8List> initialUpdates = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
changesA = EditorChanges();
|
||||||
|
changesB = EditorChanges();
|
||||||
|
_initializeDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeDocument() async {
|
||||||
|
// Create a document and get initial updates
|
||||||
|
initialUpdates = [
|
||||||
|
await AppflowyEditorSyncUtilityFunctions.initDocumentFromExistingDocument(
|
||||||
|
Document.blank(withInitialText: true),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await _initEditors();
|
||||||
|
setState(() {
|
||||||
|
isInitialized = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initEditors() async {
|
||||||
|
// Add initial updates to both editors
|
||||||
|
for (final update in initialUpdates) {
|
||||||
|
changesA.addUpdate(DbUpdate(update: update));
|
||||||
|
changesB.addUpdate(DbUpdate(update: update));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize editor A
|
||||||
|
wrapperA = EditorStateSyncWrapper(
|
||||||
|
syncAttributes: SyncAttributes(
|
||||||
|
// Both editors get the same initial updates
|
||||||
|
getInitialUpdates: () async {
|
||||||
|
return changesA.getAllUpdates();
|
||||||
|
},
|
||||||
|
getUpdatesStream: changesA.updatesStream,
|
||||||
|
saveUpdate: (Uint8List update) async {
|
||||||
|
if (isOnline) {
|
||||||
|
changesA.addUpdate(DbUpdate(update: update));
|
||||||
|
changesB.addUpdate(DbUpdate(update: update));
|
||||||
|
} else {
|
||||||
|
changesA.addUpdate(DbUpdate(update: update));
|
||||||
|
changesB.addPending(DbUpdate(update: update));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize editor B
|
||||||
|
wrapperB = EditorStateSyncWrapper(
|
||||||
|
syncAttributes: SyncAttributes(
|
||||||
|
// Both editors get the same initial updates
|
||||||
|
getInitialUpdates: () async {
|
||||||
|
return changesB.getAllUpdates();
|
||||||
|
},
|
||||||
|
getUpdatesStream: changesB.updatesStream,
|
||||||
|
saveUpdate: (Uint8List update) async {
|
||||||
|
if (isOnline) {
|
||||||
|
changesA.addUpdate(DbUpdate(update: update));
|
||||||
|
changesB.addUpdate(DbUpdate(update: update));
|
||||||
|
} else {
|
||||||
|
changesB.addUpdate(DbUpdate(update: update));
|
||||||
|
changesA.addPending(DbUpdate(update: update));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
editorStateA = await wrapperA.initAndHandleChanges();
|
||||||
|
editorStateB = await wrapperB.initAndHandleChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleOfflineMode() {
|
||||||
|
setState(() {
|
||||||
|
isOnline = !isOnline;
|
||||||
|
if (isOnline) {
|
||||||
|
changesA.applyAllPending();
|
||||||
|
changesB.applyAllPending();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
changesA.dispose();
|
||||||
|
changesB.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!isInitialized) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Collaborative Offline Editor'),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Best tested on desktop',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('Online Mode'),
|
||||||
|
Switch(
|
||||||
|
value: isOnline,
|
||||||
|
onChanged: (_) => _toggleOfflineMode(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('Pending Updates A: '),
|
||||||
|
ValueListenableBuilder<int>(
|
||||||
|
valueListenable:
|
||||||
|
changesA.pendingUpdatesLengthNotifier,
|
||||||
|
builder: (context, value, _) => Text('$value'),
|
||||||
|
),
|
||||||
|
const Text(', B: '),
|
||||||
|
ValueListenableBuilder<int>(
|
||||||
|
valueListenable:
|
||||||
|
changesB.pendingUpdatesLengthNotifier,
|
||||||
|
builder: (context, value, _) => Text('$value'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('History A: '),
|
||||||
|
ValueListenableBuilder<int>(
|
||||||
|
valueListenable: changesA.updatesLengthNotifier,
|
||||||
|
builder: (context, value, _) => Text('$value'),
|
||||||
|
),
|
||||||
|
const Text(', B: '),
|
||||||
|
ValueListenableBuilder<int>(
|
||||||
|
valueListenable: changesB.updatesLengthNotifier,
|
||||||
|
builder: (context, value, _) => Text('$value'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: !isOnline ? Colors.red : Colors.green,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: AppFlowyEditor(
|
||||||
|
editorState: editorStateA,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: !isOnline ? Colors.red : Colors.green,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: AppFlowyEditor(
|
||||||
|
editorState: editorStateB,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
appflowy_editor:
|
appflowy_editor:
|
||||||
|
appflowy_editor_sync_plugin:
|
||||||
|
git:
|
||||||
|
url: https://github.com/Musta-Pollo/appflowy_editor_sync_plugin.git
|
||||||
|
ref: main
|
||||||
|
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
url_launcher: ^6.1.5
|
url_launcher: ^6.1.5
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class _AutoScrollableWidgetState extends State<AutoScrollableWidget> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
_scrollableState.dispose();
|
_scrollableState.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue