Building a Medication Tracker with Flutter Clean Architecture
Get new tutorials every Tuesday
Join developers reading CoreCodery Weekly — Flutter, Cloudflare & Supabase.
No spam. Unsubscribe anytime.
Building a Medication Tracker with Flutter Clean Architecture
Series: Product Building · Part 1 of 4 Keywords: Flutter Clean Architecture tutorial, Flutter BLoC pattern, Flutter repository pattern
Introduction
When we built Pill Timer — a medication reminder app — we needed an architecture that could scale: multiple data sources (local + cloud), testable business logic, and a UI that stays responsive under state changes. We chose Clean Architecture, and it paid off.
This article walks through the exact architecture powering Pill Timer, with real code from the project.
Why Clean Architecture for Flutter?
Flutter apps can get messy fast. Widget trees grow deep, business logic bleeds into UI, and testing becomes painful.
Clean Architecture solves this with three guiding principles:
- Separation of concerns — UI does not know how data is stored; business rules do not know about Flutter at all
- Dependency inversion — inner layers define interfaces; outer layers implement them
- Testability — domain logic is pure Dart with zero Flutter or platform dependencies
The result: you can swap Sembast for SQLite, or add Supabase sync, without touching a single BLoC or use case.
The 3 Layers
lib/features/medication/
├── domain/ ← pure Dart, zero framework deps
│ ├── entities/
│ ├── repositories/ (abstract interfaces only)
│ └── usecases/
├── data/ ← implements domain interfaces
│ ├── models/
│ ├── services/
│ └── repositories/
└── presentation/ ← Flutter widgets + BLoC
├── blocs/
└── pages/1. Domain Layer — The Heart
The domain layer contains your business rules. It has no Flutter imports, no database drivers, no HTTP clients. Just pure Dart.
Entities are immutable value objects:
class MedicationEntity extends Equatable {
final String id;
final String name;
final String dosage;
final MedicationFrequency frequency;
final List<String> scheduledTimes; // ["08:00", "20:00"]
final DateTime startDate;
final bool isActive;
const MedicationEntity({
required this.id,
required this.name,
required this.dosage,
required this.frequency,
required this.scheduledTimes,
required this.startDate,
this.isActive = true,
});
// Never mutate — always return a new copy
MedicationEntity copyWith({String? name, bool? isActive}) {
return MedicationEntity(
name: name ?? this.name,
isActive: isActive ?? this.isActive,
);
}
@override
List<Object?> get props => [id, name, dosage, frequency, scheduledTimes, startDate, isActive];
}Repository interfaces define *what* operations exist, not *how* they work:
abstract class MedicationRepository {
Future<List<MedicationEntity>> getMedications({bool activeOnly = false});
Future<MedicationEntity?> getMedicationById(String id);
Future<void> addMedication(MedicationEntity medication);
Future<void> updateMedication(MedicationEntity medication);
Future<void> deleteMedication(String id);
}The domain layer depends on this interface. Nothing else.
2. Data Layer — Implementation Details
The data layer implements the domain interfaces. It knows about Sembast, Supabase, and JSON — but the domain layer never sees any of that.
class MedicationRepositoryImpl implements MedicationRepository {
final MedicationService _service;
MedicationRepositoryImpl(this._service);
@override
Future<List<MedicationEntity>> getMedications({bool activeOnly = false}) =>
_service.getMedications(activeOnly: activeOnly);
@override
Future<void> addMedication(MedicationEntity medication) =>
_service.saveMedication(MedicationModel.fromEntity(medication));
@override
Future<void> deleteMedication(String id) =>
_service.deleteMedication(id);
}`MedicationModel` is the serializable version of `MedicationEntity` — it knows about `fromJson`/`toJson`. The entity knows nothing about serialization.
3. Presentation Layer — Flutter + BLoC
This is the only layer that imports Flutter. It reacts to domain data and dispatches user actions.
BLoC Pattern — State Management
BLoC (Business Logic Component) separates UI from logic. Events flow in, states flow out.
class MedicationBloc extends Bloc<MedicationEvent, MedicationState> {
final GetMedicationsUseCase getMedications;
final AddMedicationUseCase addMedication;
final MarkMedicationTakenUseCase markTaken;
MedicationBloc({required this.getMedications, ...})
: super(const MedicationInitial()) {
on<LoadMedications>(_onLoad);
on<AddMedication>(_onAdd);
on<MarkMedicationTaken>(_onMarkTaken);
}
Future<void> _onLoad(
LoadMedications event,
Emitter<MedicationState> emit,
) async {
emit(const MedicationLoading());
try {
final items = await getMedications(
GetMedicationsParams(activeOnly: event.activeOnly),
);
final todayLogs = await _getTodayLogs();
emit(MedicationLoaded(items, todayLogs: todayLogs));
} catch (e) {
emit(MedicationError(e.toString()));
}
}
}The BLoC calls use cases, not repositories directly. It has no idea whether data comes from Sembast or Supabase.
In the UI:
BlocBuilder<MedicationBloc, MedicationState>(
builder: (context, state) {
return switch (state) {
MedicationLoaded(:final medications) =>
MedicationList(medications: medications),
MedicationLoading() => const CircularProgressIndicator(),
MedicationError(:final message) => ErrorWidget(message),
_ => const SizedBox.shrink(),
};
},
)Repository Pattern — Data Abstraction
The repository pattern is what makes swapping data sources painless. Pill Timer stores data locally (Sembast) for offline-first experience and optionally syncs to Supabase.
The BLoC never calls Sembast directly. It calls a use case, which calls the abstract `MedicationRepository`, and the concrete `MedicationRepositoryImpl` handles the actual storage.
Adding Supabase sync is a data-layer concern only:
- Before: MedicationRepositoryImpl → SembastService
- After: MedicationRepositoryImpl → SembastService + SupabaseService
Zero changes to domain or presentation layers.
Use Cases — Single-Responsibility Operations
Each use case does exactly one thing:
abstract class UseCase<T, Params> {
Future<T> call(Params params);
}
class AddMedicationUseCase implements UseCase<void, MedicationEntity> {
final MedicationRepository repository;
AddMedicationUseCase(this.repository);
@override
Future<void> call(MedicationEntity params) =>
repository.addMedication(params);
}For complex domain logic like streak calculation, it lives in the domain layer as a pure function:
class StreakCalculator {
static StreakResult compute(
List<MedicationLogEntity> logs,
DateTime today,
) {
if (logs.isEmpty) return const StreakResult(currentStreak: 0, totalTaken: 0);
final days = logs
.map((l) => _dateOnly(l.takenAt))
.toSet()
.toList()
..sort((a, b) => b.compareTo(a));
int streak = 1;
for (int i = 1; i < days.length; i++) {
final expected = days[i - 1].subtract(const Duration(days: 1));
if (days[i] == expected) streak++;
else break;
}
return StreakResult(currentStreak: streak, totalTaken: logs.length);
}
}Pure logic — no I/O, no Flutter, fully testable.
Dependency Injection with get_it
We use get_it as a service locator. All wiring happens in `injection_container.dart`:
final sl = GetIt.instance;
Future<void> initDependencies() async {
// 1. External dependencies
final sembastStorage = SembastStorage();
await sembastStorage.init();
sl.registerSingleton<SembastStorage>(sembastStorage);
// 2. Feature registrations
_initMedicationFeature();
}
void _initMedicationFeature() {
sl.registerLazySingleton<MedicationService>(() => MedicationService());
// Interface → Implementation binding
sl.registerLazySingleton<MedicationRepository>(
() => MedicationRepositoryImpl(sl()),
);
sl.registerLazySingleton(() => GetMedicationsUseCase(sl()));
sl.registerLazySingleton(() => AddMedicationUseCase(sl()));
sl.registerLazySingleton(() => MarkMedicationTakenUseCase(sl()));
// Factory: fresh BLoC per screen
sl.registerFactory(
() => MedicationBloc(
getMedications: sl(),
addMedication: sl(),
markTaken: sl(),
),
);
}Key decisions:
- Singletons for services and repositories (shared state)
- Factory for BLoCs (fresh instance per screen, no state leakage)
Testing — Domain Logic is Pure Dart
Because the domain layer has zero Flutter or database dependencies, testing is straightforward:
void main() {
group('StreakCalculator', () {
test('returns 0 streak when no logs', () {
final result = StreakCalculator.compute([], DateTime.now());
expect(result.currentStreak, 0);
expect(result.totalTaken, 0);
});
test('counts consecutive days correctly', () {
final today = DateTime(2026, 4, 7);
final logs = [
_logOn(today),
_logOn(today.subtract(const Duration(days: 1))),
_logOn(today.subtract(const Duration(days: 2))),
];
final result = StreakCalculator.compute(logs, today);
expect(result.currentStreak, 3);
});
test('breaks streak on gap', () {
final today = DateTime(2026, 4, 7);
final logs = [
_logOn(today),
_logOn(today.subtract(const Duration(days: 3))),
];
final result = StreakCalculator.compute(logs, today);
expect(result.currentStreak, 1);
});
});
}No mocks, no setup — pure logic tests. Use cases and repositories are tested with mock implementations of the abstract interfaces.
The Full Picture
Flutter Widgets
↓ dispatches Events
BLoC (presentation layer)
↓ calls
Use Cases (domain layer)
↓ calls abstract interface
MedicationRepository (domain interface)
↑ implemented by
MedicationRepositoryImpl (data layer)
↓ calls
Sembast / Supabase ServicesDependencies only point inward. The domain layer knows nothing about Flutter, Sembast, or Supabase. The data layer knows about storage but not UI. The presentation layer knows about Flutter but delegates all logic downward.
Conclusion
Clean Architecture adds some upfront structure, but it pays back quickly:
- Swap storage without touching UI code
- Test business logic without a device or database
- Onboard new features by following the same pattern
Pill Timer started as a weekend project. The architecture is what let it grow into a production app with offline-first storage, Supabase sync, streak tracking, and push notifications — without the codebase turning into spaghetti.
Try Pill Timer → Pill Timer on CoreCodery
*Next in this series: How we sync local Sembast data to Supabase for cross-device support.*
*Tags: Flutter, Clean Architecture, BLoC, Dart, Mobile Development*
Enjoyed this? Get notified of new posts.
Weekly tutorials on Flutter, Cloudflare & Supabase — free.
No spam. Unsubscribe anytime.