dartvex_flutter 0.2.0 copy "dartvex_flutter: ^0.2.0" to clipboard
dartvex_flutter: ^0.2.0 copied to clipboard

Flutter widgets for Convex — ConvexProvider, QueryBuilder, MutationBuilder, and more. Reactive UI powered by Dartvex.

example/lib/main.dart

import 'dart:async';

import 'package:dartvex_flutter/dartvex_flutter.dart';
import 'package:flutter/material.dart';

import 'concierge_theme.dart';

void main() {
  runApp(const ExampleApp());
}

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  late final DemoRuntimeClient _client;

  @override
  void initState() {
    super.initState();
    _client = DemoRuntimeClient()
      ..emitConnectionState(ConvexConnectionState.connected);
  }

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

  @override
  Widget build(BuildContext context) {
    return ConvexProvider(
      client: _client,
      child: MaterialApp(
        title: 'dartvex_flutter example',
        debugShowCheckedModeBanner: false,
        theme: buildConciergeTheme(),
        home: const ExampleHomePage(),
      ),
    );
  }
}

class ExampleHomePage extends StatefulWidget {
  const ExampleHomePage({super.key});

  @override
  State<ExampleHomePage> createState() => _ExampleHomePageState();
}

class _ExampleHomePageState extends State<ExampleHomePage> {
  // The message currently being sent, shared between the mutation args and the
  // optimistic update (the OptimisticUpdate typedef carries no args of its own).
  String _composedText = '';
  bool _failNextSend = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        titleSpacing: 16,
        title: const Row(
          children: <Widget>[
            DartvexLogoMark(size: 30),
            SizedBox(width: 12),
            Text('dartvex_flutter'),
          ],
        ),
      ),
      body: ConciergeBackground(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Align(
                alignment: Alignment.centerLeft,
                child: Wrap(
                  spacing: 12,
                  runSpacing: 8,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: <Widget>[
                    DecoratedBox(
                      decoration: BoxDecoration(
                        color: ConciergeColors.surfaceHigh,
                        borderRadius: BorderRadius.circular(999),
                        border: Border.all(
                          color: ConciergeColors.outline.withValues(alpha: 0.6),
                        ),
                      ),
                      child: Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 14,
                          vertical: 8,
                        ),
                        child: ConvexConnectionIndicator(
                          connectedBuilder: (context) =>
                              const Text('Connected'),
                          connectingBuilder: (context) =>
                              const Text('Connecting'),
                          disconnectedBuilder: (context) =>
                              const Text('Disconnected'),
                        ),
                      ),
                    ),
                    // Shown only while the client is recovering auth after a
                    // rejection. Backed by ConvexClient.authRefreshing.
                    const AuthRefreshingBadge(),
                  ],
                ),
              ),
              const SizedBox(height: 24),
              const Text(
                'Realtime messages',
                style: TextStyle(
                  fontSize: 28,
                  fontWeight: FontWeight.w700,
                  letterSpacing: 0,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'This example uses an in-memory runtime client to demonstrate '
                'the widget API. Swap it with ConvexClientRuntime in a real app.',
              ),
              const SizedBox(height: 16),
              const RealtimeInternalsPanel(),
              const SizedBox(height: 16),
              SizedBox(
                height: 240,
                child: ConvexQuery<List<String>>(
                  query: 'messages:list',
                  decode: (value) => List<String>.from(value as List<dynamic>),
                  builder: (context, snapshot) {
                    if (snapshot.isLoading) {
                      return const Center(child: CircularProgressIndicator());
                    }
                    if (snapshot.hasError) {
                      return Center(child: Text(snapshot.error.toString()));
                    }
                    final messages = snapshot.data ?? const <String>[];
                    return ListView.separated(
                      itemCount: messages.length,
                      separatorBuilder: (context, index) =>
                          const SizedBox(height: 12),
                      itemBuilder: (context, index) {
                        return DecoratedBox(
                          decoration: BoxDecoration(
                            color: ConciergeColors.surface,
                            borderRadius: BorderRadius.circular(16),
                            border: Border.all(
                              color: ConciergeColors.cyan.withValues(
                                alpha: 0.14,
                              ),
                            ),
                          ),
                          child: Padding(
                            padding: const EdgeInsets.all(16),
                            child: Text(messages[index]),
                          ),
                        );
                      },
                    );
                  },
                ),
              ),
              const SizedBox(height: 16),
              // Toggle to make the next send fail, demonstrating that the
              // optimistic message is rolled back when the mutation fails.
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Fail next send (demo rollback)'),
                value: _failNextSend,
                onChanged: (value) {
                  setState(() => _failNextSend = value);
                  final client = ConvexProvider.of(context);
                  if (client is DemoRuntimeClient) {
                    client.failNextMutation = value;
                  }
                },
              ),
              ConvexMutation<String>(
                mutation: 'messages:send',
                // Appends the pending message to messages:list instantly; the
                // overlay is rolled back automatically if the send fails.
                optimisticUpdate: (store) {
                  final current =
                      (store.getQuery('messages:list') as List<dynamic>?)
                          ?.cast<String>() ??
                      const <String>[];
                  store.setQuery(
                    'messages:list',
                    const <String, dynamic>{},
                    <String>[_composedText, ...current],
                  );
                },
                builder: (context, mutate, snapshot) {
                  return FilledButton(
                    onPressed: snapshot.isLoading
                        ? null
                        : () {
                            _composedText =
                                'Message sent at '
                                '${DateTime.now().toIso8601String()}';
                            mutate(<String, dynamic>{'text': _composedText});
                          },
                    child: Text(
                      snapshot.isLoading ? 'Sending...' : 'Send a demo message',
                    ),
                  );
                },
              ),
              const SizedBox(height: 12),
              OutlinedButton(
                onPressed: () {
                  final client = ConvexProvider.of(context);
                  if (client is DemoRuntimeClient) {
                    unawaited(client.simulateAuthRefresh());
                  }
                },
                child: const Text('Simulate auth refresh'),
              ),
              const SizedBox(height: 12),
              OutlinedButton(
                onPressed: () {
                  final client = ConvexProvider.of(context);
                  if (client is DemoRuntimeClient) {
                    unawaited(client.simulateReconnect());
                  }
                },
                child: const Text('Simulate reconnect'),
              ),
              const SizedBox(height: 12),
              OutlinedButton(
                onPressed: () => Navigator.of(context).push(
                  MaterialPageRoute<void>(
                    builder: (_) => const PaginatedHistoryPage(),
                  ),
                ),
                child: const Text('Open paginated history'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// A page demonstrating live, reactive pagination with [PaginatedQueryBuilder].
///
/// The list loads pages on demand via "Load more" and updates reactively: the
/// "Add entry" button prepends a backlog entry, which appears at the top of the
/// already-loaded first page without a manual reload.
class PaginatedHistoryPage extends StatelessWidget {
  /// Creates the paginated history demo page.
  const PaginatedHistoryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Paginated history')),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          final client = ConvexProvider.of(context);
          if (client is DemoRuntimeClient) {
            client.addHistoryEntry(
              'Live entry at ${DateTime.now().toIso8601String()}',
            );
          }
        },
        icon: const Icon(Icons.add),
        label: const Text('Add entry'),
      ),
      body: PaginatedQueryBuilder<String>(
        query: 'messages:history',
        pageSize: 8,
        fromJson: (json) => json['text'] as String,
        builder: (context, items, loadMore, status) {
          if (status == PaginationStatus.loading) {
            return const Center(child: CircularProgressIndicator());
          }
          if (status == PaginationStatus.error) {
            return const Center(child: Text('Failed to load history'));
          }
          return ListView.builder(
            padding: const EdgeInsets.all(16),
            itemCount: items.length + 1,
            itemBuilder: (context, index) {
              if (index < items.length) {
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 6),
                  child: Text(items[index]),
                );
              }
              switch (status) {
                case PaginationStatus.allLoaded:
                  return const Padding(
                    padding: EdgeInsets.symmetric(vertical: 16),
                    child: Center(child: Text('— end of history —')),
                  );
                case PaginationStatus.loadingMore:
                  return const Padding(
                    padding: EdgeInsets.symmetric(vertical: 16),
                    child: Center(child: CircularProgressIndicator()),
                  );
                case PaginationStatus.loading:
                case PaginationStatus.idle:
                case PaginationStatus.error:
                  return Padding(
                    padding: const EdgeInsets.symmetric(vertical: 12),
                    child: Center(
                      child: OutlinedButton(
                        onPressed: loadMore,
                        child: const Text('Load more'),
                      ),
                    ),
                  );
              }
            },
          );
        },
      ),
    );
  }
}

