An example application for the Mobile Development Summer School (208.1).
This app is a small but complete task manager: users sign in, then create, edit, complete, and delete personal tasks — optionally with an attached image. It is intended as a learning project that shows how to combine Flutter with Firebase using a clean, testable architecture.
- What the app does
- Tech stack
- Architecture overview
- Project structure
- How the layers work together
- State management with Provider
- Data model & Firestore structure
- Getting started
- Configuration (Firebase & Cloudinary)
- Running the app
- Running the tests
- Key concepts to learn from this project
- Authentication: register and log in with email + password (Firebase Auth).
- Tasks: each logged-in user has their own list of tasks.
- CRUD: create, read, update, complete, and delete tasks.
- Real-time: the task list updates live via Firestore streams — no manual refresh needed.
- Images: attach a picture to a task. Images are uploaded to Cloudinary and only the resulting URL is stored in Firestore.
| Concern | Technology |
|---|---|
| UI framework | Flutter (Material) |
| State management | provider (ChangeNotifier) |
| Authentication | firebase_auth |
| Database | cloud_firestore (real-time) |
| Image hosting | Cloudinary (via http upload) |
| Image picking | image_picker |
| Configuration | flutter_dotenv (.env file) |
| Tests | flutter_test + hand-written fakes |
The app uses a layered architecture. Each layer has a single responsibility and only talks to the layer directly below it. The UI never talks to Firebase or Cloudinary directly.
┌─────────────────────────────────────────────┐
│ Views & Widgets (UI) │ what the user sees
│ login_screen, task_list_screen, task_form… │
└───────────────┬───────────────────────────────┘
│ reads state / calls methods
┌───────────────▼───────────────────────────────┐
│ Providers (state management) │ app logic + UI state
│ AuthProvider, TaskProvider │
└───────────────┬───────────────────────────────┘
│ depends on interfaces (not Firebase!)
┌───────────────▼───────────────────────────────┐
│ Repositories & Services (abstractions) │ data access contracts
│ TaskRepository, AuthService, │
│ ImageStorageRepository │
└───────────────┬───────────────────────────────┘
│ implemented by
┌───────────────▼───────────────────────────────┐
│ Concrete implementations │ the real integrations
│ FirestoreTaskRepository, FirebaseAuthService, │
│ CloudinaryImageRepository │
└───────────────┬───────────────────────────────┘
│
┌───────────────▼───────────────────────────────┐
│ External services: Firebase, Cloudinary │
└─────────────────────────────────────────────┘
Why this matters: because the providers depend on interfaces
(TaskRepository, AuthService, ImageStorageRepository) rather than concrete
Firebase classes, we can swap in fake implementations during tests and run
the whole app logic without a network or a real Firebase project.
task_manager/
├── lib/
│ ├── main.dart App entry point + provider wiring
│ ├── models/
│ │ └── task_model.dart The Task data class (immutable)
│ ├── providers/
│ │ ├── auth_provider.dart Auth state (user, loading, errors)
│ │ └── task_provider.dart Task list state (streamed from Firestore)
│ ├── repositories/
│ │ ├── task_repository.dart interface
│ │ ├── firestore_task_repository.dart Firestore implementation
│ │ ├── image_storage_repository.dart interface
│ │ └── cloudinary_image_repository.dart Cloudinary implementation
│ ├── services/
│ │ ├── auth_service.dart interface
│ │ └── firebase_auth_service.dart Firebase Auth implementation
│ ├── views/
│ │ ├── login_screen.dart
│ │ ├── task_list_screen.dart
│ │ ├── add_task_screen.dart
│ │ └── task_detail_screen.dart
│ ├── widgets/
│ │ ├── task_item.dart one row in the task list
│ │ └── task_form.dart shared add/edit form
│ └── utils/
│ ├── firebase_options.dart generated Firebase config
│ ├── cloudinary_config.dart reads Cloudinary values from .env
│ └── theme.dart app theme
├── test/ unit & widget tests (mirrors lib/)
├── .env.example template for your local .env
└── firestore.rules Firestore security rules
A concrete example: showing the task list in real time.
TaskListScreenreadsTaskProviderviaProvider.of/context.watch.TaskProvidersubscribes toTaskRepository.watchTasks(userId).FirestoreTaskRepositoryreturns a stream built from Firestore.snapshots(), ordered bycreatedAt.- Whenever data changes in Firestore, the stream emits a new
List<Task>. TaskProviderstores it and callsnotifyListeners().- The UI rebuilds automatically.
Creating, updating, or deleting a task simply writes to Firestore — the change flows back through the same stream, so the UI never needs manual updates.
This project uses the provider package. The two key objects are
ChangeNotifiers registered in main.dart:
MultiProvider(
providers: [
Provider<ImageStorageRepository>(
create: (_) => CloudinaryImageRepository(...),
),
ChangeNotifierProvider(create: (_) => AuthProvider(FirebaseAuthService())),
ChangeNotifierProxyProvider<AuthProvider, TaskProvider>(
create: (_) => TaskProvider(FirestoreTaskRepository()),
update: (_, auth, tasks) => tasks!..updateAuthProvider(auth),
),
],
...
)ChangeNotifierProviderexposes aChangeNotifierto the widget tree. When it callsnotifyListeners(), listening widgets rebuild.ChangeNotifierProxyProvideris used becauseTaskProviderdepends onAuthProvider: it needs the current user's id to know whose tasks to load. Whenever auth changes, the proxy hands the latestAuthProvidertoTaskProvider.- Dependency injection: the providers receive their data sources through
their constructors (
AuthProvider(FirebaseAuthService()),TaskProvider(FirestoreTaskRepository())). This is what makes them testable.
In widgets you typically:
final tasks = context.watch<TaskProvider>().tasks; // rebuild on change
context.read<TaskProvider>().deleteTask(id); // call once, no rebuildTask is an immutable value object with copyWith, ==, and hashCode:
class Task {
final String id;
final String title;
final String description;
final bool isCompleted;
final String? imageUrl;
final DateTime? createdAt;
}In Firestore the data is stored per user:
users/{userId}/tasks/{taskId}
title: string
description: string
isCompleted: boolean
imageUrl: string | null
createdAt: timestamp (set by the server)
Access is restricted by firestore.rules so that each user can only read and
write documents under their own users/{userId} path.
- Flutter SDK (Dart
>=3.4) - An editor (VS Code or Android Studio) with the Flutter plugin
- A device or emulator
- A Firebase project
- A free Cloudinary account (for image uploads)
cd task_manager
flutter pub getThis repo already contains a generated lib/utils/firebase_options.dart and the
Android google-services.json. To point the app at your own Firebase
project instead, install the FlutterFire CLI and run:
flutterfire configureThen, in the Firebase console:
-
enable Authentication → Email/Password
-
create a Cloud Firestore database
-
deploy the security rules:
firebase deploy --only firestore:rules
Images are uploaded directly from the app using an unsigned upload preset, so no secret key is ever stored in the app.
-
Create a free Cloudinary account.
-
In Settings → Upload → Upload presets, create a preset with Signing Mode = Unsigned.
-
Copy the example env file and fill in your values:
cp .env.example .env
CLOUDINARY_CLOUD_NAME=your_cloud_name CLOUDINARY_UPLOAD_PRESET=your_unsigned_upload_preset
⚠️ The.envfile is git-ignored — never commit it. Each developer keeps their own. Never put your Cloudinary API secret in the app; only the cloud name and the unsigned preset are needed.
cd task_manager
flutter runOn first launch you will see the login screen. Create an account, then start adding tasks.
cd task_manager
flutter testThe tests use hand-written fakes (test/fakes.dart) instead of real
Firebase, and a mock HTTP client for the Cloudinary upload. They cover:
AuthProvider— loading/error state and auth-state changesTaskProvider— stream subscription and CRUD forwardingCloudinaryImageRepository— upload request, success, and failureTaskmodel — serialization andcopyWithLoginScreen— an example widget test
- Layered architecture and separation of concerns
- Programming to an interface, not an implementation
- Dependency injection via constructors
provider/ChangeNotifierfor state managementChangeNotifierProxyProviderfor cross-provider dependencies- Firestore real-time streams (
.snapshots()) - Firestore security rules (per-user data isolation)
- Using a third-party service (Cloudinary) alongside Firebase
- Configuration via
.envinstead of hard-coded values - Testing business logic without a backend, using fakes/mocks