Building a Habit Tracker Widget with Streak Counter in Flutter
Habit trackers are powerful tools that help individuals cultivate positive habits and achieve personal growth. By providing visual feedback on consistency, they motivate users to maintain their routines. Building a habit tracker in Flutter allows for a beautiful, cross-platform application with a native feel. This article will guide you through creating a simple habit tracker widget with a streak counter, highlighting the core logic and Flutter implementation.
Understanding the Core Logic
At the heart of any habit tracker is the ability to record daily completion and calculate streaks. A "streak" represents the number of consecutive days a habit has been performed. This requires tracking several key pieces of information for each habit:
- Name: A descriptive name for the habit (e.g., "Drink 2 Liters of Water").
- Last Completed Date: The most recent date the habit was marked as done.
- Current Streak: The number of consecutive days the habit has been completed.
- Creation Date: When the habit was initially added.
The Habit Data Model
First, let's define our data model for a single habit. We'll also include a utility extension for easy date comparisons.
// lib/utils/date_time_extension.dart
extension DateTimeExtension on DateTime {
bool isSameDay(DateTime other) {
return year == other.year && month == other.month && day == other.day;
}
bool isYesterday(DateTime other) {
final yesterday = other.subtract(const Duration(days: 1));
return year == yesterday.year && month == yesterday.month && day == yesterday.day;
}
}
// lib/models/habit.dart
import 'package:flutter/foundation.dart';
import '../utils/date_time_extension.dart';
class Habit {
final String id;
String name;
DateTime? lastCompletedDate;
int _currentStreak; // Internal streak count
final DateTime creationDate;
Habit({
required this.id,
required this.name,
this.lastCompletedDate,
int initialStreak = 0,
required this.creationDate,
}) : _currentStreak = initialStreak;
// Calculates the effective streak for display, considering missed days.
int get effectiveStreak {
if (lastCompletedDate == null) return 0;
final now = DateTime.now();
// If completed today, the streak is as recorded.
if (lastCompletedDate!.isSameDay(now)) {
return _currentStreak;
}
// If completed yesterday, it's still current, but needs to be completed today to increment.
// Display the recorded streak for continuity.
if (lastCompletedDate!.isYesterday(now)) {
return _currentStreak;
}
// Otherwise, it was missed, so the streak resets to 0 for display.
return 0;
}
// Marks the habit as done for today and updates the internal streak count.
void markDone() {
final now = DateTime.now();
if (lastCompletedDate != null && lastCompletedDate!.isSameDay(now)) {
// Already done today, no change.
return;
}
if (lastCompletedDate != null && lastCompletedDate!.isYesterday(now)) {
// Habit was done yesterday, so increment streak.
_currentStreak++;
} else {
// Habit was not done yesterday (or never done before), start a new streak.
_currentStreak = 1;
}
lastCompletedDate = now;
}
// Checks if the habit is marked as done for the current day.
bool get isDoneForToday {
if (lastCompletedDate == null) return false;
return lastCompletedDate!.isSameDay(DateTime.now());
}
// A convenience method to reset streak explicitly, if needed (e.g., for editing).
void resetStreak() {
_currentStreak = 0;
lastCompletedDate = null;
}
}
Streak Calculation Logic
The `effectiveStreak` getter in our `Habit` model is crucial. It dynamically calculates the streak based on the `lastCompletedDate` and the current date. If the habit was not completed yesterday or today, the streak effectively resets to zero for display purposes, even if the internal `_currentStreak` value holds a historical high. The `markDone()` method correctly updates the internal `_currentStreak` based on whether the habit was done yesterday or not.
Designing the UI with Flutter
Our UI will consist of a main screen displaying a list of habits, and individual "Habit Tile" widgets for each habit. Each tile will show the habit's name, its current status (done/not done), and the effective streak.
The Habit Tile Widget
This widget will represent a single habit, allowing users to mark it as complete for the day and view its streak.
// lib/widgets/habit_tile.dart
import 'package:flutter/material.dart';
import 'package:habit_tracker/models/habit.dart'; // Ensure correct path
class HabitTile extends StatelessWidget {
final Habit habit;
final VoidCallback onToggle;
const HabitTile({
Key? key,
required this.habit,
required this.onToggle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
elevation: 2.0,
child: ListTile(
contentPadding: const EdgeInsets.all(12.0),
leading: Checkbox(
value: habit.isDoneForToday,
onChanged: (bool? newValue) {
onToggle();
},
activeColor: Colors.deepPurple,
),
title: Text(
habit.name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
decoration: habit.isDoneForToday ? TextDecoration.lineThrough : TextDecoration.none,
color: habit.isDoneForToday ? Colors.grey : Colors.black87,
),
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.deepPurple.shade100,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_fire_department, color: Colors.orange, size: 18),
const SizedBox(width: 4),
Text(
'${habit.effectiveStreak}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.deepPurple,
),
),
],
),
),
onTap: onToggle, // Tapping the tile also toggles the habit
),
);
}
}
The Habit Tracker Screen
This `StatefulWidget` will manage the list of habits and their states. For simplicity, we'll use in-memory state management with `setState`. In a real application, you might use a state management solution like Provider, Riverpod, or BLoC, and persist data to storage.
// lib/screens/habit_tracker_screen.dart
import 'package:flutter/material.dart';
import 'package:habit_tracker/models/habit.dart'; // Ensure correct path
import 'package:habit_tracker/widgets/habit_tile.dart'; // Ensure correct path
class HabitTrackerScreen extends StatefulWidget {
const HabitTrackerScreen({Key? key}) : super(key: key);
@override
State createState() => _HabitTrackerScreenState();
}
class _HabitTrackerScreenState extends State {
// Sample habits
final List _habits = [
Habit(id: '1', name: 'Drink 2 Liters of Water', creationDate: DateTime.now().subtract(const Duration(days: 5))),
Habit(id: '2', name: 'Read 20 Pages', creationDate: DateTime.now().subtract(const Duration(days: 3))),
Habit(id: '3', name: 'Exercise for 30 Mins', creationDate: DateTime.now().subtract(const Duration(days: 10))),
];
void _toggleHabit(Habit habit) {
setState(() {
if (habit.isDoneForToday) {
// If already done today, treat as "undo"
habit.lastCompletedDate = habit.lastCompletedDate!.subtract(const Duration(days: 1)); // This simplification for undo isn't perfect for streak management,
// a more robust solution would track all completion dates.
// For this article, we focus on the markDone logic.
// A better undo logic would require tracking the *previous* streak state
// and last completion date prior to marking today's completion.
// For simplicity, we'll assume habit is only toggled ON for this demo.
// The effectiveStreak getter will handle displaying 0 if missed.
} else {
habit.markDone();
}
// Re-evaluate all habits' effective streaks after a change, especially if using persistence
_habits.forEach((h) => h.effectiveStreak);
});
}
// A method to simulate daily reset for demonstration if needed,
// typically handled by the effectiveStreak getter itself.
void _checkAllHabitsForDailyReset() {
setState(() {
// The effectiveStreak getter automatically handles resets when accessed.
// This explicit loop isn't strictly necessary for the current Habit model,
// but good to show where daily maintenance logic would go.
_habits.forEach((habit) {
if (!habit.isDoneForToday && !habit.lastCompletedDate!.isYesterday(DateTime.now())) {
// If the habit was missed yesterday and not done today, the effective streak will be 0.
// The model's effectiveStreak getter handles this without modifying the stored _currentStreak.
}
});
});
}
@override
void initState() {
super.initState();
// Simulate some initial completions for demo purposes
_habits[0].markDone(); // Done today
_habits[0].markDone(); // Still done today, no change
// Simulate a streak for habit 1
_habits[0].lastCompletedDate = DateTime.now().subtract(const Duration(days: 1));
_habits[0].markDone(); // Done today, streak 2
// Simulate an older completion for habit 2
_habits[1].lastCompletedDate = DateTime.now().subtract(const Duration(days: 3));
_habits[1].markDone(); // Should restart streak to 1
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Habits'),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
body: ListView.builder(
itemCount: _habits.length,
itemBuilder: (context, index) {
final habit = _habits[index];
return HabitTile(
habit: habit,
onToggle: () => _toggleHabit(habit),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement "Add New Habit" functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Add New Habit functionality coming soon!')),
);
},
backgroundColor: Colors.deepPurple,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
}
Putting It All Together
Finally, we need to set up our `main.dart` file to run the application.
// main.dart
import 'package:flutter/material.dart';
import 'package:habit_tracker/screens/habit_tracker_screen.dart'; // Ensure correct path
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Habit Tracker',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const HabitTrackerScreen(),
);
}
}
Enhancements and Further Considerations
This basic structure provides a solid foundation. Here are some ideas for further enhancements:
- Persistence: Currently, habits are stored in memory and reset on app restart. Implement local storage (e.g., using shared_preferences or Hive) or a backend (e.g., Firebase) to save habit data.
- Add/Edit Habits: Implement forms to add new habits and modify existing ones.
- Notifications: Schedule daily reminders to complete habits.
- Historical View: Display a calendar view to see habit completion history.
- Advanced Streak Logic: Consider "skip days" or "buffer days" for habits that aren't daily.
- State Management: For larger apps, replace `setState` with a more robust solution like Provider, Riverpod, or BLoC for cleaner state management across widgets.
Conclusion
Building a habit tracker with a streak counter in Flutter demonstrates the framework's power for creating engaging and functional mobile applications. By carefully designing your data model and implementing logical streak calculation, you can provide users with a compelling tool to stay consistent with their goals. This article has covered the essential components, from data modeling to UI implementation, giving you a strong starting point for your own Flutter habit tracker.