/// A chip shown only while the client is refreshing auth after a rejection.
///
/// Demonstrates [ConvexAuthRefreshingBuilder] driven by
/// `ConvexClient.authRefreshing`.
class AuthRefreshingBadge extends StatelessWidget {
  /// Creates an [AuthRefreshingBadge].
  const AuthRefreshingBadge({super.key});

  @override
  Widget build(BuildContext context) {
    return ConvexAuthRefreshingBuilder(
      builder: (context, isRefreshing) {
        if (!isRefreshing) {
          return const SizedBox.shrink();
        }
        return DecoratedBox(
          decoration: BoxDecoration(
            color: ConciergeColors.warning.withValues(alpha: 0.16),
            borderRadius: BorderRadius.circular(999),
            border: Border.all(
              color: ConciergeColors.warning.withValues(alpha: 0.5),
            ),
          ),
          child: const Padding(
            padding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                SizedBox(
                  width: 14,
                  height: 14,
                  child: CircularProgressIndicator(
                    strokeWidth: 2,
                    color: ConciergeColors.warning,
                  ),
                ),
                SizedBox(width: 8),
                Text(
                  'Authenticating…',
                  style: TextStyle(
                    color: ConciergeColors.warning,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

/// A collapsible "Realtime internals" panel surfacing the live connection and
/// auth state, driven by [ConvexConnectionStatusBuilder] and
/// [ConvexAuthRefreshingBuilder] (the WS4b rich connection status).
class RealtimeInternalsPanel extends StatelessWidget {
  /// Creates a [RealtimeInternalsPanel].
  const RealtimeInternalsPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return Material(
      color: ConciergeColors.surface,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: ConciergeColors.cyan.withValues(alpha: 0.14)),
      ),
      child: Theme(
        data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
        child: ExpansionTile(
          tilePadding: const EdgeInsets.symmetric(horizontal: 16),
          childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
          title: const Text(
            'Realtime internals',
            style: TextStyle(fontWeight: FontWeight.w600),
          ),
          subtitle: const Text('Live connection + auth state'),
          children: <Widget>[
            ConvexConnectionStatusBuilder(
              builder: (context, status) {
                return Column(
                  children: <Widget>[
                    _InternalsRow('State', status.state.name),
                    _InternalsRow(
                      'WebSocket connected',
                      '${status.isWebSocketConnected}',
                    ),
                    _InternalsRow('Synced', '${status.isConnected}'),
                    _InternalsRow('Loading', '${status.isLoading}'),
                    _InternalsRow(
                      'Has ever connected',
                      '${status.hasEverConnected}',
                    ),
                    _InternalsRow(
                      'Connection count',
                      '${status.connectionCount}',
                    ),
                    _InternalsRow(
                      'Reconnect retries',
                      '${status.connectionRetries}',
                    ),
                    _InternalsRow(
                      'Inflight mutations',
                      '${status.inflightMutations}',
                    ),
                    _InternalsRow(
                      'Inflight actions',
                      '${status.inflightActions}',
                    ),
                    _InternalsRow(
                      'Oldest inflight',
                      status.timeOfOldestInflightRequest?.toIso8601String() ??
                          '—',
                    ),
                  ],
                );
              },
            ),
            ConvexAuthRefreshingBuilder(
              builder: (context, isRefreshing) =>
                  _InternalsRow('Auth refreshing', '$isRefreshing'),
            ),
          ],
        ),
      ),
    );
  }
}

class _InternalsRow extends StatelessWidget {
  const _InternalsRow(this.label, this.value);

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Text(label, style: const TextStyle(color: ConciergeColors.textDim)),
          Flexible(
            child: Text(
              value,
              textAlign: TextAlign.right,
              style: const TextStyle(fontWeight: FontWeight.w500),
            ),
          ),
        ],
      ),
    );
  }
}

class DemoRuntimeClient implements ConvexRuntimeClient {
  DemoRuntimeClient()
    : _connectionController = StreamController<ConvexConnectionState>.broadcast(
        sync: true,
      ) {
    _messages = <String>[
      'Welcome to dartvex_flutter.',
      'This list updates through the shared runtime interface.',
    ];
  }

