Examples

Case Study: Complete Todo App

A production-grade Flutter app combining every Qora capability: PersistQoraClient, infinite scroll with pagination, optimistic create/toggle/delete, offline mutation queue, and simulated connectivity toggling.

Objective

Context: Real apps rarely use one feature in isolation. Authentication gates data access, infinite scroll fetches paginated server data, mutations need optimistic feedback with rollback, and connectivity is unreliable. Each concern is straightforward in isolation; the challenge is composing them without coupling.

This example combines every production-grade Qora capability into a single app against the live JSONPlaceholder API. The result is a reference implementation: each pattern is self-contained and extractable to a real project.


Key Features Demonstrated

  • PersistQoraClient + HiveStorageAdapter: write-through persistence; hydrate() warms the cache before the first frame
  • AuthService (ValueNotifier): user session state independent of Qora; gates which todo list is fetched
  • InfiniteQueryBuilder<TodosPage, int>: cursor-based pagination with a sentinel-item scroll trigger
  • InfiniteQueryController.refetch(): pull-to-refresh that resets pagination and re-fetches from page 1
  • Optimistic create with offline queue: QoraMutationBuilder with offlineQueue: true, optimisticResponse, and a _pendingTodos local list for instant UI feedback
  • Per-tile toggle and delete mutations: independent QoraMutationBuilder per tile; toggling one item never blocks another
  • SimulatedConnectivityManager: custom ConnectivityManager implementation for self-contained offline demo via an AppBar toggle
  • NetworkMode.offlineFirst: stale cache served instantly on reconnect; background revalidation triggered automatically
  • NetworkStatusIndicator: zero-boilerplate offline banner using the built-in widget

Architecture Highlights

The app is organised into feature modules with a shared infrastructure core. Qora APIs surface only at the UI layer; models, API clients, and serializers have no Qora dependency:

ex_07_complete_todo_app/lib/
├── main.dart                                    ← bootstrap: PersistQoraClient, hydrate, QoraScope
├── core/
│   ├── auth/
│   │   └── auth_service.dart                   ← ValueNotifier<AuthUser?>, login/logout
│   ├── connectivity/
│   │   └── simulated_connectivity_manager.dart ← ConnectivityManager implementation (toggle button)
│   └── persistence/
│       ├── hive_storage_adapter.dart           ← StorageAdapter over Hive box
│       └── serializers.dart                    ← registerAllSerializers()
├── features/
│   ├── auth/ui/login_screen.dart               ← form, calls AuthService.login()
│   └── todos/
│       ├── data/todo_api.dart                  ← JSONPlaceholder HTTP client
│       ├── models/todo.dart                    ← Todo, TodosPage, CreateTodoInput, ToggleTodoInput
│       └── ui/
│           ├── todo_list_screen.dart           ← all Qora patterns composed together
│           └── todo_tile.dart                  ← presentation widget, decoupled from mutations
└── shared/widgets/offline_banner.dart          ← OfflineBannerWrapper using NetworkStatusIndicator

Bootstrap sequence

lib/main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final storage = await HiveStorageAdapter.open('qora_cache');
  final connectivity = SimulatedConnectivityManager();

  final client = PersistQoraClient(
    storage: storage,
    persistDuration: const Duration(days: 30),
    config: QoraClientConfig(
      defaultOptions: const QoraOptions(
        staleTime: Duration(minutes: 5),
        networkMode: NetworkMode.offlineFirst,
      ),
      reconnectStrategy: const ReconnectStrategy(
        maxConcurrent: 3,
        jitter: Duration(milliseconds: 150),
      ),
      debugMode: kDebugMode,
    ),
  );

  registerAllSerializers(client);
  await client.hydrate(); // ← warm cache before first frame

  runApp(
    QoraScope(
      client: client,
      connectivityManager: connectivity,
      lifecycleManager: FlutterLifecycleManager(qoraClient: client),
      child: OfflineBannerWrapper(child: _App(auth: AuthService(), connectivity: connectivity)),
    ),
  );
}

AuthService extends ValueNotifier<AuthUser?>. _App listens to it with ValueListenableBuilder and navigates between LoginScreen and TodoListScreen reactively, no routing library, no extra state management layer.


