crdt_lf_flutter 0.1.0 copy "crdt_lf_flutter: ^0.1.0" to clipboard
crdt_lf_flutter: ^0.1.0 copied to clipboard

Flutter reactivity for crdt_lf — rebuild widgets when CRDT state changes, with selectors, a provider and a collaborative text field. Wraps provider, like flutter_bloc.

example/lib/main.dart

import 'dart:math';

import 'package:crdt_lf/crdt_lf.dart';
import 'package:crdt_lf_flutter/crdt_lf_flutter.dart';
import 'package:flutter/material.dart';

void main() => runApp(const CrdtApp());

// Handler ids — sub-widgets look handlers up by id from the ambient document.
const _counterId = 'counter';
const _todosId = 'todos';
const _settingsId = 'settings';
const _nicknameKey = 'nickname';
const _noteId = 'note';

/// Builds the single document shared by the whole app.
///
/// Handlers auto-register on construction, so we don't keep references: every
/// widget/callback resolves them by id from the ambient [CrdtProvider].
CRDTDocument _buildDocument() {
  final doc = CRDTDocument()..registerDefaultFactories();
  CRDTRegisterHandler<int>(doc, _counterId).set(0);
  CRDTListHandler<String>(doc, _todosId);
  // A container handler ("settings") holding a nested text handler.
  CRDTMapRefHandler(
    doc,
    _settingsId,
  ).setRef(_nicknameKey, CRDTFugueTextHandler(doc, doc.newHandlerId()));
  // A fixed id so the "Remote edit" throwaway peer can address the same
  // handler.
  CRDTFugueTextHandler(doc, _noteId).insert(0, 'hello');
  return doc;
}

/// Entry widget: a [MaterialApp] whose subtree is fed a single [CRDTDocument]
/// through [CrdtProvider] (which owns and disposes it).
class CrdtApp extends StatelessWidget {
  /// Creates the example app.
  const CrdtApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'crdt_lf_flutter',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: CrdtProvider(
        create: (_) => _buildDocument(),
        child: const HomePage(),
      ),
    );
  }
}

/// The demo screen. Every card is independent: watch the "rebuilt ×N" badges to
/// see that editing one handler only re-renders the widgets that observe it.
class HomePage extends StatelessWidget {
  /// Creates the home page.
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('crdt_lf_flutter'),
        // Rebuilds on ANY document change (document-level baseline).
        actions: const [
          Padding(
            padding: EdgeInsets.only(right: 12),
            child: Center(child: _DocumentSummary()),
          ),
        ],
      ),
      body: const SingleChildScrollView(
        padding: EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _Legend(),
            SizedBox(height: 8),
            _DocumentSelectorCard(),
            _CounterCard(),
            _ListenerCard(),
            _TodosCard(),
            _SettingsCard(),
            _NoteCard(),
          ],
        ),
      ),
    );
  }
}

/// Short explanation shown at the top of the demo.
class _Legend extends StatelessWidget {
  const _Legend();

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      child: const Padding(
        padding: EdgeInsets.all(12),
        child: Text(
          'Each ⟳ badge counts how many times that region rebuilt. Edit one '
          'handler and notice only the widgets that observe it light up — that '
          'is the point of CrdtSelector / CrdtHandlerBuilder. The Listener '
          'card fires side effects without rebuilding, and the Note card '
          'binds a TextField to a text handler — including changes imported '
          'from a remote peer and a collaborator cursor drawn over the field.',
        ),
      ),
    );
  }
}

/// Document-level baseline: rebuilds on every change.
class _DocumentSummary extends StatelessWidget {
  const _DocumentSummary();

  @override
  Widget build(BuildContext context) {
    return CrdtBuilder(
      builder:
          (context, document) => _RebuildBadge(
            label: 'document',
            stretch: false,
            child: Text('${document.exportChanges().length} changes'),
          ),
    );
  }
}

/// A document-level derived scalar shown with [CrdtSelector]: rebuilds only
/// when the selected value changes, deduping every unrelated edit.
class _DocumentSelectorCard extends StatelessWidget {
  const _DocumentSelectorCard();

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Document slice — CrdtSelector',
      description:
          'Selects document.registeredHandlers.length: every edit is '
          'deduplicated until "Add key" (Settings card) registers a new '
          'handler.',
      actions: const [],
      child: CrdtSelector<int>(
        selector: (context, document) => document.registeredHandlers.length,
        builder:
            (context, count) => _RebuildBadge(
              label: 'doc-handlers',
              child: Text(
                'registered handlers: $count',
                style: _valueStyle(context),
              ),
            ),
      ),
    );
  }
}

