Flutter  

Building A Habit Tracker App In Flutter: Part 3 - Core Habit Features (CRUD)

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!