Implementation Details

Infinite scroll with sentinel pattern

InfiniteQueryBuilder fetches paginated todos from JSONPlaceholder. The sentinel pattern avoids a separate scroll controller and notificationPredicate:

lib/features/todos/ui/todo_list_screen.dart
InfiniteQueryBuilder<TodosPage, int>(
  queryKey: ['todos', auth.id],
  fetcher: (page) => api.getTodosPage(auth.id, page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
  ),
  builder: (context, state, controller) {
    // ... state handling
    final allItems = [
      ..._pendingTodos, // optimistic items first
      ...state.data!.flatten((p) => p.todos),
    ];

    return RefreshIndicator(
      onRefresh: () => controller.refetch(),
      child: ListView.builder(
        itemCount: allItems.length + 1, // +1 for sentinel
        itemBuilder: (context, index) {
          if (index == allItems.length) {
            // Sentinel: auto-fetch next page when scrolled into view
            if (state.hasNextPage && !state.isFetchingNextPage) {
              controller.fetchNextPage();
            }
            return state.isFetchingNextPage
                ? const _PageLoader()
                : const SizedBox.shrink();
          }
          final todo = allItems[index];
          return todo.isOptimistic
              ? TodoTile(todo: todo) // optimistic: no mutation callbacks
              : _MutableTodoTile(todo: todo, api: api, auth: auth);
        },
      ),
    );
  },
)

_pendingTodos is a local List<Todo> in the screen's State. It holds optimistic items created while a mutation is in-flight or queued offline. These items are prepended to the list and rendered with the isOptimistic visual style in TodoTile. When the mutation succeeds, the list is invalidated and _pendingTodos is cleared.

Optimistic create with offline queue

The create mutation uses a TContext snapshot to enable rollback:

lib/features/todos/ui/todo_list_screen.dart
QoraMutationBuilder<Todo, CreateTodoInput, List<Todo>>(
  mutator: api.createTodo,
  options: MutationOptions(
    offlineQueue: true,
    optimisticResponse: (input) => Todo.optimistic(title: input.title),
    onMutate: (input) async {
      // Snapshot current pending list for rollback
      final snapshot = List<Todo>.from(_pendingTodos);
      setState(() => _pendingTodos.add(Todo.optimistic(title: input.title)));
      return snapshot;
    },
    onSuccess: (_, __, ___) async {
      setState(() => _pendingTodos.clear());
      context.qora.invalidate(['todos', auth.id]);
    },
    onError: (_, __, snapshot) async {
      // Restore pre-mutation pending list
      setState(() => _pendingTodos = snapshot ?? []);
    },
  ),
  builder: (context, state, mutate) {
    return FloatingActionButton(
      onPressed: state.isPending ? null : () => _showCreateDialog(mutate),
      child: state.isPending
          ? const CircularProgressIndicator(strokeWidth: 2)
          : const Icon(Icons.add),
    );
  },
)

When offline, offlineQueue: true causes the mutation to emit MutationSuccess(isOptimistic: true) immediately. The todo appears in the list with the pending style. On reconnect, the queue replays the mutation and onSuccess fires, which clears _pendingTodos and invalidates the cache to fetch the server-confirmed item.

Per-tile toggle and delete

Each tile wraps two independent QoraMutationBuilder instances. They are nested inside _MutableTodoTile, not in the list screen, so their state is scoped to the tile widget lifecycle:

lib/features/todos/ui/todo_list_screen.dart
class _MutableTodoTile extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return QoraMutationBuilder<Todo, ToggleTodoInput, void>(
      mutator: api.toggleTodo,
      options: MutationOptions(
        onSuccess: (_, __, ___) async =>
            context.qora.invalidate(['todos', auth.id]),
      ),
      builder: (context, toggleState, toggle) {
        return QoraMutationBuilder<void, int, void>(
          mutator: api.deleteTodo,
          options: MutationOptions(
            onSuccess: (_, __, ___) async =>
                context.qora.invalidate(['todos', auth.id]),
          ),
          builder: (context, deleteState, delete) {
            return TodoTile(
              todo: todo,
              onToggle: toggleState.isPending ? null
                  : () => toggle(ToggleTodoInput(id: todo.id, completed: !todo.completed)),
              onDelete: deleteState.isPending ? null
                  : () => delete(todo.id),
            );
          },
        );
      },
    );
  }
}