/// A `CRDTRegisterHandler<int>` shown reactively with a selector.
class _CounterCard extends StatelessWidget {
  const _CounterCard();

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Counter — CrdtHandlerSelector',
      description: 'Rebuilds only when the counter value changes.',
      actions: [
        FilledButton.tonalIcon(
          onPressed: () {
            final counter = context.crdtHandler<CRDTRegisterHandler<int>>(
              _counterId,
            );
            counter.set((counter.value ?? 0) + 1);
          },
          icon: const Icon(Icons.add),
          label: const Text('Increment'),
        ),
      ],
      child: CrdtHandlerSelector<CRDTRegisterHandler<int>, int>(
        id: _counterId,
        selector: (context, handler) => handler.value ?? 0,
        builder:
            (context, value) => _RebuildBadge(
              label: 'counter',
              child: Text('value: $value', style: _valueStyle(context)),
            ),
      ),
    );
  }
}

/// A [CrdtHandlerListener]: fires a side effect (a SnackBar) when the counter
/// changes, while its child is never rebuilt.
class _ListenerCard extends StatelessWidget {
  const _ListenerCard();

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Side effects — CrdtHandlerListener',
      description:
          'Shows a SnackBar whenever the counter changes (press "Increment" '
          'above); the child below never rebuilds.',
      actions: const [],
      child: CrdtHandlerListener<CRDTRegisterHandler<int>>(
        id: _counterId,
        listener:
            (context, handler) =>
                ScaffoldMessenger.of(context)
                  ..hideCurrentSnackBar()
                  ..showSnackBar(
                    SnackBar(
                      content: Text('counter changed → ${handler.value}'),
                    ),
                  ),
        child: const _RebuildBadge(
          label: 'listener-child',
          child: Text('static child — the listener fires, I never rebuild'),
        ),
      ),
    );
  }
}

/// A `CRDTListHandler<String>` shown two ways: a length selector (rebuilds only
/// when the count changes) and a list builder (rebuilds on any list change).
class _TodosCard extends StatelessWidget {
  const _TodosCard();

  CRDTListHandler<String> _todos(BuildContext context) =>
      context.crdtHandler<CRDTListHandler<String>>(_todosId);

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Todos — Selector (count) + HandlerBuilder (list)',
      description:
          'Add/remove changes the length (both rebuild). "Edit first" '
          'mutates an item in place: the list rebuilds, the count does not.',
      actions: [
        FilledButton.tonalIcon(
          onPressed: () {
            final todos = _todos(context);
            todos.insert(todos.length, 'Todo #${todos.length + 1}');
          },
          icon: const Icon(Icons.add),
          label: const Text('Add'),
        ),
        FilledButton.tonalIcon(
          onPressed: () {
            final todos = _todos(context);
            if (todos.length > 0) todos.delete(todos.length - 1, 1);
          },
          icon: const Icon(Icons.remove),
          label: const Text('Remove last'),
        ),
        FilledButton.tonalIcon(
          onPressed: () {
            final todos = _todos(context);
            if (todos.length > 0) {
              todos.update(0, '${todos.value.first} ✎');
            }
          },
          icon: const Icon(Icons.edit),
          label: const Text('Edit first'),
        ),
      ],
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          CrdtHandlerSelector<CRDTListHandler<String>, int>(
            id: _todosId,
            selector: (context, handler) => handler.value.length,
            builder:
                (context, count) => _RebuildBadge(
                  label: 'todos-count',
                  child: Text('count: $count', style: _valueStyle(context)),
                ),
          ),
          const SizedBox(height: 8),
          CrdtHandlerBuilder<CRDTListHandler<String>>(
            id: _todosId,
            builder: (context, handler) {
              final todos = handler.value;
              return _RebuildBadge(
                label: 'todos-list',
                child:
                    todos.isEmpty
                        ? const Text('No todos yet')
                        : Column(
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: [
                            for (var i = 0; i < todos.length; i++)
                              ListTile(
                                dense: true,
                                // Neutral icon — todos aren't checkable.
                                leading: CircleAvatar(
                                  radius: 12,
                                  child: Text('${i + 1}'),
                                ),
                                title: Text(todos[i]),
                              ),
                          ],
                        ),
              );
            },
          ),
        ],
      ),
    );
  }
}

/// A `CRDTMapRefHandler` (container) with a nested text child, rendered by two
/// `CrdtHandlerBuilder`s that differ only in `nested`.
class _SettingsCard extends StatelessWidget {
  const _SettingsCard();

