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

```plaintext
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.

```dart
@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:

```dart
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:

```dart
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:

```dart
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.

```dart
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.

```dart
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.

```dart
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](https://github.com/cjdotdev/Flutter-Todoapp). Clone it, experiment, and make it your own.