  final StreamController<ConvexConnectionState> _connectionController;
  final StreamController<bool> _authRefreshingController =
      StreamController<bool>.broadcast(sync: true);
  final List<DemoRuntimeSubscription> _subscriptions =
      <DemoRuntimeSubscription>[];
  final List<DemoPaginatedQuery> _paginatedQueries = <DemoPaginatedQuery>[];
  late List<String> _messages;
  // A longer, paginated backlog rendered by the live paginated history page.
  late List<Map<String, dynamic>> _history =
      List<Map<String, dynamic>>.generate(
        24,
        (index) => <String, dynamic>{
          'id': index,
          'text': 'History message #${24 - index}',
        },
      );
  ConvexConnectionState _currentConnectionState =
      ConvexConnectionState.connecting;
  ConnectionStatus _currentConnectionStatus = ConnectionStatus.fromState(
    ConvexConnectionState.connecting,
  );
  final StreamController<ConnectionStatus> _connectionStatusController =
      StreamController<ConnectionStatus>.broadcast(sync: true);
  // Simulated transport metrics so the "Realtime internals" panel has something
  // to show; a real ConvexClient reports these for real.
  int _connectionCount = 1;
  int _connectionRetries = 0;
  int _inflightMutations = 0;
  // The demo never issues actions, so this stays zero; a real ConvexClient
  // reports actual inflight actions here.
  final int _inflightActions = 0;
  bool _hasEverConnected = false;
  DateTime? _oldestInflight;
  bool _currentAuthRefreshing = false;
  bool _disposed = false;

