isar_sync_flutter 0.1.0-dev.3 copy "isar_sync_flutter: ^0.1.0-dev.3" to clipboard
isar_sync_flutter: ^0.1.0-dev.3 copied to clipboard

PlatformiOSmacOS

Flutter companion for isar_sync: wire Google Sign-In (Drive) and the icloud_storage plugin into isar_sync's cloud sync adapters.

example/lib/main.dart

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;
  }
}
0
likes
160
points
10
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Flutter companion for isar_sync: wire Google Sign-In (Drive) and the icloud_storage plugin into isar_sync's cloud sync adapters.

Repository (GitHub)
View/report issues
Contributing

Topics

#isar #sync #offline-first #google-sign-in #icloud

License

MIT (license)

Dependencies

flutter, google_sign_in, googleapis, http, icloud_storage, isar_sync, path, path_provider

More

Packages that depend on isar_sync_flutter