Skip to main content

Command Palette

Search for a command to run...

Building a Robust Flutter Todo App: MVC Architecture and Offline-First Approach

Updated
•6 min read•View as Markdown
Building a Robust Flutter Todo App: MVC Architecture and Offline-First Approach

Building a Robust Flutter Todo App: Offline-First Architecture for Beginners

Building a Todo app is the classic first project in any framework, but most tutorials focus only on the UI. This article takes a different approach: it walks through building a production-ready Todo app with a clean architecture that scales : offline-first, locally persisted, and fully reactive.

By the end, you'll understand how to structure a Flutter project using the Repository pattern, Cubit state management, and the Isar database for offline storage. No cloud services, no complex setup : just a solid foundation you can extend later.

Project Structure

Before writing any code, lay out a clear folder structure. Each layer has one responsibility:

lib/
├── model/          # Data representation
├── repository/     # Data access
├── cubit/          # State management
├── screens/        # Full-page UI
├── widgets/        # Reusable UI pieces
├── theme/          # Colors, fonts, sizes
└── utils/          # Dependency injection setup

This separation means changing the database only touches the repository. Changing the look only touches screens and widgets. Each layer is independent.

1. The Model : Keeping Data Simple

The model is a plain Dart class that represents a task. It uses Isar, an embedded database that stores data permanently on the device.

@collection
class Task {
  Id id = Isar.autoIncrement;
  late String title;
  String? description;
  bool isCompleted = false;
  late DateTime date;

  Task({required this.title, this.description, this.isCompleted = false})
    : date = DateTime.now();
}

Key concepts:

  • @collection tells Isar this class should be stored in the database.

  • Id is the auto-incrementing primary key : every new task gets a unique number.

  • isCompleted defaults to false; a new task is always pending.

  • date is automatically set to the current time when the task is created.

A copyWith method and a dateFormatee getter are added for convenience. The model contains no logic and no UI code : just data.

2. The Repository : Abstracting the Database

The repository pattern creates a wall between business logic and the database. The app never talks directly to Isar. It talks to a repository, which handles the details.

The Interface (Contract)

First, define what the repository can do:

abstract class TaskRepository {
  Stream<List<Task>> watchAllTasks();
  Future<int> saveTask(Task task);
  Future<void> deleteTask(int id);
  Future<Task?> getTaskById(int id);
  Future<int> toggleStatus(int id);
}

The Implementation (How)

Then implement how it does it with Isar:

class TaskRepositoryImpl implements TaskRepository {
  final Isar _isar;

  @override
  Future<int> saveTask(Task task) async {
    if (task.title.trim().isEmpty) throw ArgumentError('Title is required');
    return await _isar.writeTxn(() => _isar.tasks.put(task));
  }

  @override
  Future<void> deleteTask(int id) async {
    await _isar.writeTxn(() => _isar.tasks.delete(id));
  }

  @override
  Stream<List<Task>> watchAllTasks() {
    return _isar.tasks.where().watchLazy(fireImmediately: true)
        .asyncMap((_) => _isar.tasks.where().findAll());
  }
}

Why an interface? If the database changes from Isar to SQLite or anything else, only a new implementation class is needed. The rest of the app remains untouched.

3. Cubit : The State Manager

The Cubit acts as the brain of the app. It listens to the repository and tells the UI what to display.

Defining States

States are defined as a sealed class hierarchy:

sealed class TaskCubitState extends Equatable {
  const TaskCubitState();
}

class TaskLoading extends TaskCubitState {}
class TaskMainState extends TaskCubitState {
  final List<Task> tasks;
  const TaskMainState({required this.tasks});
}
class TaskFailureState extends TaskCubitState {
  final String message;
  const TaskFailureState({required this.message});
}

The Cubit

The Cubit subscribes to the repository's stream. When data changes (a task is added, deleted, or toggled), the stream emits a new list, and the Cubit emits a new state. The UI rebuilds automatically.

class TaskCubit extends Cubit<TaskCubitState> {
  final TaskRepository _repository;

  void watchAllTasks() {
    emit(const TaskLoading());
    _subscription = _repository.watchAllTasks().listen(
      (tasks) => emit(TaskMainState(tasks: tasks)),
      onError: (e) => emit(TaskFailureState(message: e.toString())),
    );
  }

  Future<void> saveTask(Task task) async {
    try {
      await _repository.saveTask(task);
    } catch (e) {
      emit(TaskFailureState(message: e.toString()));
    }
  }
}

Notice that saveTask does not emit a success state. The stream subscription in watchAllTasks automatically emits the updated list. The UI stays in sync without manual refresh.

4. The UI : Putting It All Together

The HomeScreen uses BlocConsumer to listen to the Cubit and rebuild when the state changes.

BlocConsumer<TaskCubit, TaskCubitState>(
  listener: (context, state) {
    if (state is TaskFailureState) showErrorSnackBar(context, state.message);
  },
  builder: (context, state) {
    if (state is TaskLoading) return const CircularProgressIndicator();
    if (state is TaskMainState) return _buildTaskList(state.tasks);
    return const SizedBox.shrink();
  },
);

Tasks are grouped by date with section headers. Active tasks appear first, completed ones in a separate section. Each task card supports swipe-to-delete and tap-to-view-details.

Reusable widgets keep the code clean:

  • TaskCard : displays a single task with checkbox, title, date, and swipe action.

  • AddTaskPopup : a bottom sheet form with real-time validation (button stays disabled until the title is filled).

  • TaskDetailPopup : shows full task info in a bottom sheet.

  • EmptyState : friendly illustration when no tasks exist.

5. The Theme : Keeping It Consistent

All colors, sizes, fonts, and styles live in dedicated files under lib/theme/. No hardcoded values anywhere.

class AppColors {
  AppColors._();  // Private constructor prevents instantiation
  static const pink = Color(0xFFEC4899);
  static const success = Color(0xFF22C55E);
  static const error = Color(0xFFEF4444);
}

Both light and dark themes are defined using ThemeData with Material 3 and a pink seed color. Switching themes is a single button tap in the AppBar.

Why Offline-First Matters

Everything writes to Isar first. The app works 100 % without internet. No loading spinners waiting for a server. No "No connection" errors. Just instant, local data.

This is the foundation. Later, cloud sync (Firestore, Supabase, or a custom API) can be added on top : the repository pattern makes it trivial to extend.

Key Takeaways

1. Separate concerns. The model knows nothing about the UI. The UI knows nothing about the database. The Cubit bridges them cleanly.

2. Streams eliminate manual refresh. Instead of calling a "refresh list" function after every action, the database stream does the work automatically. Add a task? Stream fires. Delete a task? Stream fires. The UI updates reactively.

3. Private constructors on utility classes (AppColors._()) prevent creating instances where none are needed. A small habit that keeps the codebase clean.

4. Declarative UI means describing what the screen should look like for each state, not how to transition between states. Flutter handles the rest.

Final Thoughts

A well-structured Todo app teaches more than any tutorial. The patterns shown here : Repository, Cubit, offline-first with Isar : apply to any Flutter project, from simple apps to complex production systems.

The complete source code is available on GitHub : github.com/cjdotdev/Flutter-Todoapp. Clone it, experiment, and make it your own.

12 views

Tutorials

Part 1 of 1