  /// The number of backlog entries available to paginate through.
  int get historyLength => _history.length;

  /// The first [count] backlog entries, newest first.
  List<Map<String, dynamic>> historySlice(int count) =>
      _history.take(count).toList(growable: false);

  /// When `true`, the next [mutate] call fails after showing its optimistic
  /// update, so the example can demonstrate rollback.
  bool failNextMutation = false;

  @override
  Stream<ConvexConnectionState> get connectionState =>
      _connectionController.stream;

  @override
  ConvexConnectionState get currentConnectionState => _currentConnectionState;

  @override
  Stream<ConnectionStatus> get connectionStatus =>
      _connectionStatusController.stream;

  @override
  ConnectionStatus get currentConnectionStatus => _currentConnectionStatus;

  @override
  Stream<bool> get authRefreshing => _authRefreshingController.stream;

  @override
  bool get currentAuthRefreshing => _currentAuthRefreshing;

  @override
  Future<dynamic> action(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
  ]) async {
    return 'Action "$name" completed';
  }

  @override
  Future<void> reconnectNow(String reason) async {}

  @override
  void dispose() {
    if (_disposed) {
      return;
    }
    _disposed = true;
    for (final subscription in _subscriptions) {
      subscription.cancel();
    }
    for (final query in List<DemoPaginatedQuery>.of(_paginatedQueries)) {
      query.cancel();
    }
    unawaited(_connectionController.close());
    unawaited(_connectionStatusController.close());
    unawaited(_authRefreshingController.close());
  }

