Building a Habit Tracker Widget with Daily Habits, Streaks, Notifications, and Reminders in Flutter
Habit tracking applications have become indispensable tools for personal development, helping users cultivate positive behaviors and break negative ones. Building such an application in Flutter offers a powerful way to create cross-platform solutions with a beautiful, performant UI. This article will guide you through the process of developing a core habit tracker widget, incorporating daily habit completion, streak tracking, and essential notification and reminder functionalities.
1. Understanding the Core Components
Before diving into the code, let's outline the essential features we aim to implement:
- Daily Habit Tracking: Users can mark habits as complete for the current day.
- Streak Management: Calculate and display the current consecutive days a habit has been completed.
- Notifications: Deliver timely alerts to remind users about their habits.
- Reminders: Schedule specific times for these notifications.
2. Data Model for a Habit
A robust data model is the foundation of any application. For our habit tracker, each habit will require several key pieces of information:
id: Unique identifier for the habit.name: The name of the habit (e.g., "Drink Water", "Read a Book").lastCompletionDate: The last date the habit was marked as complete. This is crucial for daily tracking and streaks.currentStreak: The number of consecutive days the habit has been completed.longestStreak: The highest streak achieved for this habit.reminderTime: ATimeOfDayobject indicating when a daily reminder should be sent.isEnabled: A boolean to toggle the habit's active status.
Here's a basic Dart class for our Habit model:
import 'package:flutter/material.dart';
class Habit {
final String id;
String name;
DateTime? lastCompletionDate;
int currentStreak;
int longestStreak;
TimeOfDay? reminderTime;
bool isEnabled;
Habit({
required this.id,
required this.name,
this.lastCompletionDate,
this.currentStreak = 0,
this.longestStreak = 0,
this.reminderTime,
this.isEnabled = true,
});
// Helper method to check if the habit was completed today
bool get isCompletedToday {
if (lastCompletionDate == null) return false;
final now = DateTime.now();
return lastCompletionDate!.year == now.year &&
lastCompletionDate!.month == now.month &&
lastCompletionDate!.day == now.day;
}
// Method to check if the habit was completed yesterday
bool _isCompletedYesterday(DateTime today) {
if (lastCompletionDate == null) return false;
final yesterday = today.subtract(const Duration(days: 1));
return lastCompletionDate!.year == yesterday.year &&
lastCompletionDate!.month == yesterday.month &&
lastCompletionDate!.day == yesterday.day;
}
// Method to mark habit as complete and update streaks
void completeHabit() {
final now = DateTime.now();
if (!isCompletedToday) { // Only update if not already completed today
if (_isCompletedYesterday(now)) {
currentStreak++; // Continue streak
} else {
currentStreak = 1; // Start new streak
}
lastCompletionDate = now;
if (currentStreak > longestStreak) {
longestStreak = currentStreak;
}
}
}
// Method to reset streak if not completed on time (e.g., at midnight)
void resetStreakIfBroken() {
final now = DateTime.now();
if (lastCompletionDate == null) {
currentStreak = 0;
return;
}
final difference = now.difference(lastCompletionDate!);
// If last completion was more than 24 hours ago and not yesterday, break streak
// Or if it was yesterday but today is not completed yet, and it's past midnight.
if (difference.inDays > 1 || (difference.inDays == 1 && !isCompletedToday)) {
if (lastCompletionDate!.day != now.subtract(const Duration(days: 1)).day) {
currentStreak = 0;
}
}
}
// Convert Habit to JSON for persistence
Map toJson() {
return {
'id': id,
'name': name,
'lastCompletionDate': lastCompletionDate?.toIso8601String(),
'currentStreak': currentStreak,
'longestStreak': longestStreak,
'reminderTimeHour': reminderTime?.hour,
'reminderTimeMinute': reminderTime?.minute,
'isEnabled': isEnabled,
};
}
// Create Habit from JSON
factory Habit.fromJson(Map json) {
DateTime? lastDate;
if (json['lastCompletionDate'] != null) {
lastDate = DateTime.parse(json['lastCompletionDate']);
}
TimeOfDay? time;
if (json['reminderTimeHour'] != null && json['reminderTimeMinute'] != null) {
time = TimeOfDay(
hour: json['reminderTimeHour'],
minute: json['reminderTimeMinute'],
);
}
return Habit(
id: json['id'],
name: json['name'],
lastCompletionDate: lastDate,
currentStreak: json['currentStreak'] ?? 0,
longestStreak: json['longestStreak'] ?? 0,
reminderTime: time,
isEnabled: json['isEnabled'] ?? true,
);
}
}
3. Building the Habit Widget UI
Each habit will be represented by a card or list tile, displaying its name, current streak, and a way to mark it as complete. We'll use a simple ListTile for this example.
import 'package:flutter/material.dart';
// Assuming Habit model is in 'models/habit.dart'
// import 'package:your_app_name/models/habit.dart';
class HabitWidget extends StatefulWidget {
final Habit habit;
final VoidCallback onHabitToggled; // Callback when habit state changes
const HabitWidget({
Key? key,
required this.habit,
required this.onHabitToggled,
}) : super(key: key);
@override
State createState() => _HabitWidgetState();
}
class _HabitWidgetState extends State {
@override
void initState() {
super.initState();
// Potentially check and reset streak at midnight if the app is open
// For a more robust solution, this should be handled by a background task
// or when the app loads, checking against previous day.
widget.habit.resetStreakIfBroken();
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: ListTile(
leading: Checkbox(
value: widget.habit.isCompletedToday,
onChanged: (bool? newValue) {
if (newValue == true) {
setState(() {
widget.habit.completeHabit();
});
widget.onHabitToggled(); // Notify parent of change
}
},
),
title: Text(
widget.habit.name,
style: TextStyle(
decoration: widget.habit.isCompletedToday
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
subtitle: Text(
'Streak: ${widget.habit.currentStreak} days (Longest: ${widget.habit.longestStreak} days)',
),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// TODO: Implement habit editing functionality
print('Edit ${widget.habit.name}');
},
),
onTap: () {
// You could also toggle completion on tap of the whole card
if (!widget.habit.isCompletedToday) {
setState(() {
widget.habit.completeHabit();
});
widget.onHabitToggled();
}
},
),
);
}
}
4. Implementing Notifications and Reminders
For local notifications and reminders, the flutter_local_notifications package is an excellent choice. This package allows you to schedule notifications even when the app is closed.
4.1. Setup flutter_local_notifications
First, add the dependency to your pubspec.yaml:
dependencies:
flutter_local_notifications: ^17.1.2
timezone: ^0.9.3 # Required for scheduling notifications in specific timezones
Then, initialize the plugin, typically in your main.dart or a dedicated notification service.
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
class NotificationService {
static final NotificationService _notificationService = NotificationService._internal();
factory NotificationService() {
return _notificationService;
}
NotificationService._internal();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
Future init() async {
final AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('app_icon'); // 'app_icon' is the name of your drawable resource.
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
macOS: initializationSettingsIOS, // Use iOS settings for macOS
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse,
);
tz.initializeTimeZones(); // Initialize timezone data
}
void onDidReceiveNotificationResponse(NotificationResponse notificationResponse) async {
// Handle notification tap
final String? payload = notificationResponse.payload;
if (payload != null) {
debugPrint('notification payload: $payload');
}
// You can navigate to a specific screen based on the payload here
}
@pragma('vm:entry-point')
static void onDidReceiveBackgroundNotificationResponse(NotificationResponse notificationResponse) {
// Handle notification tap when app is in background/terminated
final String? payload = notificationResponse.payload;
if (payload != null) {
debugPrint('background notification payload: $payload');
}
}
NotificationDetails _notificationDetails() {
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails(
'habitTrackerChannelId', // id
'Habit Reminders', // name
channelDescription: 'Reminders for your daily habits', // description
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
const DarwinNotificationDetails iosNotificationDetails = DarwinNotificationDetails();
return const NotificationDetails(
android: androidNotificationDetails,
iOS: iosNotificationDetails,
);
}
Future scheduleDailyHabitReminder(
int id,
String title,
String body,
TimeOfDay reminderTime,
) async {
await flutterLocalNotificationsPlugin.zonedSchedule(
id,
title,
body,
_nextInstanceOfTime(reminderTime),
_notificationDetails(),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteLocal,
matchDateTimeComponents: DateTimeComponents.time, // Repeat daily at this time
payload: 'habit_reminder_$id',
);
}
tz.TZDateTime _nextInstanceOfTime(TimeOfDay time) {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduledDate = tz.TZDateTime(
tz.local,
now.year,
now.month,
now.day,
time.hour,
time.minute,
);
if (scheduledDate.isBefore(now)) {
scheduledDate = scheduledDate.add(const Duration(days: 1));
}
return scheduledDate;
}
Future cancelNotification(int id) async {
await flutterLocalNotificationsPlugin.cancel(id);
}
}
4.2. Scheduling Reminders for Habits
When a habit is created or its reminder time is updated, you'll want to schedule or reschedule its notification. Each habit should have a unique notification ID (e.g., derived from its hashCode or a dedicated integer ID).
// In your main habit management logic (e.g., a service or provider)
// Function to set or update a habit's reminder
void setHabitReminder(Habit habit) {
// Cancel any existing reminder for this habit first
NotificationService().cancelNotification(habit.id.hashCode);
if (habit.isEnabled && habit.reminderTime != null) {
NotificationService().scheduleDailyHabitReminder(
habit.id.hashCode,
'Time for ${habit.name}!',
'Don\'t forget to complete your habit today.',
habit.reminderTime!,
);
}
}
// Example usage when creating a new habit or updating existing one:
// Habit newHabit = Habit(
// id: 'unique_habit_id_1',
// name: 'Go for a walk',
// reminderTime: TimeOfDay(hour: 08, minute: 00),
// );
// setHabitReminder(newHabit);
5. Putting It All Together (Main Screen)
Your main screen would typically fetch a list of habits (from a local database like Hive or SQLite, or persistent storage like shared_preferences) and display them using ListView.builder.
import 'package:flutter/material.dart';
// import 'package:your_app_name/models/habit.dart';
// import 'package:your_app_name/widgets/habit_widget.dart';
// import 'package:your_app_name/services/notification_service.dart';
// You would also need a way to persist/load habits, e.g., using shared_preferences for simplicity
class HabitListScreen extends StatefulWidget {
const HabitListScreen({Key? key}) : super(key: key);
@override
State createState() => _HabitListScreenState();
}
class _HabitListScreenState extends State {
List _habits = [];
@override
void initState() {
super.initState();
_loadHabits();
// Initialize notification service early
NotificationService().init();
}
Future _loadHabits() async {
// In a real app, you'd load from a database or shared preferences
// For this example, we'll use a hardcoded list
setState(() {
_habits = [
Habit(
id: '1',
name: 'Drink 8 glasses of water',
lastCompletionDate: DateTime.now().subtract(const Duration(days: 1)),
currentStreak: 5,
longestStreak: 10,
reminderTime: const TimeOfDay(hour: 9, minute: 0),
),
Habit(
id: '2',
name: 'Read for 30 minutes',
lastCompletionDate: DateTime.now(), // Completed today
currentStreak: 2,
longestStreak: 5,
reminderTime: const TimeOfDay(hour: 20, minute: 30),
),
Habit(
id: '3',
name: 'Meditate for 10 minutes',
currentStreak: 0,
longestStreak: 3,
reminderTime: const TimeOfDay(hour: 7, minute: 0),
),
];
});
// Schedule reminders for all loaded habits
for (var habit in _habits) {
if (habit.isEnabled && habit.reminderTime != null) {
NotificationService().scheduleDailyHabitReminder(
habit.id.hashCode,
'Time for ${habit.name}!',
'Don\'t forget to complete your habit today.',
habit.reminderTime!,
);
}
}
}
void _onHabitToggled() {
// This callback is triggered when a habit's completion status changes.
// In a real app, you would persist the updated habit list here.
// For now, we just update the UI state.
setState(() {
// Re-evaluate state, or you could specifically update the habit that changed.
});
// You might also want to reschedule notifications if reminder time changed,
// though for simple completion, it's not strictly necessary.
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Habits'),
),
body: _habits.isEmpty
? const Center(child: Text('No habits added yet!'))
: ListView.builder(
itemCount: _habits.length,
itemBuilder: (context, index) {
return HabitWidget(
habit: _habits[index],
onHabitToggled: _onHabitToggled,
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement adding new habit functionality
print('Add new habit');
},
child: const Icon(Icons.add),
),
);
}
}
6. Conclusion
This article has provided a comprehensive guide to building a fundamental habit tracker widget in Flutter. We've covered the crucial aspects of designing a data model for habits, implementing the UI for daily tracking and streak visualization, and integrating local notifications for timely reminders. By leveraging the flutter_local_notifications package and careful state management, you can create an engaging and effective tool to help users build better habits. Further enhancements could include data persistence (e.g., using a database), habit analytics, custom themes, and more advanced reminder options.