Build a complete Todo App with API integration, then deploy it to Android, iOS, and Web.
We will build a Todo App with API that demonstrates all the Flutter skills from the previous lessons in one cohesive project.
| Feature | Technique Used |
|---|---|
| Fetch todos from API | http package + FutureBuilder |
| Display todo list | ListView.builder + ListTile |
| Add new todo | Form + TextFormField + Navigator |
| Toggle done/undone | Checkbox + PATCH request |
| Delete todo | Dismissible + DELETE request |
| Persist auth token | shared_preferences |
| Shared state | Provider + ChangeNotifier |
| Navigation | Navigator.push / pushNamed |
API: JSONPlaceholder (https://jsonplaceholder.typicode.com/todos) — free mock REST API, perfect for learning.
todo_app/
lib/
main.dart -- App entry point, routing, Provider setup
models/
todo.dart -- Todo data model (fromJson / toJson)
services/
api_service.dart -- All HTTP calls (fetchTodos, createTodo, etc.)
prefs_service.dart -- shared_preferences helpers
providers/
todo_provider.dart -- ChangeNotifier holding the todo list
screens/
home_screen.dart -- Main list screen
add_todo_screen.dart -- Form to create a new todo
detail_screen.dart -- View/edit a single todo
widgets/
todo_tile.dart -- Single todo list item widget
empty_state.dart -- Widget shown when list is empty
loading_indicator.dart -- Centered CircularProgressIndicator
pubspec.yaml
# pubspec.yaml
name: todo_app
description: Flutter Todo App with API
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.2.1
provider: ^6.1.2
shared_preferences: ^2.3.1
flutter:
uses-material-design: true
// lib/models/todo.dart
class Todo {
final int id;
final int userId;
final String title;
final bool completed;
const Todo({
required this.id,
required this.userId,
required this.title,
required this.completed,
});
// Parse from JSON (API response)
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
id: json['id'] as int,
userId: json['userId'] as int,
title: json['title'] as String,
completed: json['completed'] as bool,
);
}
// Serialize to JSON (for POST/PUT)
Map<String, dynamic> toJson() => {
'id': id,
'userId': userId,
'title': title,
'completed': completed,
};
// copyWith — create a modified copy without mutating the original
Todo copyWith({
int? id,
int? userId,
String? title,
bool? completed,
}) {
return Todo(
id: id ?? this.id,
userId: userId ?? this.userId,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
@override
String toString() => 'Todo($id, "$title", done:$completed)';
@override
bool operator ==(Object other) => other is Todo && other.id == id;
@override
int get hashCode => id.hashCode;
}
// lib/services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/todo.dart';
class ApiService {
static const _base = 'https://jsonplaceholder.typicode.com';
static const _headers = {'Content-Type': 'application/json'};
// ---- Fetch all todos (paginated) ----
static Future<List<Todo>> fetchTodos({int limit = 20, int start = 0}) async {
final uri = Uri.parse('$_base/todos').replace(
queryParameters: {'_limit': '$limit', '_start': '$start'},
);
final response = await http.get(uri).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) throw Exception('Failed to load todos');
final List data = jsonDecode(response.body);
return data.map((j) => Todo.fromJson(j)).toList();
}
// ---- Fetch single todo ----
static Future<Todo> fetchTodo(int id) async {
final response = await http.get(Uri.parse('$_base/todos/$id'));
if (response.statusCode != 200) throw Exception('Todo not found');
return Todo.fromJson(jsonDecode(response.body));
}
// ---- Create new todo ----
static Future<Todo> createTodo(String title) async {
final response = await http.post(
Uri.parse('$_base/todos'),
headers: _headers,
body: jsonEncode({'title': title, 'completed': false, 'userId': 1}),
);
if (response.statusCode != 201) throw Exception('Failed to create todo');
return Todo.fromJson(jsonDecode(response.body));
}
// ---- Toggle completed ----
static Future<Todo> toggleTodo(Todo todo) async {
final response = await http.patch(
Uri.parse('$_base/todos/${todo.id}'),
headers: _headers,
body: jsonEncode({'completed': !todo.completed}),
);
if (response.statusCode != 200) throw Exception('Failed to update todo');
return todo.copyWith(completed: !todo.completed);
}
// ---- Delete todo ----
static Future<void> deleteTodo(int id) async {
final response = await http.delete(Uri.parse('$_base/todos/$id'));
if (response.statusCode != 200) throw Exception('Failed to delete todo');
}
}
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../services/api_service.dart';
import 'add_todo_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late Future<List<Todo>> _todosFuture;
@override
void initState() {
super.initState();
_todosFuture = ApiService.fetchTodos(); // store future here, not in build()
}
void _refresh() {
setState(() => _todosFuture = ApiService.fetchTodos());
}
Future<void> _toggle(Todo todo) async {
await ApiService.toggleTodo(todo);
_refresh();
}
Future<void> _delete(int id) async {
await ApiService.deleteTodo(id);
_refresh();
}
Future<void> _addTodo() async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AddTodoScreen()),
);
if (result == true) _refresh(); // refresh list if a todo was added
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
actions: [
IconButton(onPressed: _refresh, icon: const Icon(Icons.refresh)),
],
),
body: FutureBuilder<List<Todo>>(
future: _todosFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text('${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _refresh,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
final todos = snapshot.data!;
if (todos.isEmpty) {
return const Center(child: Text('No todos yet. Add one!'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return Dismissible(
key: ValueKey(todo.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _delete(todo.id),
child: CheckboxListTile(
value: todo.completed,
onChanged: (_) => _toggle(todo),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
color: todo.completed ? Colors.grey : null,
),
),
subtitle: Text('User ${todo.userId}'),
secondary: CircleAvatar(child: Text('${todo.id}')),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addTodo,
icon: const Icon(Icons.add),
label: const Text('Add Todo'),
),
);
}
}
// lib/screens/add_todo_screen.dart
import 'package:flutter/material.dart';
import '../services/api_service.dart';
class AddTodoScreen extends StatefulWidget {
const AddTodoScreen({super.key});
@override
State<AddTodoScreen> createState() => _AddTodoScreenState();
}
class _AddTodoScreenState extends State<AddTodoScreen> {
final _formKey = GlobalKey<FormState>();
final _titleCtrl = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_titleCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ApiService.createTodo(_titleCtrl.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Todo added!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true); // return true = list should refresh
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add Todo')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _titleCtrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Todo Title',
hintText: 'What do you need to do?',
prefixIcon: Icon(Icons.edit),
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Please enter a title';
if (v.trim().length < 3) return 'Title must be at least 3 characters';
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _isLoading
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Todo', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
],
),
),
),
);
}
}
# Build a debug APK (for testing) flutter build apk # Build a release APK (signed, optimized) flutter build apk --release # Build Android App Bundle (recommended for Play Store) flutter build appbundle --release # Output location: # build/app/outputs/flutter-apk/app-release.apk # build/app/outputs/bundle/release/app-release.aab # Split by ABI (smaller per-device downloads) flutter build apk --split-per-abi --release
# 1. Generate keystore keytool -genkey -v -keystore ~/upload-keystore.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias upload # 2. android/key.properties storePassword=your_store_password keyPassword=your_key_password keyAlias=upload storeFile=/Users/you/upload-keystore.jks # 3. android/app/build.gradle — add signing config block # (Follow official Flutter docs for the full build.gradle setup)
# Build release iOS (archive)
flutter build ios --release
# Then open Xcode to archive and submit to App Store:
open ios/Runner.xcworkspace
# Or use xcodebuild:
xcodebuild -workspace ios/Runner.xcworkspace \
-scheme Runner \
-configuration Release \
-archivePath build/Runner.xcarchive \
archive
# Build web (uses CanvasKit renderer by default) flutter build web --release # HTML renderer (better for text, smaller, works on more browsers) flutter build web --web-renderer html --release # WASM (experimental — best performance) flutter build web --wasm --release # Output: build/web/ — deploy this folder to any static host # (Nginx, Apache, Firebase Hosting, Netlify, Vercel, GitHub Pages) # Example: deploy to Firebase Hosting firebase deploy --only hosting
# Windows executable flutter build windows --release # Output: build\windows\x64\runner\Release\ # macOS app flutter build macos --release # Output: build/macos/Build/Products/Release/ # Linux binary flutter build linux --release # Output: build/linux/x64/release/bundle/
# Obfuscate Dart code (hides class/function names from reverse engineers) flutter build apk --release --obfuscate --split-debug-info=build/debug-info/ # Analyze app size flutter build apk --analyze-size # Tree shake icons (removes unused Material icons — saves ~100KB-1MB) flutter build apk --release # Icon tree-shaking is enabled by default in release builds
You have completed all 8 Flutter lessons. You now know how to build full cross-platform apps.