  CRDTMapRefHandler _settings(BuildContext context) =>
      context.crdtHandler<CRDTMapRefHandler>(_settingsId);

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Settings (ref handler) — nested: false vs true',
      description:
          'Edit nickname changes a CHILD handler: only nested:true '
          'rebuilds. Add/remove key changes the container itself: both '
          'rebuild. Only keys added with "Add key" are removable (nickname '
          'stays): "Remove key" disables itself when none is left.',
      actions: [
        FilledButton.tonalIcon(
          onPressed: () {
            final settings = _settings(context);
            final nickname = settings.getRefAs<CRDTFugueTextHandler>(
              _nicknameKey,
            );
            if (nickname != null) {
              context.crdtDocument.runInTransaction(
                () => nickname.change('user-${Random().nextInt(20)}'),
              );
            }
          },
          icon: const Icon(Icons.edit),
          label: const Text('Edit nickname'),
        ),
        FilledButton.tonalIcon(
          onPressed: () {
            final doc = context.crdtDocument;
            final settings = _settings(context);
            // The map key can be reused after a delete, but handler ids are
            // never unregistered from the document — keep them unique.
            settings.setRef(
              'flag-${settings.value.length}',
              CRDTFugueTextHandler(doc, doc.newHandlerId())..insert(0, 'on'),
            );
          },
          icon: const Icon(Icons.add),
          label: const Text('Add key'),
        ),
        // Only the keys added with "Add key" are removable (nickname is the
        // nested demo): the button disables itself — reactively — when none
        // is left, instead of silently doing nothing.
        CrdtHandlerSelector<CRDTMapRefHandler, bool>(
          id: _settingsId,
          selector:
              (context, handler) =>
                  handler.value.keys.any((k) => k != _nicknameKey),
          builder: (context, hasExtra) {
            return FilledButton.tonalIcon(
              onPressed:
                  !hasExtra
                      ? null
                      : () {
                        final settings = _settings(context);
                        final extra =
                            settings.value.keys
                                .where((k) => k != _nicknameKey)
                                .toList();
                        if (extra.isNotEmpty) {
                          settings.delete(extra.last);
                        }
                      },
              icon: const Icon(Icons.remove),
              label: const Text('Remove key'),
            );
          },
        ),
      ],
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          CrdtHandlerBuilder<CRDTMapRefHandler>(
            id: _settingsId,
            builder: (context, handler) {
              return _RebuildBadge(
                label: 'settings-flat',
                child: Text(
                  'nested: false → ${handler.value.length} keys',
                  style: _valueStyle(context),
                ),
              );
            },
          ),
          const SizedBox(height: 8),
          CrdtHandlerBuilder<CRDTMapRefHandler>(
            id: _settingsId,
            nested: true,
            builder: (context, handler) {
              return _RebuildBadge(
                label: 'settings-nested',
                child: Text(
                  'nested: true → ${handler.resolved}',
                  style: _valueStyle(context),
                ),
              );
            },
          ),
        ],
      ),
    );
  }
}

/// A `CRDTFugueTextHandler` bound to a TextField through [CrdtTextFieldBuilder]
/// (local keystrokes diff into the handler, remote values are adopted with the
/// caret preserved), plus a naive remote peer simulated on demand and a fake
/// collaborator cursor drawn by [CrdtTextCursorsOverlay].
class _NoteCard extends StatefulWidget {
  const _NoteCard();

  @override
  State<_NoteCard> createState() => _NoteCardState();
}

class _NoteCardState extends State<_NoteCard> {
  /// The fake collaborator's cursors. In a real app they come from a presence
  /// channel (e.g. crdt_socket_sync's awareness plugin); a ValueNotifier so
  /// updating them rebuilds only the overlay, not the CrdtTextFieldBuilder.
  final _cursors = ValueNotifier<List<CrdtTextCursor>>(const []);

  @override
  void dispose() {
    _cursors.dispose();
    super.dispose();
  }

  void _toggleCursor() {
    if (_cursors.value.isNotEmpty) {
      _cursors.value = const [];
      return;
    }
    final note = context.crdtHandler<CRDTFugueTextHandler>(_noteId);
    // A stable anchor mid-text: watch it follow the text when "Remote edit"
    // prepends content or when you type before it.
    _cursors.value = [
      CrdtTextCursor(
        id: 'bob',
        label: 'Bob',
        color: Colors.pink,
        base: note.stablePositionAt(note.length ~/ 2),
      ),
    ];
  }