  void emitConnectionState(ConvexConnectionState state) {
    _currentConnectionState = state;
    if (state == ConvexConnectionState.connected) {
      _hasEverConnected = true;
    }
    _connectionController.add(state);
    _publishStatus();
  }

  /// Rebuilds and broadcasts the rich [ConnectionStatus] from the demo's
  /// simulated transport metrics.
  void _publishStatus() {
    final connected =
        _currentConnectionState == ConvexConnectionState.connected;
    final inflight = _inflightMutations + _inflightActions;
    _currentConnectionStatus = ConnectionStatus(
      state: _currentConnectionState,
      isWebSocketConnected: connected,
      isConnected: connected && inflight == 0,
      hasEverConnected: _hasEverConnected,
      connectionCount: _connectionCount,
      connectionRetries: _connectionRetries,
      inflightMutations: _inflightMutations,
      inflightActions: _inflightActions,
      timeOfOldestInflightRequest: inflight > 0 ? _oldestInflight : null,
      hasSyncedPastLastReconnect: connected,
    );
    if (!_connectionStatusController.isClosed) {
      _connectionStatusController.add(_currentConnectionStatus);
    }
  }

  /// Simulates a reconnect cycle: the connection drops to reconnecting (the
  /// retry counter ticks up), then re-establishes (the connection count grows),
  /// the way a real reconnect would, so the internals panel updates live.
  Future<void> simulateReconnect() async {
    _connectionRetries += 1;
    emitConnectionState(ConvexConnectionState.reconnecting);
    await Future<void>.delayed(const Duration(milliseconds: 900));
    if (_disposed) {
      return;
    }
    _connectionRetries = 0;
    _connectionCount += 1;
    emitConnectionState(ConvexConnectionState.connected);
  }

  void emitAuthRefreshing(bool isRefreshing) {
    _currentAuthRefreshing = isRefreshing;
    _authRefreshingController.add(isRefreshing);
  }

  /// Simulates the client recovering auth after a server rejection: it flips to
  /// "refreshing" briefly, the way a real reauth (stop socket, refetch token,
  /// restart) would, then settles back.
  Future<void> simulateAuthRefresh() async {
    emitAuthRefreshing(true);
    await Future<void>.delayed(const Duration(milliseconds: 1200));
    if (_disposed) {
      return;
    }
    emitAuthRefreshing(false);
  }