Toggling item A and deleting item B are completely independent operations. Each QoraMutationBuilder has its own MutationController lifecycle. A server error on item A does not affect item B's state.

Graceful degradation on infinite query failure

InfiniteFailure with previousData keeps the feed visible when a page load fails:

lib/features/todos/ui/todo_list_screen.dart
case InfiniteFailure(:final error, :final previousData):
  if (previousData != null) {
    // Show stale data + error banner at top
    return Column(
      children: [
        _ErrorBanner(error: error, onRetry: controller.fetchNextPage),
        Expanded(child: _buildList(previousData, controller)),
      ],
    );
  }
  // No data at all: full error screen with retry
  return _ErrorView(error: error, onRetry: controller.refetch);

SimulatedConnectivityManager

A custom ConnectivityManager that toggles between online and offline via an AppBar button. This removes any runtime dependency on connectivity_plus for the demo:

lib/core/connectivity/simulated_connectivity_manager.dart
class SimulatedConnectivityManager implements ConnectivityManager {
  final _controller = StreamController<NetworkStatus>.broadcast();
  NetworkStatus _status = NetworkStatus.online;

  @override
  Stream<NetworkStatus> get statusStream => _controller.stream;

  @override
  NetworkStatus get currentStatus => _status;

  void toggle() {
    _status = _status == NetworkStatus.online
        ? NetworkStatus.offline
        : NetworkStatus.online;
    _controller.add(_status);
  }

  @override
  void dispose() => _controller.close();
}

The AppBar button calls connectivity.toggle(). All active queries pause immediately (FetchStatus.paused), and any mutations fired while offline are queued. On toggle back to online, the queue replays automatically.


Validation Steps

  1. Log in with any user ID (1–10) → todos load from JSONPlaceholder
  2. Scroll to bottom → next page auto-loads via sentinel trigger
  3. Pull to refresh → list resets and re-fetches from page 1
  4. Add a todo → appears instantly in the list with pending style
  5. Toggle the wifi icon in the AppBar → offline banner appears; existing todos remain visible
  6. Add another todo while offline → appears with pending style; "queued" state on FAB
  7. Toggle wifi back → queued mutation replays; list refreshes with server-confirmed data
  8. Toggle a todo → checked/unchecked immediately; server sync in background
  9. Delete a todo → removed immediately; server confirms in background
  10. Force-kill and reopen the app → cached auth and todos restore on first frame (within stale window)

Source Code

examples/ex_07_complete_todo_app/
├── lib/
│   ├── main.dart
│   ├── core/
│   │   ├── auth/auth_service.dart
│   │   ├── connectivity/simulated_connectivity_manager.dart
│   │   └── persistence/
│   │       ├── hive_storage_adapter.dart
│   │       └── serializers.dart
│   ├── features/
│   │   ├── auth/ui/login_screen.dart
│   │   └── todos/
│   │       ├── data/todo_api.dart
│   │       ├── models/todo.dart
│   │       └── ui/
│   │           ├── todo_list_screen.dart
│   │           └── todo_tile.dart
│   └── shared/widgets/offline_banner.dart
└── pubspec.yaml

View full source code


Patterns at a glance

PatternFileQora API
Bootstrap + persistencemain.dartPersistQoraClient, QoraScope, .hydrate()
Auth-gated query keytodo_list_screen.dart['todos', auth.id] key
Infinite scroll sentineltodo_list_screen.dartInfiniteQueryBuilder, InfiniteQueryController
Pull-to-refreshtodo_list_screen.dartcontroller.refetch()
Optimistic create + offline queuetodo_list_screen.dartQoraMutationBuilder, offlineQueue, optimisticResponse, onMutate
Per-tile independent mutationstodo_list_screen.dartNested QoraMutationBuilder
Simulated offline togglesimulated_connectivity_manager.dartConnectivityManager interface
Offline banneroffline_banner.dartNetworkStatusIndicator

Copyright © 2026