Goal and App Scope
In this chapter you will assemble a small but complete cross-platform mini app: a simple task tracker. It includes two main screens (list and detail), an add/edit form, shared state across screens, and a lightweight persistence layer. You will build it end-to-end with checkpoints so you can run and verify each feature before moving on.
What you will build
- Task List Screen: shows all tasks, supports toggling done, and navigating to details.
- Task Detail Screen: shows full task info and allows editing.
- Add/Edit Task Screen: a form used for both creating and updating tasks.
- Shared state: a single source of truth for tasks used by all screens.
- Persistence: start with in-memory; optionally add local storage using a repository interface.
Step 1 — Define the Data Model and App State
Start by defining a task model and a store (state holder) that exposes operations the UI needs. Keep the model small and focused: id, title, optional notes, done flag, and timestamps.
Create the model
class Task { final String id; final String title; final String? notes; final bool isDone; final DateTime createdAt; final DateTime? updatedAt; const Task({ required this.id, required this.title, this.notes, this.isDone = false, required this.createdAt, this.updatedAt, }); Task copyWith({ String? title, String? notes, bool? isDone, DateTime? updatedAt, }) { return Task( id: id, title: title ?? this.title, notes: notes ?? this.notes, isDone: isDone ?? this.isDone, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, ); }}Create a store (shared state) with a repository dependency
The store owns the list of tasks and provides methods for CRUD operations. It also coordinates loading/saving via a repository. This keeps UI code clean and makes persistence optional.
abstract class TaskRepository { Future<List<Task>> loadTasks(); Future<void> saveTasks(List<Task> tasks);}class InMemoryTaskRepository implements TaskRepository { List<Task> _cache = []; @override Future<List<Task>> loadTasks() async { return _cache; } @override Future<void> saveTasks(List<Task> tasks) async { _cache = List.of(tasks); }}class TaskStore { TaskStore(this._repo); final TaskRepository _repo; final List<Task> _tasks = []; bool _loaded = false; List<Task> get tasks => List.unmodifiable(_tasks); Future<void> ensureLoaded() async { if (_loaded) return; final loaded = await _repo.loadTasks(); _tasks ..clear() ..addAll(loaded); _loaded = true; } Task? byId(String id) { try { return _tasks.firstWhere((t) => t.id == id); } catch (_) { return null; } } Future<void> add(Task task) async { _tasks.add(task); await _repo.saveTasks(_tasks); } Future<void> update(Task updated) async { final index = _tasks.indexWhere((t) => t.id == updated.id); if (index == -1) return; _tasks[index] = updated; await _repo.saveTasks(_tasks); } Future<void> remove(String id) async { _tasks.removeWhere((t) => t.id == id); await _repo.saveTasks(_tasks); } Future<void> toggleDone(String id) async { final task = byId(id); if (task == null) return; await update(task.copyWith(isDone: !task.isDone, updatedAt: DateTime.now())); }}Checkpoint 1
- You have a
Taskmodel. - You have a
TaskStorewith async load/save hooks. - You can keep persistence in-memory for now and swap later.
Step 2 — Wire the Store into the App (Single Source of Truth)
The key idea: every screen reads tasks from the same store instance. You can provide the store using your preferred approach (for example, a Provider-style pattern). The important part is that navigation does not create new stores.
Provide the store at the top
final taskStore = TaskStore(InMemoryTaskRepository());void main() { runApp(AppRoot(store: taskStore));}class AppRoot extends StatelessWidget { const AppRoot({super.key, required this.store}); final TaskStore store; @override Widget build(BuildContext context) { return StoreScope( store: store, child: const MyApp(), ); }}StoreScope is a placeholder for your chosen dependency injection mechanism. The rest of this chapter will assume you can access the store from any screen via something like StoreScope.of(context).
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
class StoreScope extends InheritedWidget { const StoreScope({super.key, required this.store, required super.child}); final TaskStore store; static TaskStore of(BuildContext context) { final scope = context.dependOnInheritedWidgetOfExactType<StoreScope>(); if (scope == null) throw Exception('StoreScope not found'); return scope.store; } @override bool updateShouldNotify(StoreScope oldWidget) => store != oldWidget.store;}Checkpoint 2
- The app has exactly one
TaskStoreinstance. - Any screen can access it through the widget tree.
Step 3 — Build the Task List Screen (List + Navigation + Toggle)
The list screen is the home screen. It loads tasks once (async), displays them, and supports: tap to open details, checkbox to toggle done, and a button to add a new task.
List screen skeleton
class TaskListScreen extends StatefulWidget { const TaskListScreen({super.key}); @override State<TaskListScreen> createState() => _TaskListScreenState();}class _TaskListScreenState extends State<TaskListScreen> { bool _loading = true; @override void initState() { super.initState(); _load(); } Future<void> _load() async { final store = StoreScope.of(context); await store.ensureLoaded(); if (!mounted) return; setState(() => _loading = false); } @override Widget build(BuildContext context) { final store = StoreScope.of(context); final tasks = store.tasks; return Scaffold( appBar: AppBar(title: const Text('Tasks')), floatingActionButton: FloatingActionButton( onPressed: () async { final created = await Navigator.push<bool>( context, MaterialPageRoute(builder: (_) => const TaskEditScreen()), ); if (created == true && mounted) setState(() {}); }, child: const Icon(Icons.add), ), body: _loading ? const Center(child: CircularProgressIndicator()) : tasks.isEmpty ? const Center(child: Text('No tasks yet')) : ListView.separated( itemCount: tasks.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final task = tasks[index]; return ListTile( title: Text( task.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), leading: Checkbox( value: task.isDone, onChanged: (_) async { await store.toggleDone(task.id); if (mounted) setState(() {}); }, ), trailing: const Icon(Icons.chevron_right), onTap: () async { await Navigator.push( context, MaterialPageRoute( builder: (_) => TaskDetailScreen(taskId: task.id), ), ); if (mounted) setState(() {}); }, ); }, ), ); }}Notice the pattern: after returning from another screen, the list calls setState to refresh. Because the store is shared, the list can simply re-read store.tasks.
Checkpoint 3
- Home screen loads tasks asynchronously once.
- Tasks render in a scrollable list.
- Toggling a checkbox updates the store and refreshes UI.
- FAB navigates to an add screen.
Step 4 — Build the Task Detail Screen (Read + Actions)
The detail screen reads the task by id from the store. It offers edit and delete actions. Because the store is the source of truth, you do not pass the whole task object through navigation; you pass an id and re-fetch from the store.
class TaskDetailScreen extends StatelessWidget { const TaskDetailScreen({super.key, required this.taskId}); final String taskId; @override Widget build(BuildContext context) { final store = StoreScope.of(context); final task = store.byId(taskId); if (task == null) { return Scaffold( appBar: AppBar(title: const Text('Task')), body: const Center(child: Text('Task not found')), ); } return Scaffold( appBar: AppBar( title: const Text('Task Details'), actions: [ IconButton( tooltip: 'Edit task', icon: const Icon(Icons.edit), onPressed: () async { final updated = await Navigator.push<bool>( context, MaterialPageRoute( builder: (_) => TaskEditScreen(existingTaskId: task.id), ), ); if (updated == true && context.mounted) { Navigator.pop(context); } }, ), IconButton( tooltip: 'Delete task', icon: const Icon(Icons.delete), onPressed: () async { await store.remove(task.id); if (context.mounted) Navigator.pop(context); }, ), ], ), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Checkbox( value: task.isDone, onChanged: (_) async { await store.toggleDone(task.id); if (context.mounted) { Navigator.pop(context); } }, ), Expanded( child: Text( task.title, style: Theme.of(context).textTheme.titleLarge, ), ), ], ), const SizedBox(height: 12), Text( task.notes?.isNotEmpty == true ? task.notes! : 'No notes', style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 12), Text('Created: ${task.createdAt}'), if (task.updatedAt != null) Text('Updated: ${task.updatedAt}'), ], ), ), ); }}Checkpoint 4
- Detail screen loads task data from the store using an id.
- Edit navigates to the same form screen used for creation.
- Delete removes from store and returns to list.
Step 5 — Build a Reusable Add/Edit Form Screen
Use one screen for both creating and editing. If an existing id is provided, prefill the form. On save, call store.add or store.update. Return a boolean to indicate whether a change occurred so previous screens can refresh.
Form screen
class TaskEditScreen extends StatefulWidget { const TaskEditScreen({super.key, this.existingTaskId}); final String? existingTaskId; @override State<TaskEditScreen> createState() => _TaskEditScreenState();}class _TaskEditScreenState extends State<TaskEditScreen> { final _formKey = GlobalKey<FormState>(); final _titleController = TextEditingController(); final _notesController = TextEditingController(); bool _saving = false; Task? _existing; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final store = StoreScope.of(context); if (widget.existingTaskId != null) { _existing = store.byId(widget.existingTaskId!); if (_existing != null) { _titleController.text = _existing!.title; _notesController.text = _existing!.notes ?? ''; setState(() {}); } } }); } @override void dispose() { _titleController.dispose(); _notesController.dispose(); super.dispose(); } Future<void> _save() async { final store = StoreScope.of(context); final valid = _formKey.currentState?.validate() ?? false; if (!valid) return; setState(() => _saving = true); final now = DateTime.now(); if (_existing == null) { final task = Task( id: now.microsecondsSinceEpoch.toString(), title: _titleController.text.trim(), notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), createdAt: now, ); await store.add(task); } else { final updated = _existing!.copyWith( title: _titleController.text.trim(), notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), updatedAt: now, ); await store.update(updated); } if (!mounted) return; setState(() => _saving = false); Navigator.pop(context, true); } @override Widget build(BuildContext context) { final isEdit = widget.existingTaskId != null; return Scaffold( appBar: AppBar(title: Text(isEdit ? 'Edit Task' : 'New Task')), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( children: [ TextFormField( controller: _titleController, decoration: const InputDecoration( labelText: 'Title', hintText: 'e.g., Buy groceries', ), textInputAction: TextInputAction.next, validator: (value) { final v = value?.trim() ?? ''; if (v.isEmpty) return 'Title is required'; if (v.length < 3) return 'Title is too short'; return null; }, ), const SizedBox(height: 12), TextFormField( controller: _notesController, decoration: const InputDecoration( labelText: 'Notes (optional)', hintText: 'Add details...', ), minLines: 3, maxLines: 5, ), const Spacer(), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ), ], ), ), ), ), ); }}Checkpoint 5
- You can create a task and see it in the list.
- You can edit a task from the detail screen.
- The form validates input and disables saving while async work runs.
Step 6 — Add Simple Persistence (Optional Upgrade)
You already have a repository interface. To add persistence, implement TaskRepository using local storage. The UI and store do not change; only the repository changes. This is the main benefit of the repository boundary.
Repository outline for local storage
This outline shows the shape without tying you to a specific package. You need: (1) serialize tasks to a JSON string, (2) store and retrieve that string, (3) parse it back into tasks.
Map<String, dynamic> taskToJson(Task t) => { 'id': t.id, 'title': t.title, 'notes': t.notes, 'isDone': t.isDone, 'createdAt': t.createdAt.toIso8601String(), 'updatedAt': t.updatedAt?.toIso8601String(),};Task taskFromJson(Map<String, dynamic> json) => Task( id: json['id'] as String, title: json['title'] as String, notes: json['notes'] as String?, isDone: json['isDone'] as bool? ?? false, createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: json['updatedAt'] == null ? null : DateTime.parse(json['updatedAt'] as String),);When you implement the local repository, keep it responsible only for reading/writing strings and converting to/from Task. The store remains responsible for app behavior (add/update/toggle/remove).
Checkpoint 6
- You can switch from in-memory to local storage by changing one line where the repository is created.
- Tasks survive app restarts (if you implemented local storage).
Architecture Review: How the Pieces Fit
Widgets and responsibilities
- TaskListScreen: reads tasks, renders list, triggers navigation, triggers toggle.
- TaskDetailScreen: reads one task by id, offers edit/delete actions.
- TaskEditScreen: owns form controllers and validation; calls store methods.
- TaskStore: single source of truth; exposes operations; coordinates persistence.
- TaskRepository: persistence boundary; can be in-memory or local storage.
State flow
- User interacts with UI (tap, checkbox, save).
- UI calls a store method (async).
- Store updates its internal list and persists via repository.
- UI refreshes by re-reading store state (e.g., via
setStateafter returning from navigation, or after an awaited store call).
Navigation flow
- List → Detail: pass
taskId. - List → New Task: no id.
- Detail → Edit: pass
existingTaskId. - Form returns
truewhen it saved, allowing previous screens to refresh.
Async flow and safety
- Load tasks once on list screen startup using an async method.
- Await store operations before popping screens so you do not lose updates.
- Check
mountedbefore callingsetStateafter awaits.
Quality Pass: UI Consistency, Accessibility, and Cross-Platform Readiness
UI consistency checklist
- Use the same button style for primary actions (e.g.,
FilledButtonfor Save). - Keep spacing consistent (e.g., 12–16 px padding and gaps).
- Use the same empty states and loading indicators across screens.
Basic accessibility checklist
- Text scaling: avoid fixed-height text containers; let text wrap and use theme text styles so it scales with system settings.
- Tap targets: prefer standard widgets like
ListTile,IconButton, andCheckboxwhich meet minimum sizes; avoid tiny custom gesture areas. - Tooltips and semantics: add
tooltipto icon buttons (edit/delete) and use clear labels like “New Task”, “Save”. - Contrast: ensure done/undone states remain readable; if you style completed tasks (e.g., strikethrough), keep sufficient contrast.
Prepare to run on Android and iOS
- Test navigation gestures: Android back button and iOS back swipe should both work with your navigation stack.
- Use
SafeAreaon form screens to avoid notches and system UI overlap. - Verify platform text scaling and dynamic type: increase system font size and confirm the list and form remain usable.
- Check input behavior: keyboard “Next/Done” actions should move through fields and submit cleanly.