  @override
  Future<dynamic> mutate(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
    OptimisticUpdate? optimisticUpdate,
  ]) async {
    _inflightMutations += 1;
    _oldestInflight ??= DateTime.now();
    _publishStatus();
    try {
      // A real ConvexClient overlays the optimistic update internally; this
      // in-memory demo runs it against a snapshot of the current results to
      // show the pending message instantly, then commits or rolls back.
      final committed = List<String>.from(_messages);
      if (optimisticUpdate != null) {
        final store = _DemoOptimisticStore(<String, Object?>{
          'messages:list': List<String>.from(_messages),
        });
        optimisticUpdate(store);
        final optimistic =
            (store.getQuery('messages:list') as List<dynamic>?)
                ?.cast<String>() ??
            committed;
        _emitToAll(
          optimistic,
          source: ConvexQuerySource.cache,
          hasPendingWrites: true,
        );
      }

      await Future<void>.delayed(const Duration(milliseconds: 600));
      if (_disposed) {
        return null;
      }

      if (failNextMutation) {
        failNextMutation = false;
        // Roll back to the authoritative server state and fail the mutation.
        _emitToAll(
          committed,
          source: ConvexQuerySource.remote,
          hasPendingWrites: false,
        );
        throw StateError(
          'Simulated send failure — optimistic message rolled back',
        );
      }

      final text = args['text'] as String? ?? 'Untitled message';
      _messages = <String>[text, ...committed];
      _emitToAll(
        List<String>.from(_messages),
        source: ConvexQuerySource.remote,
        hasPendingWrites: false,
      );
      return text;
    } finally {
      _inflightMutations -= 1;
      if (_inflightMutations == 0 && _inflightActions == 0) {
        _oldestInflight = null;
      }
      _publishStatus();
    }
  }

  void _emitToAll(
    List<String> value, {
    required ConvexQuerySource source,
    required bool hasPendingWrites,
  }) {
    for (final subscription in _subscriptions) {
      subscription.emit(
        value,
        source: source,
        hasPendingWrites: hasPendingWrites,
      );
    }
  }

  @override
  Future<dynamic> query(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
  ]) async {
    return List<String>.from(_messages);
  }

  @override
  Future<T> queryOnce<T>(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
  ]) async {
    final result = await query(name, args);
    return result as T;
  }

  @override
  ConvexRuntimeSubscription subscribe(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
  ]) {
    final subscription = DemoRuntimeSubscription();
    _subscriptions.add(subscription);
    scheduleMicrotask(() {
      if (!subscription.isCanceled) {
        subscription.emit(List<String>.from(_messages));
      }
    });
    return subscription;
  }

  @override
  ConvexRuntimePaginatedQuery paginatedQuery(
    String name,
    Map<String, dynamic> args, {
    int pageSize = 20,
  }) {
    // A real ConvexClient runs an actual paginated query; this demo paginates
    // the in-memory backlog so the page list updates live as entries arrive.
    final query = DemoPaginatedQuery(this, pageSize);
    _paginatedQueries.add(query);
    return query;
  }

  /// Removes a finished paginated query from the live set.
  void unregisterPaginatedQuery(DemoPaginatedQuery query) {
    _paginatedQueries.remove(query);
  }

  /// Prepends a backlog entry (newest first), reactively growing every live
  /// paginated query's first page so the new message appears at the top.
  void addHistoryEntry(String text) {
    _history = <Map<String, dynamic>>[
      <String, dynamic>{'id': _history.length, 'text': text},
      ..._history,
    ];
    for (final query in List<DemoPaginatedQuery>.of(_paginatedQueries)) {
      query.onHistoryChanged();
    }
  }
}

class DemoRuntimeSubscription implements ConvexRuntimeSubscription {
  final StreamController<ConvexRuntimeQueryEvent> _controller =
      StreamController<ConvexRuntimeQueryEvent>.broadcast(sync: true);
  bool isCanceled = false;

  @override
  Stream<ConvexRuntimeQueryEvent> get stream => _controller.stream;

  @override
  void cancel() {
    if (isCanceled) {
      return;
    }
    isCanceled = true;
    unawaited(_controller.close());
  }

  void emit(
    dynamic value, {
    ConvexQuerySource source = ConvexQuerySource.remote,
    bool hasPendingWrites = false,
  }) {
    if (isCanceled) {
      return;
    }
    _controller.add(
      ConvexRuntimeQuerySuccess(
        value,
        source: source,
        hasPendingWrites: hasPendingWrites,
      ),
    );
  }
}

/// An in-memory, reactive paginated query over [DemoRuntimeClient]'s backlog.
///
/// Loads the first page after a short delay, grows by [pageSize] on
/// [loadMore], and re-emits when the backlog changes so the page list stays
/// live — the in-memory stand-in for the core reactive pagination engine.
class DemoPaginatedQuery implements ConvexRuntimePaginatedQuery {
  /// Creates a paginated view of [_client]'s backlog with the given [pageSize].
  DemoPaginatedQuery(this._client, this.pageSize) {
    Future<void>.delayed(const Duration(milliseconds: 300), () {
      if (isCanceled) {
        return;
      }
      _loaded = pageSize;
      _refresh();
    });
  }

