Volume 10: Production Capstone
Volume 10: Production Capstone
Learning Objectives
- Synthesize all previous volumes into a single, cohesive architecture.
- Design, build, and deploy a production-ready application from scratch.
- Understand the difference between a prototype and a product.
Why Does This Exist?
Knowledge is useless if it exists in a vacuum. If you know how to build a button, fetch an API, and manage state, but you don't know how to weave them together into a scalable system, you will fail when you attempt to build a real product. The capstone project forces you to confront the friction of integration.
The Problem Before the Solution
Most developers suffer from "Tutorial Hell." They can follow instructions perfectly, but when faced with a blank IDE screen, they freeze. They try to build the database, the UI, the animations, and the networking all at the exact same time. The codebase becomes a tangled mess, and they abandon the project after two weeks.
Mental Model: Progressive Enhancement
Building software is like painting a masterpiece. You don't start at the top left corner and perfectly paint every pixel until you reach the bottom right.
You sketch the outline (Prototype). You block in the basic colors (Functional App). You refine the lighting (Architecture & Data). You add the fine details (Animations & Polish). You varnish the canvas to protect it (Security & Tests).
The 10-Stage Development Methodology
- Stage 1: Prototype (The Outline)
Use hardcoded dummy data. Build the basic UI screens. Ensure the navigation flow works. If the idea is bad, kill it here before wasting time on a database. - Stage 2: Functional Application (The Colors)
Replace dummy data with local state. Make the buttons actually do something. - Stage 3: Proper Architecture (The Structure)
Rip out the messy state. Implement your Riverpod/BLoC architecture. Separate the Presentation, Domain, and Data layers. - Stage 4: Backend Integration (The Engine)
Connect to Firebase, Supabase, or your REST API. Replace local state with real network data. - Stage 5: Persistence (The Memory)
Cache network data into a local SQLite database or SharedPreferences so the app works offline. - Stage 6: Testing (The Safety Net)
Write Unit tests for the Domain layer. Write Widget tests for the complex screens. - Stage 7: Performance (The Polish)
Open DevTools. Profile the app. Fix janky animations. Paginate long lists. Optimize massive images. - Stage 8: Security (The Vault)
Obfuscate code. Hide API keys. Secure local storage for auth tokens. - Stage 9: CI/CD (The Factory)
Set up GitHub Actions to run your tests automatically on every commit. - Stage 10: Production Release (The Ship)
Sign the app, create the store listings, and hit publish.
Engineering Challenge: The Capstone Application
Your Task: Build a complete "Task & Habit Tracker" that syncs to a backend.
- Authentication: Users must be able to log in securely.
- Offline-First: If the user is on an airplane, they must be able to check off tasks. When they get Wi-Fi, the app must silently sync with the backend.
- State Management: Use Riverpod. The UI must react instantly when a task is checked off.
- Performance: The habit history graph must render at 60/120 FPS even with 10,000 data points.
- Testing: The synchronization logic must be covered by unit tests.
Final Thoughts
If you have reached this point, you are no longer a beginner. You understand how Flutter paints pixels, how Dart manages memory, how to secure an application, and how to automate its delivery to millions of users.
You do not just write code anymore. You engineer software.
Now, open your editor, and start building.
🔧 Mini Project ⏱ 20 min
Goal: The Architecture Decision Record
Before writing a single line of code for the Capstone app (Task & Habit Tracker), create an ADR.md file documenting your architecture decisions. Using the ADR format, document 3 decisions: (1) State Management choice (Riverpod vs BLoC vs Provider), (2) Backend choice (Firebase vs Supabase vs custom REST), (3) Local persistence choice (SQLite/Drift vs Hive vs SharedPreferences). For each, write: Context, Options Considered, Decision, Consequences (positive and negative).
💡 See One Approach (Mini Project)
One valid solution — yours may differ.
# Architecture Decision Records — Task & Habit Tracker
## ADR-001: State Management
**Date:** 2026-08-08 **Status:** Accepted
**Context:** The app has complex async state (network + local DB), needs dependency injection, and will be maintained long-term.
**Options:**
- Provider: Simple, official, but lacks code gen and async patterns
- BLoC: Battle-tested, verbose, steep learning curve
- Riverpod 2.x: Modern, code generation, excellent async support, testable
**Decision:** Riverpod 2.x with code generation (@riverpod annotation)
**Consequences:**
+ Excellent separation of concerns
+ Compile-time safety with code gen
+ Easy to test with provider overrides
- Requires `build_runner` in workflow
- Team must learn Riverpod-specific patterns
---
## ADR-002: Backend / Persistence
**Date:** 2026-08-08 **Status:** Accepted
**Context:** Need auth, real-time sync, and offline-first capability.
**Options:**
- Firebase: Excellent offline support, real-time, proprietary lock-in
- Supabase: Open source, PostgreSQL, REST + realtime
- Custom REST: Full control, requires backend team
**Decision:** Supabase for backend + Drift (SQLite) for local persistence
**Consequences:**
+ Open source, can self-host
+ Drift provides type-safe SQL with code gen
+ Offline-first: write to Drift, sync to Supabase on connectivity
- Two data layers to maintain
- Conflict resolution needed for sync
---
## ADR-003: Local Persistence
**Decision:** Drift (SQLite wrapper)
**Why not Hive:** Hive is a key-value store — no relational queries. Can't efficiently query "all habits due today".
**Why not SharedPreferences:** Only for simple key-value settings (theme, onboarding status).
**Why Drift:** Type-safe, reactive (returns Streams), supports complex SQL queries, integrates with Riverpod's AsyncNotifier.
🏗 Bigger Project ⏱ 1.5 hrs
Goal: The Capstone Stage 1 Prototype
Build Stage 1 of the Task & Habit Tracker. Use ONLY hardcoded dummy data (no backend yet). Implement all screens with navigation: HomeScreen (list of today's tasks with checkboxes), HabitScreen (habit grid with streak counter), AddTaskBottomSheet, TaskDetailScreen. Use Riverpod with simple StateNotifier (no async yet). Make every UI interaction work with the fake data. The architecture must be clean enough that swapping fake data for real backend in Stage 2 requires ONLY changing the repository layer.
💡 See One Approach (Bigger Project)
One valid solution — yours may differ.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ─── Domain Models ─────────────────────────────────────────────
class Task {
final String id, title, description;
final bool completed;
final DateTime dueDate;
Task({required this.id, required this.title, this.description = '', this.completed = false, required this.dueDate});
Task copyWith({bool? completed}) => Task(id: id, title: title, description: description, completed: completed ?? this.completed, dueDate: dueDate);
}
class Habit {
final String id, name, emoji;
final List completedDates;
Habit({required this.id, required this.name, required this.emoji, this.completedDates = const []});
int get streak {
int s = 0;
var d = DateTime.now();
for (int i = 0; i < 365; i++) {
final check = DateTime(d.year, d.month, d.day - i);
if (completedDates.any((c) => c.year == check.year && c.month == check.month && c.day == check.day)) s++;
else break;
}
return s;
}
}
// ─── Fake Repository (swap for real in Stage 4) ────────────────
class TaskRepository {
final List _tasks = [
Task(id: '1', title: 'Review pull request', description: 'Review Tarun\'s PR #42', dueDate: DateTime.now()),
Task(id: '2', title: 'Write unit tests', description: 'Cover the auth service', completed: true, dueDate: DateTime.now()),
Task(id: '3', title: 'Deploy to staging', dueDate: DateTime.now()),
];
List getToday() => _tasks.where((t) => t.dueDate.day == DateTime.now().day).toList();
void toggle(String id) {
final i = _tasks.indexWhere((t) => t.id == id);
if (i != -1) _tasks[i] = _tasks[i].copyWith(completed: !_tasks[i].completed);
}
void add(String title) => _tasks.add(Task(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title, dueDate: DateTime.now()));
}
// ─── Riverpod Providers ────────────────────────────────────────
final taskRepoProvider = Provider((_) => TaskRepository());
final tasksProvider = StateNotifierProvider<_TasksNotifier, List>((ref) => _TasksNotifier(ref.read(taskRepoProvider)));
class _TasksNotifier extends StateNotifier> {
final TaskRepository _repo;
_TasksNotifier(this._repo) : super([]) { _load(); }
void _load() => state = _repo.getToday();
void toggle(String id) { _repo.toggle(id); _load(); }
void add(String title) { _repo.add(title); _load(); }
}
// ─── Main App ──────────────────────────────────────────────────
void main() => runApp(const ProviderScope(child: MaterialApp(home: HomeScreen())));
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(tasksProvider);
final done = tasks.where((t) => t.completed).length;
return Scaffold(
appBar: AppBar(
title: const Text('Today'),
subtitle: Text('$done/${tasks.length} complete'),
),
body: ListView.builder(
itemCount: tasks.length,
itemBuilder: (_, i) => ListTile(
leading: Checkbox(value: tasks[i].completed, onChanged: (_) => ref.read(tasksProvider.notifier).toggle(tasks[i].id)),
title: Text(tasks[i].title, style: tasks[i].completed ? const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey) : null),
subtitle: tasks[i].description.isNotEmpty ? Text(tasks[i].description) : null,
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet(
context: context,
builder: (_) => AddTaskSheet(onAdd: (title) => ref.read(tasksProvider.notifier).add(title)),
),
child: const Icon(Icons.add),
),
);
}
}
class AddTaskSheet extends StatelessWidget {
final void Function(String) onAdd;
const AddTaskSheet({super.key, required this.onAdd});
@override
Widget build(BuildContext context) {
final ctrl = TextEditingController();
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 24, right: 24, top: 24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: ctrl, autofocus: true, decoration: const InputDecoration(labelText: 'New task', border: OutlineInputBorder())),
const SizedBox(height: 16),
FilledButton(onPressed: () { onAdd(ctrl.text); Navigator.pop(context); }, child: const Text('Add Task')),
const SizedBox(height: 16),
]),
);
}
}
🎯 Interview Questions
Answer these before revealing. These appear in real Flutter/Dart interviews.
🔍 Easy: What is the purpose of an Architecture Decision Record (ADR) and when should you write one?
An ADR documents a significant architectural decision: what was decided, why that option was chosen over alternatives, and what the trade-offs are. Write one whenever: you're choosing a major library or framework, you're making a decision that's hard to reverse, or you're making a trade-off that future developers will question. ADRs serve as institutional memory — 6 months later when a new developer asks "why are we using Riverpod instead of Provider?", the ADR explains the reasoning without needing to find the original decision-maker.
🔍 Medium: Explain the 10-stage development methodology. Why is Stage 1 (hardcoded dummy data) so important?
Stage 1 forces you to validate the UX before investing in backend work. If the UI flow is confusing with hardcoded data, it will be 10× worse with real async data, loading states, and errors. It also lets designers and stakeholders review the app immediately. The clean architecture constraint (repositories as the only data source) means Stage 1 code is already structured for Stage 4 backend integration — only the repository implementation changes, not the UI or business logic. Most tutorial developers skip Stage 1 and go straight to the database, then spend 3 weeks fighting data model and UI simultaneously.
🔍 Hard: Design the offline-first sync architecture for the Task & Habit Tracker. How do you handle a conflict where the user checked off a task on their phone while offline, but the task was deleted on the web dashboard?
Architecture: every record has id (UUID v4, generated client-side), updated_at (timestamp), deleted_at (nullable — soft delete, never hard delete), and sync_status (pending/synced). Sync flow: 1. User action → write to Drift immediately, set sync_status=pending, update UI (instant). 2. On network available → fetch server changes since last sync (use updated_at > last_sync_time). 3. Push local pending records to server. Conflict resolution for your scenario (locally completed, server deleted): use server-wins for deletions — if server has deleted_at set, the record is gone. Before marking deleted locally, show a brief notification: "Task 'Deploy to staging' was removed from another device." CRDT alternative: use a boolean CRDT where completed=true is an irreversible state — once true anywhere, it stays true. This prevents the conflict entirely but means tasks can never be un-deleted. The right choice depends on product requirements.