Module 19: Capstone -- Building a Complete Task Manager
Teach: How to structure a real Flutter application with proper separation of concerns, from models to screens. See: A complete Task Manager app built layer by layer, with every file and connection explained. Feel: Ready to build production-quality Flutter apps independently.
This is it. The final boss. Everything you have learned over the past eighteen modules comes together right here: Dart classes, widgets, state management, navigation, theming, testing -- all of it. You are going to build a complete Task Manager application from scratch, and more importantly, you are going to build it the right way. Not everything crammed into one file. Not spaghetti code that works but makes you cringe when you look at it a week later. A properly structured app with clear layers, clean separation of concerns, and code you would be proud to show in a job interview.
Project Structure Best Practices
Teach: The standard folder structure for a production Flutter app and why each folder has a single responsibility. See: A complete lib/ directory layout with models, screens, widgets, services, and providers clearly separated. Feel: That organizing code into layers is not overhead -- it is the foundation that keeps complexity manageable.
Every serious Flutter project follows a standard folder structure inside lib/. Here is the layout you will use:
lib/
main.dart
models/ -- Data classes (Task, Category, User)
screens/ -- Full-page widgets (TaskListScreen, AddTaskScreen)
widgets/ -- Reusable UI components (TaskTile, PriorityBadge)
services/ -- Data access and business logic (TaskService)
providers/ -- State management classes (TaskProvider, ThemeProvider)
Why this structure? Because each folder has a single responsibility:
- models/ contains pure Dart classes with no Flutter imports. They define the shape of your data.
- services/ contains classes that manage data operations -- CRUD, filtering, sorting, API calls.
- providers/ contains ChangeNotifier classes that hold app state and notify widgets when it changes.
- widgets/ contains reusable UI building blocks that appear across multiple screens.
- screens/ contains full-page widgets that compose smaller widgets into complete views.
Think of it like building a house. Models are the blueprints -- they describe what a room looks like. Services are the plumbing and electrical -- they move things around behind the walls. Providers are the thermostat -- they track the current state and tell the house when something changes. Widgets are the furniture -- individual pieces you can move between rooms. Screens are the rooms themselves -- complete spaces assembled from furniture. You would not stuff the blueprints, the pipes, the thermostat, the couch, and the room layout into one giant pile. Same principle.
Layer 1: Models
Teach: How to define immutable data classes with copyWith, toMap/fromMap serialization, and equality overrides. See: A complete Task model and TaskCategory model with every method a real app needs. Feel: That models are simple, predictable, and completely independent of Flutter or UI code.
Models are the foundation. They define your data with no dependencies on Flutter, no UI code, no business logic. Just fields, constructors, and data manipulation methods.
// lib/models/task.dart
class Task {
final String id;
final String title;
final String description;
final String category;
final int priority; // 1 = High, 2 = Medium, 3 = Low
final bool isCompleted;
final DateTime dueDate;
final DateTime createdAt;
const Task({
required this.id,
required this.title,
required this.description,
required this.category,
required this.priority,
this.isCompleted = false,
required this.dueDate,
required this.createdAt,
});
Task copyWith({
String? title,
String? description,
String? category,
int? priority,
bool? isCompleted,
DateTime? dueDate,
}) {
return Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
category: category ?? this.category,
priority: priority ?? this.priority,
isCompleted: isCompleted ?? this.isCompleted,
dueDate: dueDate ?? this.dueDate,
createdAt: createdAt,
);
}
Task toggleCompleted() => copyWith(isCompleted: !isCompleted);
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'description': description,
'category': category,
'priority': priority,
'isCompleted': isCompleted,
'dueDate': dueDate.toIso8601String(),
'createdAt': createdAt.toIso8601String(),
};
}
factory Task.fromMap(Map<String, dynamic> map) {
return Task(
id: map['id'] as String,
title: map['title'] as String,
description: map['description'] as String,
category: map['category'] as String,
priority: map['priority'] as int,
isCompleted: map['isCompleted'] as bool,
dueDate: DateTime.parse(map['dueDate'] as String),
createdAt: DateTime.parse(map['createdAt'] as String),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is Task && id == other.id;
@override
int get hashCode => id.hashCode;
}
Teach: The model pattern -- immutable fields, copyWith for updates, toMap/fromMap for serialization, equality override. See: A complete Task model with every method a real app needs. Feel: That models are simple and predictable -- no surprises, no side effects.
Why copyWith Instead of Setters?
Notice that every field is final. You cannot change a Task -- you can only create a new one with different values. This is called immutability, and it prevents a huge category of bugs where some part of your code mutates an object that another part of your code is still using.
// Instead of: task.isCompleted = true; (BAD -- mutation)
// Do this:
final updatedTask = task.copyWith(isCompleted: true); // GOOD -- new object
The Category Model
Categories add structure to tasks. Each category has a name, icon, and color:
// lib/models/category.dart
import 'package:flutter/material.dart';
class TaskCategory {
final String name;
final IconData icon;
final Color color;
const TaskCategory({
required this.name,
required this.icon,
required this.color,
});
static const work = TaskCategory(
name: 'Work', icon: Icons.work, color: Colors.blue,
);
static const personal = TaskCategory(
name: 'Personal', icon: Icons.person, color: Colors.green,
);
static const shopping = TaskCategory(
name: 'Shopping', icon: Icons.shopping_cart, color: Colors.orange,
);
static const health = TaskCategory(
name: 'Health', icon: Icons.favorite, color: Colors.red,
);
static const List<TaskCategory> all = [work, personal, shopping, health];
static TaskCategory getCategory(String name) {
return all.firstWhere(
(c) => c.name == name,
orElse: () => work,
);
}
}
Look at what these model classes have, and what they don't. They have fields, a constructor, copyWith, and serialization methods. They don't have any Flutter imports. They don't know about widgets, providers, or databases. That purity is intentional. If you ever want to run this logic outside a Flutter app — say, in a command-line tool or on a server — these models come along unchanged. That portability only works if you keep your models pure. Every time you're tempted to import package:flutter/material.dart into a model file, stop and ask "can I pass this value in instead?" The answer is almost always yes.
Layer 2: Services
Teach: How the service layer encapsulates data operations (CRUD, filtering, sorting) behind a clean interface that the rest of the app calls. See: A TaskService with add, update, delete, and query methods backed by an in-memory list that could be swapped for a database later. Feel: That separating data operations from state management makes future changes painless.
The service layer handles data operations. It sits between your providers and your data source (in-memory list, database, API, whatever). The rest of the app does not care where the data lives -- it just calls the service.
// lib/services/task_service.dart
class TaskService {
final List<Task> _tasks = [
Task(
id: '1',
title: 'Review pull request',
description: 'Check the new authentication flow',
category: 'Work',
priority: 1,
dueDate: DateTime.now().add(const Duration(days: 1)),
createdAt: DateTime.now(),
),
Task(
id: '2',
title: 'Buy groceries',
description: 'Milk, eggs, bread, coffee',
category: 'Shopping',
priority: 2,
dueDate: DateTime.now().add(const Duration(days: 2)),
createdAt: DateTime.now(),
),
Task(
id: '3',
title: 'Morning run',
description: '5K around the park',
category: 'Health',
priority: 3,
isCompleted: true,
dueDate: DateTime.now(),
createdAt: DateTime.now().subtract(const Duration(days: 1)),
),
Task(
id: '4',
title: 'Read Flutter documentation',
description: 'Animations and custom painters',
category: 'Personal',
priority: 2,
dueDate: DateTime.now().add(const Duration(days: 3)),
createdAt: DateTime.now(),
),
Task(
id: '5',
title: 'Fix login bug',
description: 'Users getting logged out on app restart',
category: 'Work',
priority: 1,
dueDate: DateTime.now(),
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
];
List<Task> getAllTasks() => List.unmodifiable(_tasks);
void addTask(Task task) => _tasks.add(task);
void updateTask(Task updated) {
final index = _tasks.indexWhere((t) => t.id == updated.id);
if (index != -1) _tasks[index] = updated;
}
void deleteTask(String id) => _tasks.removeWhere((t) => t.id == id);
List<Task> getTasksByCategory(String category) =>
_tasks.where((t) => t.category == category).toList();
List<Task> getCompletedTasks() =>
_tasks.where((t) => t.isCompleted).toList();
List<Task> getPendingTasks() =>
_tasks.where((t) => !t.isCompleted).toList();
}
Why bother with a service layer when the provider could just manage the list directly? Because tomorrow you might swap the in-memory list for a SQLite database. Or an API. If the service layer handles data operations, you only change one file. The provider, the widgets, and the screens never know the difference. That is separation of concerns in action. Each layer knows about its own job and nothing else.
Why List.unmodifiable?
List<Task> getAllTasks() => List.unmodifiable(_tasks);
This returns a read-only view of the internal list. If someone tries to call .add() or .remove() on the returned list, they get an error. All modifications must go through the service's methods (addTask, deleteTask, etc.). This prevents bugs where some widget accidentally modifies the data list directly.
Layer 3: Providers
Teach: How ChangeNotifier providers wrap service methods, expose computed properties, and call notifyListeners after every mutation. See: A TaskProvider with filtering, completion percentage, category selection, and CRUD -- plus a ThemeProvider for light/dark toggling. Feel: That state management is straightforward when providers focus only on bridging services to the UI.
Providers bridge the service layer and the UI layer. They extend ChangeNotifier, call service methods, and notify listeners when state changes.
// lib/providers/task_provider.dart
import 'package:flutter/foundation.dart';
class TaskProvider extends ChangeNotifier {
final TaskService _service = TaskService();
String _selectedCategory = 'All';
List<Task> get tasks => _service.getAllTasks();
List<Task> get completedTasks => _service.getCompletedTasks();
List<Task> get pendingTasks => _service.getPendingTasks();
String get selectedCategory => _selectedCategory;
set selectedCategory(String category) {
_selectedCategory = category;
notifyListeners();
}
List<Task> get filteredTasks {
if (_selectedCategory == 'All') return tasks;
return _service.getTasksByCategory(_selectedCategory);
}
int get completionPercentage {
if (tasks.isEmpty) return 0;
return ((completedTasks.length / tasks.length) * 100).round();
}
void addTask(Task task) {
_service.addTask(task);
notifyListeners();
}
void toggleTask(String id) {
final task = tasks.firstWhere((t) => t.id == id);
_service.updateTask(task.toggleCompleted());
notifyListeners();
}
void deleteTask(String id) {
_service.deleteTask(id);
notifyListeners();
}
void updateTask(Task task) {
_service.updateTask(task);
notifyListeners();
}
}
Teach: The provider pattern -- wrap a service, expose getters for data, expose methods for mutations, call notifyListeners after every change. See: A complete TaskProvider with filtering, computed properties, and CRUD methods. Feel: That state management is straightforward when each layer has a clear job.
The Theme Provider
A second provider manages the app's visual theme:
// lib/providers/theme_provider.dart
import 'package:flutter/material.dart';
class ThemeProvider extends ChangeNotifier {
bool _isDarkMode = false;
bool get isDarkMode => _isDarkMode;
ThemeData get theme => _isDarkMode
? ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(secondary: Colors.teal),
)
: ThemeData(
primarySwatch: Colors.blue,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
);
void toggleTheme() {
_isDarkMode = !_isDarkMode;
notifyListeners();
}
}
Notice the two providers are independent. The task provider has no idea about themes. The theme provider has no idea about tasks. MultiProvider will register both at the top of the app, and widgets that care about one or the other — or both — will watch whichever they need. This is the scaling pattern. Every new concern in your app gets its own provider, and the providers stay focused. Imagine instead of two providers, you had one "AppProvider" that held tasks, theme, user, settings, and notifications. Every widget that watched it would rebuild for every change, and tracing bugs would be miserable. Small, focused providers are the right answer every time.
Layer 4: Custom Widgets
Teach: How to build focused, reusable widget components (PriorityBadge, CategoryChip, TaskTile) that compose into screens. See: Three custom widgets, each handling one visual responsibility, that can be used across any screen in the app. Feel: That small, single-purpose widgets are easier to build, test, and maintain than large monolithic ones.
Reusable widgets that appear across multiple screens:
// lib/widgets/priority_badge.dart
import 'package:flutter/material.dart';
class PriorityBadge extends StatelessWidget {
final int priority;
const PriorityBadge({super.key, required this.priority});
@override
Widget build(BuildContext context) {
final (color, label) = switch (priority) {
1 => (Colors.red, 'HIGH'),
2 => (Colors.orange, 'MED'),
_ => (Colors.green, 'LOW'),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
}
}
// lib/widgets/category_chip.dart
import 'package:flutter/material.dart';
class CategoryChip extends StatelessWidget {
final String label;
final bool isSelected;
final Color color;
final VoidCallback onTap;
const CategoryChip({
super.key,
required this.label,
required this.isSelected,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(
label,
style: TextStyle(
color: isSelected ? color : Colors.grey,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
selected: isSelected,
onSelected: (_) => onTap(),
selectedColor: color.withOpacity(0.2),
checkmarkColor: color,
),
);
}
}
// lib/widgets/task_tile.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class TaskTile extends StatelessWidget {
final Task task;
final VoidCallback? onTap;
const TaskTile({super.key, required this.task, this.onTap});
@override
Widget build(BuildContext context) {
final category = TaskCategory.getCategory(task.category);
return Dismissible(
key: Key(task.id),
direction: DismissDirection.endToStart,
onDismissed: (_) {
context.read<TaskProvider>().deleteTask(task.id);
},
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
child: ListTile(
leading: Checkbox(
value: task.isCompleted,
onChanged: (_) {
context.read<TaskProvider>().toggleTask(task.id);
},
),
title: Text(
task.title,
style: TextStyle(
decoration: task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
subtitle: Row(
children: [
Icon(category.icon, size: 14, color: category.color),
const SizedBox(width: 4),
Text(task.category),
],
),
trailing: PriorityBadge(priority: task.priority),
onTap: onTap,
),
);
}
}
Three custom widgets, each one focused on a single concern. PriorityBadge knows how to turn a priority number into a colored chip. CategoryChip knows how to render a category with its icon. TaskTile composes them into a full list row. You could test each one in isolation with a widget test. You could swap any of them for a different design and the others wouldn't care. That independence is what makes the app easy to change. When the designer says "let's make priority badges rounded instead of square," you edit one file and every screen that uses a PriorityBadge updates automatically.
Layer 5: Screens
Teach: How screens compose custom widgets, read from providers, and dispatch actions without knowing how data is stored or managed. See: A TaskListScreen with category filters, a progress bar, a scrollable task list, and FAB navigation -- all assembled from smaller pieces. Feel: That a well-structured screen is clean and readable because the complexity lives in the layers below it.
Screens compose widgets into complete pages:
// lib/screens/task_list_screen.dart
class TaskListScreen extends StatelessWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context) {
final taskProvider = context.watch<TaskProvider>();
final themeProvider = context.watch<ThemeProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager'),
actions: [
IconButton(
icon: Icon(themeProvider.isDarkMode
? Icons.light_mode
: Icons.dark_mode),
onPressed: () => themeProvider.toggleTheme(),
),
],
),
body: Column(
children: [
// Category filter chips
SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
CategoryChip(
label: 'All',
isSelected: taskProvider.selectedCategory == 'All',
color: Colors.blue,
onTap: () => taskProvider.selectedCategory = 'All',
),
...TaskCategory.all.map((cat) => CategoryChip(
label: cat.name,
isSelected:
taskProvider.selectedCategory == cat.name,
color: cat.color,
onTap: () =>
taskProvider.selectedCategory = cat.name,
)),
],
),
),
// Progress indicator
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
LinearProgressIndicator(
value: taskProvider.completionPercentage / 100,
),
const SizedBox(height: 4),
Text('${taskProvider.completionPercentage}% complete'),
],
),
),
// Task list
Expanded(
child: ListView.builder(
itemCount: taskProvider.filteredTasks.length,
itemBuilder: (context, index) {
final task = taskProvider.filteredTasks[index];
return TaskTile(
task: task,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => TaskDetailScreen(taskId: task.id),
),
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddTaskScreen()),
),
child: const Icon(Icons.add),
),
);
}
}
Look at how clean that screen is. It does not know how tasks are stored. It does not know the filtering logic. It does not manage the theme state. It just reads from providers, renders widgets, and dispatches actions. That is the payoff of proper architecture. Each layer is simple because it only does one thing. The complexity lives in the connections between layers, and those connections are clear and explicit.
Wiring It All Together: main.dart
Teach: How MultiProvider registers all providers at the widget tree root so every screen can access shared state. See: A main.dart that sets up TaskProvider and ThemeProvider in three lines, connecting the entire app architecture. Feel: That the wiring step is surprisingly simple when every layer has already been built with clear boundaries.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => TaskProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Task Manager',
theme: context.watch<ThemeProvider>().theme,
home: const TaskListScreen(),
debugShowCheckedModeBanner: false,
);
}
}
MultiProvider registers both providers at the root of the widget tree. Every widget below can access them with context.watch (rebuilds on changes) or context.read (one-time access for method calls).
This is the entire wiring step. Every piece of architecture we built — models, services, providers, custom widgets, screens — connects at this thirty-line main file. MultiProvider at the root, MaterialApp watching the theme provider, and the home screen as the entry point. That's it. You spent most of this capstone module building layers. The wiring is almost an afterthought because each layer has clean edges. If main.dart ever starts getting complicated, that's a signal you've put too much logic in the wrong place. Keep it this simple — always.
Where this fits: This capstone integrates every major concept from the course. Models (Module 3), widgets and layout (Modules 5-7), StatefulWidget (Module 8), lists (Module 9), forms (Module 10), navigation (Module 11), theming (Module 12), Provider (Module 13), and custom widgets (Module 15). If you can build this app, you can build any Flutter app.
The Add Task Screen
Teach: How to build a form screen with validation, dropdowns, choice chips, a date picker, and Provider integration for saving data. See: A complete AddTaskScreen that collects all task fields and writes to the TaskProvider on save. Feel: That forms, pickers, and state management come together naturally when each piece follows established patterns.
The form screen demonstrates validation, dropdowns, date pickers, and Provider integration:
// lib/screens/add_task_screen.dart
class AddTaskScreen extends StatefulWidget {
const AddTaskScreen({super.key});
@override
State<AddTaskScreen> createState() => _AddTaskScreenState();
}
class _AddTaskScreenState extends State<AddTaskScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
String _category = 'Work';
int _priority = 2;
DateTime _dueDate = DateTime.now().add(const Duration(days: 1));
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _save() {
if (_formKey.currentState!.validate()) {
final task = Task(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: _titleController.text,
description: _descriptionController.text,
category: _category,
priority: _priority,
dueDate: _dueDate,
createdAt: DateTime.now(),
);
context.read<TaskProvider>().addTask(task);
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add Task')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
validator: (value) =>
value == null || value.isEmpty ? 'Title is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _category,
decoration: const InputDecoration(labelText: 'Category'),
items: TaskCategory.all
.map((c) => DropdownMenuItem(
value: c.name,
child: Text(c.name),
))
.toList(),
onChanged: (value) => setState(() => _category = value!),
),
const SizedBox(height: 16),
Text('Priority', style: Theme.of(context).textTheme.titleSmall),
Row(
children: [1, 2, 3].map((p) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(['High', 'Medium', 'Low'][p - 1]),
selected: _priority == p,
onSelected: (_) => setState(() => _priority = p),
),
);
}).toList(),
),
const SizedBox(height: 16),
ListTile(
title: const Text('Due Date'),
subtitle: Text('${_dueDate.month}/${_dueDate.day}/${_dueDate.year}'),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _dueDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _dueDate = picked);
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _save,
child: const Text('Save Task'),
),
],
),
),
);
}
}
The add-task screen is the one place where you'll write the most lines of code in this capstone, and that's because forms are inherently verbose. TextEditingControllers, Form key, validators, choice chips for priority, category dropdown, date picker, save handler. But step back and notice what the screen actually does: it collects input, validates, constructs a Task, calls taskProvider.addTask(newTask), and pops. That's it. All the complexity is UI mechanics. The data flow is one line. That's what proper architecture buys you — even complex-looking screens have simple data flow.
Testing the Capstone
Teach: How to write tests for a properly structured app where each layer can be tested independently. See: Unit tests for the model and provider layers. Feel: That good architecture makes testing easy -- each layer is testable in isolation.
Unit Tests for the Task Model
// test/task_test.dart
import 'package:test/test.dart';
void main() {
group('Task', () {
late Task task;
setUp(() {
task = Task(
id: '1',
title: 'Test Task',
description: 'A test',
category: 'Work',
priority: 1,
dueDate: DateTime(2026, 4, 15),
createdAt: DateTime(2026, 4, 1),
);
});
test('has correct default values', () {
expect(task.isCompleted, isFalse);
});
test('copyWith preserves unchanged fields', () {
final updated = task.copyWith(title: 'New Title');
expect(updated.title, 'New Title');
expect(updated.description, task.description);
expect(updated.id, task.id);
});
test('toggleCompleted flips the boolean', () {
final toggled = task.toggleCompleted();
expect(toggled.isCompleted, isTrue);
expect(toggled.title, task.title);
});
test('toMap and fromMap round-trip correctly', () {
final map = task.toMap();
final restored = Task.fromMap(map);
expect(restored.id, task.id);
expect(restored.title, task.title);
expect(restored.isCompleted, task.isCompleted);
});
test('equality is based on id', () {
final same = Task(
id: '1', title: 'Different', description: '',
category: 'Work', priority: 2,
dueDate: DateTime.now(), createdAt: DateTime.now(),
);
final different = Task(
id: '2', title: 'Test Task', description: 'A test',
category: 'Work', priority: 1,
dueDate: DateTime.now(), createdAt: DateTime.now(),
);
expect(task, equals(same));
expect(task, isNot(equals(different)));
});
});
}
Unit Tests for TaskProvider
// test/task_provider_test.dart
import 'package:test/test.dart';
void main() {
group('TaskProvider', () {
late TaskProvider provider;
setUp(() {
provider = TaskProvider();
});
test('initial tasks are loaded', () {
expect(provider.tasks.isNotEmpty, isTrue);
});
test('addTask increases the task count', () {
final initialCount = provider.tasks.length;
provider.addTask(Task(
id: '99', title: 'New', description: '',
category: 'Work', priority: 1,
dueDate: DateTime.now(), createdAt: DateTime.now(),
));
expect(provider.tasks.length, initialCount + 1);
});
test('deleteTask decreases the task count', () {
final initialCount = provider.tasks.length;
final firstId = provider.tasks.first.id;
provider.deleteTask(firstId);
expect(provider.tasks.length, initialCount - 1);
});
test('toggleTask changes completion status', () {
final firstTask = provider.tasks.first;
final wasDone = firstTask.isCompleted;
provider.toggleTask(firstTask.id);
final updated = provider.tasks.firstWhere((t) => t.id == firstTask.id);
expect(updated.isCompleted, !wasDone);
});
});
}
These tests are short because the code they test is simple. That's a good sign. If a test requires forty lines of setup to verify one behavior, that behavior is probably too tangled. The task model test is essentially arithmetic — put values in, check values out. The provider test goes one layer further — exercise the service through the provider's public interface. Neither test needs a widget tree, neither needs async setup, neither is flaky. This is the reward for the architecture work. Clean layers produce clean tests.
There Are No Dumb Questions
Teach: Answers to common architecture questions about service layers, Provider vs. setState, testing priorities, and adding persistent storage. See: Practical guidance on the trade-offs developers face when structuring real Flutter applications. Feel: Clear on when to keep things simple and when the extra structure pays for itself.
Q: Do I always need a service layer? Can the provider just manage the list directly?
A: For a small app, the provider can manage the list directly. But as your app grows, the service layer pays for itself. It makes it easy to swap data sources (in-memory to SQLite to API), and it keeps your provider focused on state management rather than data operations.
Q: Why use Provider instead of just StatefulWidget with setState?
A: setState works when state is local to one widget. But when multiple screens need the same data -- like a task list that appears on the home screen and a task count that appears in the app bar -- Provider shares that state efficiently. Without Provider, you end up passing data through constructor chains, which gets messy fast.
Q: Should I test the UI or the logic?
A: Both, but prioritize logic. Unit tests for models and providers catch the most bugs with the least effort. Widget tests for complex interactive widgets catch UI regressions. You do not need to test every pixel -- focus on behavior.
Q: How would I add persistent storage to this app?
A: Replace the in-memory list in TaskService with SharedPreferences (for simple data) or sqflite (for structured data). The beauty of the service layer is that only task_service.dart changes. The provider, widgets, and screens stay exactly the same.
The persistence question is the best illustration of why we built this architecture. Swap the in-memory list for a SQLite database? Only task_service.dart changes. Add an API sync? Only the service changes. Add caching? Only the service. The provider keeps calling _service.getAllTasks() exactly like before, the widgets keep watching the provider, and nothing else in the app needs to know. Compare that to an app where widgets talk directly to the database — now every screen has to update, tests break, and a one-hour change turns into a multi-day rewrite. Architecture isn't overhead. It's leverage.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
Sharpen Your Pencil
Teach: How to build a complete, properly structured Flutter app from scratch by assembling every layer covered in this module. See: A 13-point checklist covering models, services, providers, widgets, screens, main.dart wiring, and unit tests. Feel: Ready to tackle a real application independently, with the confidence that comes from having built one end to end.
The Full Capstone Build
Build the complete Task Manager app following the structure in this module. Your submission should include:
- Project structure -- all folders and files as specified
- Task model -- with copyWith, toMap, fromMap, toggleCompleted, equality override
- TaskCategory model -- with static constants, all list, and getCategory method
- TaskService -- with CRUD operations and filtering methods, 5 sample tasks
- TaskProvider -- with filtered tasks, completion percentage, category selection, and all mutation methods
- ThemeProvider -- with light/dark toggle
- TaskTile -- with checkbox, strikethrough, category indicator, priority badge, swipe-to-delete
- CategoryChip and PriorityBadge -- reusable supporting widgets
- TaskListScreen -- with category filters, progress bar, filtered list, FAB navigation
- AddTaskScreen -- with validated form, dropdown, priority chips, date picker
- TaskDetailScreen -- with full task display, edit mode, toggle, and delete
- main.dart -- with MultiProvider and ThemeProvider-driven theme
- Unit tests -- at least 5 for Task model and 4 for TaskProvider
This is a real app. When you finish, you will have built something that demonstrates every fundamental skill a Flutter developer needs.
This is the whole course distilled into one project. Twenty modules led to this. When you sit down to build it, do not try to write the whole thing in one evening. Build the models first, test them, commit. Build the service, test it, commit. Build the providers, test them, commit. Then the widgets, then the screens. Each step should take an hour or two and leave you with working, tested code. By the end, you'll have a polished task manager that looks and behaves like a real shipped app — and a portfolio piece you'll want to show in an interview. This is what "learning Flutter" actually means. Everything before was preparation. This is the main event.
Architecture is not about making simple things complicated. It is about making complicated things manageable. Five files with 50 lines each are always easier to understand than one file with 250 lines.