isar_sync_flutter 0.1.0-dev.3
isar_sync_flutter: ^0.1.0-dev.3 copied to clipboard
Flutter companion for isar_sync: wire Google Sign-In (Drive) and the icloud_storage plugin into isar_sync's cloud sync adapters.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:isar_sync_flutter/isar_sync_flutter.dart';
const _collection = 'notes';
const _googleNamespace = 'isar-sync-flutter-example-google';
const _defaultICloudNamespace = 'isar-sync-flutter-example-icloud';
const _defaultICloudContainer = 'iCloud.com.example.myapp';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'isar_sync_flutter e2e',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const SyncDemoHomePage(),
);
}
}
class SyncDemoHomePage extends StatelessWidget {
const SyncDemoHomePage({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('isar_sync_flutter e2e'),
bottom: const TabBar(
tabs: [
Tab(text: 'Google Drive'),
Tab(text: 'iCloud'),
],
),
),
body: const TabBarView(
children: [
GoogleDriveDemoTab(),
ICloudDemoTab(),
],
),
),
);
}
}
class GoogleDriveDemoTab extends StatefulWidget {
const GoogleDriveDemoTab({super.key});
@override
State<GoogleDriveDemoTab> createState() => _GoogleDriveDemoTabState();
}
class _GoogleDriveDemoTabState extends State<GoogleDriveDemoTab> {
final GoogleSignInDriveAuth _driveAuth = GoogleSignInDriveAuth();
final _runtime = _DemoSyncRuntime();
final TextEditingController _titleController =
TextEditingController(text: 'Google synced note');
String _status = 'Initialize Google Sign-In first.';
String _accountEmail = '-';
String _accessTokenPreview = '-';
String? _lastEntityId;
@override
void initState() {
super.initState();
unawaited(_initializeGoogleSignIn());
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
Future<void> _initializeGoogleSignIn() async {
setState(() {
_status = 'Initializing Google Sign-In...';
});
try {
final clientId = const String.fromEnvironment('GOOGLE_CLIENT_ID');
final serverClientId =
const String.fromEnvironment('GOOGLE_SERVER_CLIENT_ID');
await _driveAuth.initialize(
clientId: clientId.isEmpty ? null : clientId,
serverClientId: serverClientId.isEmpty ? null : serverClientId,
);
setState(() {
_status = 'Google Sign-In initialized. Tap SIGN IN.';
});
} catch (error) {
setState(() {
_status = 'Initialization failed: $error';
});
}
}
Future<void> _signInAndBuildSyncCore() async {
setState(() {
_status = 'Signing in and requesting Drive scope...';
});
try {
final session = await _driveAuth.authenticateAndCreateAdapter(
namespace: _googleNamespace,
);
_runtime.connect(
adapter: session.adapter,
deviceId: session.account.id,
);
setState(() {
_accountEmail = session.account.email;
_accessTokenPreview = _truncateToken(session.accessToken);
_status = 'Signed in. You can enqueue local changes and sync now.';
});
} catch (error) {
setState(() {
_status = 'Sign in failed: $error';
});
}
}
Future<void> _enqueueLocalChange() async {
if (!_runtime.isConnected) {
setState(() {
_status = 'Sign in first.';
});
return;
}
final entityId = await _runtime.enqueueUpsert(
title: _titleController.text.trim().isEmpty
? 'Google synced note'
: _titleController.text.trim(),
);
setState(() {
_lastEntityId = entityId;
_status = 'Enqueued local change for $entityId';
});
}
Future<void> _enqueueDeleteLast() async {
final entityId = _lastEntityId;
if (!_runtime.isConnected || entityId == null) {
setState(() {
_status = 'No entity to delete. Create one first.';
});
return;
}
await _runtime.enqueueDelete(entityId: entityId);
setState(() {
_status = 'Enqueued delete for $entityId';
});
}
Future<void> _syncNow() async {
if (!_runtime.isConnected) {
setState(() {
_status = 'Sign in first.';
});
return;
}
try {
final report = await _runtime.syncNow();
final latest = _lastEntityId == null
? null
: await _runtime.readLocal(_lastEntityId!);
setState(() {
_status =
'Sync done: pushed=${report.pushedCount}, failed=${report.failedPushCount}, pulled=${report.pulledCount}, latestLocal=$latest';
});
} catch (error) {
setState(() {
_status = 'Sync failed: $error';
});
}
}
Future<void> _disconnect() async {
await _driveAuth.disconnect();
_runtime.reset();
setState(() {
_accountEmail = '-';
_accessTokenPreview = '-';
_lastEntityId = null;
_status = 'Disconnected.';
});
}
String _truncateToken(String? token) {
if (token == null || token.isEmpty) {
return '(not available)';
}
if (token.length <= 24) {
return token;
}
return '${token.substring(0, 24)}...';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Google Auth + Drive Sync Demo',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
SelectableText('Account: $_accountEmail'),
SelectableText('Access token preview: $_accessTokenPreview'),
const SizedBox(height: 12),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Local note title for queued upsert',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _signInAndBuildSyncCore,
child: const Text('SIGN IN'),
),
ElevatedButton(
onPressed: _enqueueLocalChange,
child: const Text('QUEUE UPSERT'),
),
ElevatedButton(
onPressed: _enqueueDeleteLast,
child: const Text('QUEUE DELETE LAST'),
),
ElevatedButton(
onPressed: _syncNow,
child: const Text('SYNC NOW (PUSH+PULL)'),
),
OutlinedButton(
onPressed: _disconnect,
child: const Text('DISCONNECT'),
),
],
),
const SizedBox(height: 16),
const Text(
'Status',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Expanded(
child: SingleChildScrollView(
child: SelectableText(_status),
),
),
],
),
);
}
}
class ICloudDemoTab extends StatefulWidget {
const ICloudDemoTab({super.key});
@override
State<ICloudDemoTab> createState() => _ICloudDemoTabState();
}
class _ICloudDemoTabState extends State<ICloudDemoTab> {
final _runtime = _DemoSyncRuntime();
final TextEditingController _containerIdController =
TextEditingController(text: _defaultICloudContainer);
final TextEditingController _namespaceController =
TextEditingController(text: _defaultICloudNamespace);
final TextEditingController _titleController =
TextEditingController(text: 'iCloud synced note');
String _status = 'Connect iCloud client first.';
String _workingDirectory = '-';
String? _lastEntityId;
bool get _supportsICloud =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS);
@override
void dispose() {
_containerIdController.dispose();
_namespaceController.dispose();
_titleController.dispose();
super.dispose();
}
Future<void> _connectICloud() async {
if (!_supportsICloud) {
setState(() {
_status =
'iCloud plugin is supported on iOS/macOS only. Current platform: ${defaultTargetPlatform.name}';
});
return;
}
final containerId = _containerIdController.text.trim();
final namespace = _namespaceController.text.trim();
if (containerId.isEmpty || namespace.isEmpty) {
setState(() {
_status = 'containerId and namespace are required.';
});
return;
}
setState(() {
_status = 'Connecting iCloud storage client...';
});
try {
final client = await ICloudStorageSyncClient.fromTemporaryDirectory(
containerId: containerId,
);
final adapter = ICloudSyncAdapter(
client: client,
namespace: namespace,
);
_runtime.connect(
adapter: adapter,
deviceId: 'icloud-${DateTime.now().millisecondsSinceEpoch}',
);
setState(() {
_workingDirectory = client.workingDirectory.path;
_status =
'iCloud connected. Queue local mutations and run sync for bidirectional flow.';
});
} catch (error) {
setState(() {
_status = 'iCloud connect failed: $error';
});
}
}
Future<void> _queueUpsert() async {
if (!_runtime.isConnected) {
setState(() {
_status = 'Connect iCloud first.';
});
return;
}
final entityId = await _runtime.enqueueUpsert(
title: _titleController.text.trim().isEmpty
? 'iCloud synced note'
: _titleController.text.trim(),
);
setState(() {
_lastEntityId = entityId;
_status = 'Queued upsert for $entityId';
});
}
Future<void> _queueDeleteLast() async {
final entityId = _lastEntityId;
if (!_runtime.isConnected || entityId == null) {
setState(() {
_status = 'No entity to delete. Queue an upsert first.';
});
return;
}
await _runtime.enqueueDelete(entityId: entityId);
setState(() {
_status = 'Queued delete for $entityId';
});
}
Future<void> _syncNow() async {
if (!_runtime.isConnected) {
setState(() {
_status = 'Connect iCloud first.';
});
return;
}
try {
final report = await _runtime.syncNow();
final latest = _lastEntityId == null
? null
: await _runtime.readLocal(_lastEntityId!);
setState(() {
_status =
'iCloud sync done: pushed=${report.pushedCount}, failed=${report.failedPushCount}, pulled=${report.pulledCount}, latestLocal=$latest';
});
} catch (error) {
setState(() {
_status = 'iCloud sync failed: $error';
});
}
}
void _disconnect() {
_runtime.reset();
setState(() {
_lastEntityId = null;
_workingDirectory = '-';
_status = 'iCloud disconnected.';
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'iCloud Sync Demo (Bidirectional)',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
TextField(
controller: _containerIdController,
decoration: const InputDecoration(
labelText: 'iCloud containerId',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _namespaceController,
decoration: const InputDecoration(
labelText: 'Sync namespace',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Local note title for queued upsert',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
SelectableText('Working dir: $_workingDirectory'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _connectICloud,
child: const Text('CONNECT ICLOUD'),
),
ElevatedButton(
onPressed: _queueUpsert,
child: const Text('QUEUE UPSERT'),
),
ElevatedButton(
onPressed: _queueDeleteLast,
child: const Text('QUEUE DELETE LAST'),
),
ElevatedButton(
onPressed: _syncNow,
child: const Text('SYNC NOW (PUSH+PULL)'),
),
OutlinedButton(
onPressed: _disconnect,
child: const Text('DISCONNECT'),
),
],
),
const SizedBox(height: 16),
const Text(
'Status',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Expanded(
child: SingleChildScrollView(
child: SelectableText(_status),
),
),
],
),
);
}
}
class _DemoSyncRuntime {
final InMemorySyncQueue _queue = InMemorySyncQueue();
final InMemorySyncStore _store = InMemorySyncStore();
final InMemorySyncCursorStore _cursorStore = InMemorySyncCursorStore();
late final DefaultApplyEngine _applyEngine = DefaultApplyEngine(
store: _store,
conflictResolver: const LastWriteWinsConflictResolver(),
);
QueueBackedChangeCapture? _capture;
SyncCore? _core;
int _sequence = 0;
bool get isConnected => _capture != null && _core != null;
void connect({
required SyncAdapter adapter,
required String deviceId,
}) {
_core = SyncCore(
queue: _queue,
adapter: adapter,
applyEngine: _applyEngine,
cursorStore: _cursorStore,
);
_capture = QueueBackedChangeCapture(
queue: _queue,
deviceId: deviceId,
);
}
Future<String> enqueueUpsert({required String title}) async {
final capture = _capture;
if (capture == null) {
throw StateError('Not connected');
}
_sequence += 1;
final entityId = 'note-$_sequence';
await capture.capture(
SyncMutation(
collection: _collection,
entityId: entityId,
operation: SyncOperation.upsert,
payload: {
'title': title,
'updatedAt': DateTime.now().toUtc().toIso8601String(),
},
),
);
return entityId;
}
Future<void> enqueueDelete({required String entityId}) async {
final capture = _capture;
if (capture == null) {
throw StateError('Not connected');
}
await capture.capture(
SyncMutation(
collection: _collection,
entityId: entityId,
operation: SyncOperation.delete,
),
);
}
Future<SyncRunReport> syncNow() {
final core = _core;
if (core == null) {
throw StateError('Not connected');
}
return core.syncOnce();
}
Future<JsonMap?> readLocal(String entityId) {
return _store.read(collection: _collection, entityId: entityId);
}
void reset() {
_capture = null;
_core = null;
_sequence = 0;
}
}