  @override
  Widget build(BuildContext context) {
    return _DemoCard(
      title: 'Note — CrdtTextFieldBuilder + remote cursors',
      description:
          'A TextField bound to a text handler: typing pushes each edit '
          'into the handler, "Remote edit" imports a change from a throwaway '
          'peer and the controller adopts it in place (caret preserved) — '
          'the badge never bumps because the subtree never rebuilds. '
          '"Toggle cursor" anchors a fake collaborator cursor that follows '
          'the text across edits.',
      actions: [
        FilledButton.tonalIcon(
          onPressed: () {
            // Naive remote peer: a throwaway document with the same handler
            // id merges cleanly via importChanges, no pre-sync needed.
            final remote = CRDTDocument(peerId: PeerId.generate());
            CRDTFugueTextHandler(remote, _noteId).insert(0, '[remote] ');
            context.crdtDocument.importChanges(remote.exportChanges());
          },
          icon: const Icon(Icons.cloud_download),
          label: const Text('Remote edit'),
        ),
        FilledButton.tonalIcon(
          onPressed: _toggleCursor,
          icon: const Icon(Icons.person_pin),
          label: const Text('Toggle cursor'),
        ),
      ],
      child: CrdtTextFieldBuilder(
        id: _noteId,
        builder: (context, controller) {
          return _RebuildBadge(
            label: 'note-text',
            child: ValueListenableBuilder<List<CrdtTextCursor>>(
              valueListenable: _cursors,
              builder: (context, cursors, child) {
                return CrdtTextCursorsOverlay(
                  id: _noteId,
                  cursors: cursors,
                  child: child!,
                );
              },
              child: TextField(
                key: const Key('note-field'),
                controller: controller,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  isDense: true,
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

TextStyle? _valueStyle(BuildContext context) =>
    Theme.of(context).textTheme.titleMedium;

/// A titled card with a description, a reactive [child] and action buttons.
class _DemoCard extends StatelessWidget {
  const _DemoCard({
    required this.title,
    required this.description,
    required this.child,
    required this.actions,
  });

  final String title;
  final String description;
  final Widget child;
  final List<Widget> actions;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 4),
            Text(description, style: Theme.of(context).textTheme.bodySmall),
            const SizedBox(height: 12),
            child,
            const SizedBox(height: 12),
            Wrap(spacing: 8, runSpacing: 8, children: actions),
          ],
        ),
      ),
    );
  }
}

/// Wraps [child] with a badge that counts (and flashes on) each rebuild of this
/// subtree — the visual proof of which regions re-render.
///
/// It is a [StatefulWidget], so its `build` runs again whenever the reactive
/// builder that returns it rebuilds; when that builder skips a rebuild the
/// count stays put.
class _RebuildBadge extends StatefulWidget {
  const _RebuildBadge({
    required this.label,
    required this.child,
    this.stretch = true,
  });

  final String label;
  final Widget child;

  /// Card content lays out full width (Column); AppBar content is compact
  /// (Row).
  final bool stretch;

  @override
  State<_RebuildBadge> createState() => _RebuildBadgeState();
}

class _RebuildBadgeState extends State<_RebuildBadge> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    _count++;
    final scheme = Theme.of(context).colorScheme;
    final chip = TweenAnimationBuilder<double>(
      key: ValueKey<int>(_count),
      tween: Tween<double>(begin: 1, end: 0),
      duration: const Duration(milliseconds: 600),
      builder: (context, t, child) {
        return Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
          decoration: BoxDecoration(
            color: Color.lerp(
              scheme.surfaceContainerHighest,
              scheme.primary,
              t,
            ),
            borderRadius: BorderRadius.circular(12),
          ),
          child: child,
        );
      },
      child: Text(
        '⟳ ${widget.label} ×$_count',
        key: Key('rebuilds-${widget.label}'),
        style: TextStyle(
          fontSize: 12,
          color: scheme.onSurfaceVariant,
          fontFeatures: const [FontFeature.tabularFigures()],
        ),
      ),
    );

    if (!widget.stretch) {
      return Row(
        mainAxisSize: MainAxisSize.min,
        children: [chip, const SizedBox(width: 8), widget.child],
      );
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      mainAxisSize: MainAxisSize.min,
      children: [
        Align(alignment: Alignment.centerLeft, child: chip),
        const SizedBox(height: 6),
        widget.child,
      ],
    );
  }
}
0
likes
0
points
141
downloads

Publisher

verified publishermattiapispisa.it

Weekly Downloads

Flutter reactivity for crdt_lf — rebuild widgets when CRDT state changes, with selectors, a provider and a collaborative text field. Wraps provider, like flutter_bloc.

Homepage
Repository (GitHub)
View/report issues

Topics

#crdt #local-first #flutter #reactive

License

unknown (license)

Dependencies

crdt_lf, flutter, provider

More

Packages that depend on crdt_lf_flutter