🏠 Home / Hub

Flutter 08 — Mini Project & Deployment

Build a complete Todo App with API integration, then deploy it to Android, iOS, and Web.

1. Project Overview & Plan

We will build a Todo App with API that demonstrates all the Flutter skills from the previous lessons in one cohesive project.

FeatureTechnique Used
Fetch todos from APIhttp package + FutureBuilder
Display todo listListView.builder + ListTile
Add new todoForm + TextFormField + Navigator
Toggle done/undoneCheckbox + PATCH request
Delete todoDismissible + DELETE request
Persist auth tokenshared_preferences
Shared stateProvider + ChangeNotifier
NavigationNavigator.push / pushNamed

API: JSONPlaceholder (https://jsonplaceholder.typicode.com/todos) — free mock REST API, perfect for learning.

2. Project Structure

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

3. Todo Model

// 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;
}

4. API Service

// 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');
  }
}

5. Home Screen with FutureBuilder List

// 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'),
      ),
    );
  }
}

6. Add Todo Form Screen

// 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'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

7. Build for Release

Android

# 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

Android — signing setup (required for 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)

iOS (requires macOS + Xcode)

# 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

Web

# 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 / macOS / Linux Desktop

# 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/

Build size optimization tips

# 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
Always test your release build on a real device before submitting to an app store. Release mode disables debug overlays and uses AOT compilation, which can surface issues not seen in debug mode.

Flutter Series Complete!

You have completed all 8 Flutter lessons. You now know how to build full cross-platform apps.

Dart Language Null Safety StatelessWidget StatefulWidget setState Text Container Image ElevatedButton Column Row Stack Expanded ListView.builder GridView.builder Scaffold AppBar Navigator.push go_router BottomNavigationBar Drawer TextField Form TextFormField Validation Checkbox Switch Slider DatePicker Provider ChangeNotifier Riverpod Bloc/Cubit http package FutureBuilder jsonDecode shared_preferences Hive sqflite flutter build apk flutter build web

📌 Study Checklist