In Part 2, we implemented the authentication system. Now, we will discuss the most vital part of our application:Managing Habits. This involves performing CRUD (Create, Read, Update, Delete) operations.
As professional developers, we must ensure that our business logic is separated from our implementation details.
1. Domain Modeling: The Habit Entity
The Entity is the core of our domain layer. It represents the data structure of a "Habit" without any dependency on external libraries or databases. We use the equatable package to handle object comparisons efficiently.
// domain/entities/habit_entity.dart
class HabitEntity extends Equatable {
final String id;
final String userId;
final String name;
final String description;
final HabitFrequency frequency;
final DateTime createdAt;
// ... other properties
const HabitEntity({
required this.id,
required this.userId,
required this.name,
// ... constructor
});
@override
List<Object?> get props => [id, userId, name, frequency, createdAt];
}
2. Defining the Repository Interface
We define the behaviors of our habit management in an abstract repository class. This allows the presentation layer to interact with data without knowing whether it comes from a local database or a cloud server like Supabase.
// domain/repositories/habit_repository.dart
abstract class HabitRepository {
Future<Either<Failure, List<HabitEntity>>> getHabits();
Future<Either<Failure, HabitEntity>> addHabit(HabitEntity habit);
Future<Either<Failure, HabitEntity>> updateHabit(HabitEntity habit);
Future<Either<Failure, void>> deleteHabit(String habitId);
}
3. Implementation with Supabase
The data layer implements our repository interface. Here, we convert our clean entities into JSON for database operations.
// data/repositories/habit_repository_impl.dart
@override
Future<Either<Failure, HabitEntity>> addHabit(HabitEntity habit) async {
try {
// 1. Convert Entity to Model (DTO)
final habitModel = HabitModel.fromEntity(habit, userId: currentUser.id);
// 2. Insert into Supabase 'habits' table
final response = await supabaseClient
.from('habits')
.insert(habitModel.toJson())
.select()
.single();
// 3. Return the result as a domain entity
return Right(HabitModel.fromJson(response));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
4. State Management with HabitBloc
We use HabitBloc to manage the list of habits and their status (Loading, Success, or Error). When a new habit is added, the BLoC handles the service call and then refreshes the list to provide a seamless experience to the user.
Future<void> _onAddHabit(AddHabitEvent event, Emitter<HabitState> emit) async {
emit(HabitLoading());
final result = await addHabit(event.habit);
result.fold(
(failure) => emit(HabitError(failure.message)),
(habit) {
// Refresh list to show the newly added habit
add(LoadHabits());
},
);
}
5. UI Integration
Our UI uses BlocBuilder to listen to the state changes. This ensures that the widget tree is rebuilt only when the state changes, making our application very efficient.
BlocBuilder<HabitBloc, HabitState>(
builder: (context, state) {
if (state is HabitLoading) {
return const CircularProgressIndicator();
} else if (state is HabitLoaded) {
return ListView.builder(
itemCount: state.habits.length,
itemBuilder: (context, index) => HabitCard(habit: state.habits[index]),
);
}
return const SizedBox();
},
)
Conclusion
We have now implemented the core CRUD logic for our Habit Tracker. By following Clean Architecture, our code remains very clean and easy to modify.
In the next article, we will tackle the most interesting part: Tracking Progress, Calculating Streaks, and Local Notifications. This is where we bring the app's business logic to life. Stay tuned for Part 4!