  final DemoRuntimeClient _client;

  /// Items requested per page.
  final int pageSize;

  final StreamController<ConvexPaginatedResult> _controller =
      StreamController<ConvexPaginatedResult>.broadcast(sync: true);
  ConvexPaginatedResult _current = const ConvexPaginatedResult(
    results: <dynamic>[],
    status: ConvexPaginationStatus.loadingFirstPage,
    isDone: false,
  );
  int _loaded = 0;
  bool _loadingMore = false;

  /// Whether this query has been canceled.
  bool isCanceled = false;

  @override
  Stream<ConvexPaginatedResult> get stream => _controller.stream;

  @override
  ConvexPaginatedResult get current => _current;

  @override
  bool loadMore([int? numItems]) {
    if (isCanceled || _loadingMore || _loaded >= _client.historyLength) {
      return false;
    }
    _loadingMore = true;
    _emit(_current.results, ConvexPaginationStatus.loadingMore, isDone: false);
    Future<void>.delayed(const Duration(milliseconds: 350), () {
      if (isCanceled) {
        return;
      }
      _loaded = (_loaded + (numItems ?? pageSize)).clamp(
        0,
        _client.historyLength,
      );
      _loadingMore = false;
      _refresh();
    });
    return true;
  }

  /// Grows the window by one and re-emits so a freshly prepended entry shows.
  void onHistoryChanged() {
    if (isCanceled || _loaded == 0) {
      return;
    }
    _loaded = (_loaded + 1).clamp(0, _client.historyLength);
    _refresh();
  }

  void _refresh() {
    final isDone = _loaded >= _client.historyLength;
    _emit(
      _client.historySlice(_loaded),
      isDone
          ? ConvexPaginationStatus.exhausted
          : ConvexPaginationStatus.canLoadMore,
      isDone: isDone,
    );
  }

  void _emit(
    List<dynamic> results,
    ConvexPaginationStatus status, {
    required bool isDone,
  }) {
    _current = ConvexPaginatedResult(
      results: results,
      status: status,
      isDone: isDone,
    );
    if (!_controller.isClosed) {
      _controller.add(_current);
    }
  }

  @override
  void cancel() {
    if (isCanceled) {
      return;
    }
    isCanceled = true;
    _client.unregisterPaginatedQuery(this);
    unawaited(_controller.close());
  }
}

/// A minimal in-memory [OptimisticLocalStore] for the demo runtime client.
///
/// A real [ConvexClient] applies optimistic updates against its live query
/// cache; this stand-in just holds the one query the example renders so the
/// update can be run and read back.
class _DemoOptimisticStore implements OptimisticLocalStore {
  _DemoOptimisticStore(this._values);

  final Map<String, Object?> _values;
  final Set<String> _loadingQueries = <String>{};

  @override
  dynamic getQuery(
    String name, [
    Map<String, dynamic> args = const <String, dynamic>{},
  ]) {
    return _values[name];
  }

  @override
  List<OptimisticQueryEntry> getAllQueries(String name) {
    if (_loadingQueries.contains(name)) {
      return const <OptimisticQueryEntry>[
        OptimisticQueryEntry(
          args: <String, dynamic>{},
          value: null,
          isLoading: true,
        ),
      ];
    }
    if (!_values.containsKey(name)) {
      return const <OptimisticQueryEntry>[];
    }
    final value = _values[name];
    return <OptimisticQueryEntry>[
      OptimisticQueryEntry(args: const <String, dynamic>{}, value: value),
    ];
  }

  @override
  void setQuery(String name, Map<String, dynamic> args, Object? value) {
    _loadingQueries.remove(name);
    _values[name] = value;
  }

  @override
  void clearQuery(String name, Map<String, dynamic> args) {
    _values.remove(name);
    _loadingQueries.add(name);
  }
}