Case Study: Complete Todo App
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 frameAuthService(ValueNotifier): user session state independent of Qora; gates which todo list is fetchedInfiniteQueryBuilder<TodosPage, int>: cursor-based pagination with a sentinel-item scroll triggerInfiniteQueryController.refetch(): pull-to-refresh that resets pagination and re-fetches from page 1- Optimistic create with offline queue:
QoraMutationBuilderwithofflineQueue: true,optimisticResponse, and a_pendingTodoslocal list for instant UI feedback - Per-tile toggle and delete mutations: independent
QoraMutationBuilderper tile; toggling one item never blocks another SimulatedConnectivityManager: customConnectivityManagerimplementation for self-contained offline demo via an AppBar toggleNetworkMode.offlineFirst: stale cache served instantly on reconnect; background revalidation triggered automaticallyNetworkStatusIndicator: 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
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:
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:
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:
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:
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:
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
- Log in with any user ID (1–10) → todos load from JSONPlaceholder
- Scroll to bottom → next page auto-loads via sentinel trigger
- Pull to refresh → list resets and re-fetches from page 1
- Add a todo → appears instantly in the list with pending style
- Toggle the wifi icon in the AppBar → offline banner appears; existing todos remain visible
- Add another todo while offline → appears with pending style; "queued" state on FAB
- Toggle wifi back → queued mutation replays; list refreshes with server-confirmed data
- Toggle a todo → checked/unchecked immediately; server sync in background
- Delete a todo → removed immediately; server confirms in background
- 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
Patterns at a glance
| Pattern | File | Qora API |
|---|---|---|
| Bootstrap + persistence | main.dart | PersistQoraClient, QoraScope, .hydrate() |
| Auth-gated query key | todo_list_screen.dart | ['todos', auth.id] key |
| Infinite scroll sentinel | todo_list_screen.dart | InfiniteQueryBuilder, InfiniteQueryController |
| Pull-to-refresh | todo_list_screen.dart | controller.refetch() |
| Optimistic create + offline queue | todo_list_screen.dart | QoraMutationBuilder, offlineQueue, optimisticResponse, onMutate |
| Per-tile independent mutations | todo_list_screen.dart | Nested QoraMutationBuilder |
| Simulated offline toggle | simulated_connectivity_manager.dart | ConnectivityManager interface |
| Offline banner | offline_banner.dart | NetworkStatusIndicator |
Related
- Offline-First Case Study:
PersistQoraClientdeep-dive - Infinite Scroll Case Study:
InfiniteQueryBuilderpatterns - Optimistic Mutations Case Study: rollback protocol
- Network Awareness Case Study:
NetworkMode,ReconnectStrategy