How We Built Pill Timer: Flutter + Clean Architecture Case Study
Get new tutorials every Tuesday
Join developers reading CoreCodery Weekly — Flutter, Cloudflare & Supabase.
No spam. Unsubscribe anytime.
How We Built Pill Timer: Flutter + Clean Architecture Case Study
Series: Product Building (Pillar 4) | Target Keyword: flutter clean architecture case study | Est. Length: ~2,500 words
The Problem With Medication Reminder Apps
Most medication reminder apps fail for the same reasons:
- They work great until a timezone changes
- Adding a new feature breaks an existing one
- Tests are impossible to write
- The codebase becomes unmaintainable at 5,000 lines
This post walks through how we avoided those pitfalls building Pill Timer — our Flutter medication management app — using Clean Architecture, BLoC, Sembast, and Supabase. All code is from the real codebase.
> Related reading: This article pairs with Flutter Clean Architecture Guide (Article #1). This is the real-world case study.
The Problem Domain
Pill Timer needs to solve three things that are harder than they look:
1. Scheduling: users take medications at specific times that must survive timezone changes, DST, app restarts, and device reboots 2. Compliance tracking: we need to know what was taken, what was skipped, and compute streaks accurately 3. User engagement: a utility app lives or dies by whether users actually open it daily — gamification matters
A flat architecture with no separation of concerns collapses under this complexity. We chose Clean Architecture to keep each concern isolated and testable.
Architecture Overview
The app is organized into three strict layers:
lib/
├── core/ # Shared: architecture base classes, DI, services
├── features/
│ ├── specific/
│ │ ├── medication/
│ │ │ ├── domain/ # Entities, repositories (interface), use cases
│ │ │ ├── data/ # Repository implementations, local services
│ │ │ └── presentation/ # BLoC, pages, widgets
│ │ ├── schedule/
│ │ └── stats/
│ ├── gamification/
│ └── profile/
└── l10n/ # 10-language localizationsThe dependency rule: outer layers depend on inner layers. `presentation` depends on `domain`; `data` depends on `domain`. `domain` depends on nothing outside itself.
Domain Layer
Base Architecture
We defined base classes in `lib/core/architecture/` that all features extend:
// base_entity.dart
abstract class BaseEntity extends Equatable {
const BaseEntity();
@override
List<Object?> get props;
@override
bool get stringify => true;
}
// base_usecase.dart
abstract class UseCase<T, Params> {
Future<T> call(Params params);
}
abstract class NoParamsUseCase<T> {
Future<T> call();
}Why `Equatable`? Value equality for free. Two `MedicationEntity` instances with the same field values are equal without writing `==` operators. This is critical for BLoC — it compares states to decide whether to rebuild the UI.
The Medication Entity
enum MedicationFrequency {
onceDaily,
twiceDaily,
thriceDaily,
everyXHours,
asNeeded,
}
class MedicationEntity extends Equatable {
final String id;
final String name;
final String dosage;
final MedicationFrequency frequency;
final List<String> scheduledTimes; // ["08:00", "20:00"] — 24h format
final DateTime startDate;
final DateTime? endDate;
final String instructions;
final int colorValue;
final bool isActive;
MedicationEntity copyWith({ /* all fields optional */ }) {
return MedicationEntity(
id: id ?? this.id,
name: name ?? this.name,
// ...
);
}
@override
List<Object?> get props => [
id, name, dosage, frequency, scheduledTimes,
startDate, endDate, instructions, colorValue, isActive,
];
}Key decisions:
- Scheduled times as `List<String>` in `"HH:mm"` format — timezone-safe. We store the logical time, not a `DateTime`. A `DateTime` would drift with DST; a string like `"08:00"` always means 8am in the local timezone.
- `copyWith` for immutability — no mutations. Every update creates a new entity. BLoC depends on this.
- `asNeeded` frequency — PRN medications don't have scheduled times and are excluded from compliance calculations.
Repository Interface
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 only knows this abstract interface. It has no idea whether storage is Sembast, SQLite, or Supabase. This lets us swap the persistence layer without touching any business logic.
Use Cases — One Per Operation
class AddMedicationUseCase implements UseCase<void, MedicationEntity> {
final MedicationRepository repository;
AddMedicationUseCase(this.repository);
@override
Future<void> call(MedicationEntity params) => repository.addMedication(params);
}We have 9 use cases for medication alone: `GetMedicationsUseCase`, `AddMedicationUseCase`, `UpdateMedicationUseCase`, `DeleteMedicationUseCase`, `MarkMedicationTakenUseCase`, `SkipMedicationUseCase`, `GetTodayLogsUseCase`, `GetAllLogsUseCase`, `GetLogsForDateUseCase`. Each one does exactly one thing.
Result Type — Functional Error Handling
We use a `sealed class Result<T>` inspired by functional programming:
sealed class Result<T> {
R when<R>({
required R Function(T data) success,
required R Function(AppException error) failure,
}) {
return switch (this) {
Success<T>(:final data) => success(data),
Failure<T>(:final error) => failure(error),
};
}
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
}
class Failure<T> extends Result<T> {
final AppException error;
const Failure(this.error);
}Why not `try/catch`? With `Result<T>`, error handling is explicit in the type signature. A function returning `Future<Result<MedicationEntity>>` forces the caller to handle both cases. We also defined `AppException` subtypes: `ServerException`, `CacheException`, `ValidationException`, `AuthException` — each BLoC handler pattern-matches on the specific type for targeted error messages.
Presentation Layer — BLoC
class MedicationBloc extends Bloc<MedicationEvent, MedicationState> {
final GetMedicationsUseCase getMedications;
final AddMedicationUseCase addMedication;
final MarkMedicationTakenUseCase markTaken;
final MedicationNotificationService _notificationService;
final AchievementService _achievementService;
MedicationBloc({
required this.getMedications,
// ...
MedicationNotificationService? notificationService,
AchievementService? achievementService,
}) : _notificationService = notificationService ?? MedicationNotificationService(),
_achievementService = achievementService ?? AchievementService(),
super(const MedicationInitial()) {
on<LoadMedications>(_onLoad);
on<AddMedication>(_onAdd);
on<MarkMedicationTaken>(_onMarkTaken);
}
}Notice the optional constructor parameters for `notificationService` and `achievementService`. This is our testability hook — in tests, pass mock implementations; in production, the real singletons are used.
Gamification System
class AchievementService {
static final AchievementService _instance = AchievementService._internal();
factory AchievementService() => _instance;
Future<void> updateStats({
required int totalTaken,
required int currentStreak,
}) async {
_totalTaken = totalTaken;
_currentStreak = currentStreak;
await _prefs.setInt(AchievementConfig.prefKeyTotalTaken, _totalTaken);
await _prefs.setInt(AchievementConfig.prefKeyStreak, _currentStreak);
await _checkAchievements();
}
Future<void> _checkAchievements() async {
final preset = RemoteConfigService.instance.achievementPreset;
if (_totalTaken >= AchievementConfig.thresholdFirstStepFor(preset)) {
await _unlock('first_step');
}
if (_currentStreak >= AchievementConfig.thresholdStreak3For(preset)) {
await _unlock('streak_3');
}
}
}Design decisions:
- Remote Config thresholds — tune achievement difficulty without an app update. We A/B test whether easier achievements drive better retention.
- `SharedPreferences` persistence — achievements survive app restarts without a Supabase round-trip.
- Singleton pattern — `AchievementService` is injected into `MedicationBloc`. When a dose is marked taken, the BLoC calls `updateStats()` and the service fires achievement unlocks.
Smart Notifications
Notifications are the most brittle part of any reminder app. Our `MedicationNotificationService` solves two key problems:
class MedicationNotificationService {
// Cancels ALL existing notifications first, then reschedules from scratch.
Future<void> rescheduleAll(
List<MedicationEntity> medications, {
List<MedicationLogEntity> todayLogs = const [],
}) async {
if (kIsWeb) return;
await _notifications.cancelAllNotifications();
final groups = buildTimeGroups(medications, todayLogs: todayLogs);
await _notifications.scheduleGroupedNotifications(groups);
}
static List<MedicationTimeGroup> buildTimeGroups(
List<MedicationEntity> medications, {
List<MedicationLogEntity> todayLogs = const [],
}) {
final active = medications.where(
(m) => m.isActive && m.frequency != MedicationFrequency.asNeeded,
);
// Medications at same time merged into one notification group.
// Already-taken medications excluded from today's schedule.
}
}Two design insights:
1. Cancel-all then reschedule — instead of tracking individual notification IDs, we cancel everything and reschedule from current state. Simpler, handles deleted meds and changed schedules automatically.
2. Smart Reminders via `todayLogs` — when `rescheduleAll` is called after marking a dose taken, today's logs are passed in. Any medication+time slot already logged is excluded from the notification schedule. Take your 8am pill at 8:05am — the 8am notification won't fire again.
Localization: 10 Languages
Pill Timer ships with 10 locales from day one:
lib/l10n/
├── en.dart # English
├── th.dart # Thai
├── ar.dart # Arabic (RTL)
├── es.dart # Spanish
├── hi.dart # Hindi
├── ja.dart # Japanese
├── ko.dart # Korean
├── pt.dart # Portuguese
├── ru.dart # Russian
└── zh.dart # ChineseEach locale is a Dart class with typed string accessors (not map keys). `flutter gen-l10n` generates `AppLocalizations` from ARB files. Type safety means a missing translation is a compile error, not a runtime crash.
Covering Thai (our home market), Arabic (RTL), and CJK (multibyte) from day one forced us to handle text rendering, RTL layouts, and date formatting correctly — before they became tech debt.
Dependency Injection with GetIt
final sl = GetIt.instance;
Future<void> initDependencies() async {
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(sharedPreferences);
sl.registerLazySingleton<MedicationRepository>(
() => MedicationRepositoryImpl(sl()),
);
sl.registerFactory(() => GetMedicationsUseCase(sl()));
sl.registerFactory(() => AddMedicationUseCase(sl()));
sl.registerFactory(() => MedicationBloc(
getMedications: sl(),
addMedication: sl(),
));
}Use cases are `registerFactory` (new instance per request, no state). Repositories are `registerLazySingleton` (one instance). BLoCs are `registerFactory` — each screen gets its own instance with fresh state.
What We Would Do Differently
1. Sembast → Drift for complex queries
Sembast (a NoSQL document store) works well for simple CRUD. But compliance stats — "what's my 30-day average completion rate by medication?" — require joins and aggregations that are painful in a document store. We would choose Drift (type-safe SQLite) if starting today.
2. Design the sync layer concurrently
We built local-first with Supabase sync planned later. The offline-first approach was correct, but we should have designed the sync layer concurrently. The `MedicationRepository` interface was built for local storage and needed refactoring to support cloud sync semantics.
3. Skip use cases for pure CRUD
Some use cases are one-liners that just delegate to a repository method. For simple CRUD with no business logic, the use case adds ceremony without value. We'd let the BLoC call the repository directly for those operations.
Key Takeaways
| Decision | Outcome | |----------|---------| | Scheduled times as "HH:mm" strings | Timezone-safe, no DST bugs | | Cancel-all then reschedule notifications | Simpler, handles all edge cases | | Remote Config achievement thresholds | A/B test retention without app update | | One use case per operation | Isolated, trivially testable | | 10 locales from day one | RTL + multibyte handled early, not as debt | | GetIt with factory BLoCs | Fresh state per screen, no stale data leaks |
What's Next
- [Flutter Clean Architecture Guide](/blog/flutter-clean-architecture) (Article #1) — the conceptual foundation behind this case study
- Download Pill Timer — iOS App Store / Google Play *(links coming)*
*Tags: Flutter, Clean Architecture, BLoC, Dart, Pill Timer, Case Study, Mobile Development*
Enjoyed this? Get notified of new posts.
Weekly tutorials on Flutter, Cloudflare & Supabase — free.
No spam. Unsubscribe anytime.