Building a Habit Tracker Widget with Weekly Progress, Streaks, and Notifications in Flutter
Habit tracking applications are powerful tools for personal development, helping users build consistency and achieve their goals. Creating a custom habit tracker widget in Flutter allows for a highly personalized and engaging user experience. This article will guide you through the process of building such a widget, incorporating key features like weekly progress visualization, streak calculation, and local notifications.
Understanding the Core Components
Before diving into the code, let's outline the fundamental components required for our habit tracker:
- Data Model: To store habit details and their completion status.
- Weekly Progress: A visual representation of a habit's completion over the past seven days.
- Streaks: Logic to calculate and display consecutive days a habit has been completed.
- Notifications: Scheduled reminders to prompt the user to complete their habits.
- State Management: How the widget will react to user interactions and data changes.
1. Data Model for Habits
We need a robust data model to represent a habit. This model should include basic information like the habit's name, a unique identifier, and most importantly, a list of dates when the habit was completed. For simplicity, we'll store dates as DateTime objects.
import 'package:flutter/material.dart';
class Habit {
final String id;
String name;
String description;
List<DateTime> completedDates;
TimeOfDay? reminderTime;
Habit({
required this.id,
required this.name,
this.description = '',
this.completedDates = const [],
this.reminderTime,
});
// Helper to check if a habit was completed on a specific day
bool isCompletedOn(DateTime date) {
return completedDates.any(
(d) => d.year == date.year && d.month == date.month && d.day == date.day,
);
}
// Add completion for today
void toggleCompletionForDate(DateTime date, {required bool isCompleted}) {
// Normalize date to remove time component for comparison
final normalizedDate = DateTime(date.year, date.month, date.day);
if (isCompleted) {
if (!isCompletedOn(normalizedDate)) {
completedDates = List.from(completedDates)..add(normalizedDate);
}
} else {
completedDates = completedDates.where(
(d) => !(d.year == normalizedDate.year && d.month == normalizedDate.month && d.day == normalizedDate.day)
).toList();
}
// Optionally sort completedDates for streak calculation efficiency
completedDates.sort((a, b) => a.compareTo(b));
}
// Convert Habit to JSON for persistence
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'completedDates': completedDates.map((date) => date.toIso8601String()).toList(),
'reminderTimeHour': reminderTime?.hour,
'reminderTimeMinute': reminderTime?.minute,
};
}
// Create Habit from JSON
factory Habit.fromJson(Map<String, dynamic> json) {
return Habit(
id: json['id'],
name: json['name'],
description: json['description'] ?? '',
completedDates: (json['completedDates'] as List<dynamic>?)
?.map((dateString) => DateTime.parse(dateString))
.toList() ?? [],
reminderTime: json['reminderTimeHour'] != null && json['reminderTimeMinute'] != null
? TimeOfDay(hour: json['reminderTimeHour'], minute: json['reminderTimeMinute'])
: null,
);
}
}
2. Implementing Weekly Progress Display
The weekly progress display will show the user a visual overview of their habit completion for the last seven days. This typically involves a row of clickable indicators (e.g., circles or squares), where each indicator represents a day.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // For date formatting
class WeeklyProgressIndicator extends StatelessWidget {
final Habit habit;
final Function(DateTime date, bool isCompleted) onToggleCompletion;
const WeeklyProgressIndicator({
Key? key,
required this.habit,
required this.onToggleCompletion,
}) : super(key: key);
@override
Widget build(BuildContext context) {
List<Widget> dayIndicators = [];
DateTime now = DateTime.now();
for (int i = 6; i >= 0; i--) { // Iterate for the last 7 days including today
DateTime day = DateTime(now.year, now.month, now.day).subtract(Duration(days: i));
bool isCompleted = habit.isCompletedOn(day);
dayIndicators.add(
GestureDetector(
onTap: () {
onToggleCompletion(day, !isCompleted);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Column(
children: [
Text(
i == 0 ? 'Today' : DateFormat('EEE').format(day), // Show 'Today' or day of week
style: Theme.of(context).textTheme.bodySmall,
),
SizedBox(height: 4),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted ? Colors.green : Colors.grey[300],
border: Border.all(
color: isCompleted ? Colors.greenAccent : Colors.grey,
width: 1.5,
),
),
child: isCompleted
? Icon(Icons.check, size: 18, color: Colors.white)
: Container(),
),
],
),
),
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: dayIndicators.reversed.toList(), // Display from past to today
);
}
}
3. Calculating Streaks
Streaks encourage users by showing them their consistent effort. A "current streak" tracks consecutive completion up to today or yesterday. A "longest streak" records the maximum consecutive days achieved.
// Inside the Habit class or a dedicated utility class
extension HabitStreaks on Habit {
int calculateCurrentStreak() {
if (completedDates.isEmpty) return 0;
int streak = 0;
// Sort for consistent streak calculation
List<DateTime> sortedCompletedDates = List.from(completedDates);
sortedCompletedDates.sort((a, b) => a.compareTo(b));
DateTime now = DateTime.now();
DateTime today = DateTime(now.year, now.month, now.day);
DateTime yesterday = today.subtract(const Duration(days: 1));
// Check if habit was completed today or yesterday to start the streak
bool completedToday = isCompletedOn(today);
bool completedYesterday = isCompletedOn(yesterday);
if (!completedToday && !completedYesterday) return 0; // Streak broken
// Start checking from the most recent date
for (int i = sortedCompletedDates.length - 1; i >= 0; i--) {
DateTime currentCompletion = sortedCompletedDates[i];
DateTime normalizedCompletion = DateTime(currentCompletion.year, currentCompletion.month, currentCompletion.day);
if (streak == 0) { // First iteration
if (normalizedCompletion == today) {
streak = 1;
} else if (normalizedCompletion == yesterday && !completedToday) {
// If today wasn't completed but yesterday was, the streak still counts up to yesterday
streak = 1;
} else {
// If the most recent completion is neither today nor yesterday, no current streak.
return 0;
}
} else {
DateTime expectedPreviousDay = DateTime(sortedCompletedDates[i+1].year, sortedCompletedDates[i+1].month, sortedCompletedDates[i+1].day).subtract(const Duration(days: 1));
if (normalizedCompletion == expectedPreviousDay) {
streak++;
} else {
// Gap found, streak broken
break;
}
}
}
return streak;
}
int calculateLongestStreak() {
if (completedDates.isEmpty) return 0;
int longestStreak = 0;
int currentConsecutive = 0;
List<DateTime> sortedCompletedDates = List.from(completedDates);
sortedCompletedDates.sort((a, b) => a.compareTo(b));
if (sortedCompletedDates.isNotEmpty) {
currentConsecutive = 1; // Start with the first date
longestStreak = 1;
for (int i = 1; i < sortedCompletedDates.length; i++) {
DateTime prevDay = DateTime(sortedCompletedDates[i-1].year, sortedCompletedDates[i-1].month, sortedCompletedDates[i-1].day);
DateTime currDay = DateTime(sortedCompletedDates[i].year, sortedCompletedDates[i].month, sortedCompletedDates[i].day);
// Check if the current day is exactly one day after the previous day
if (currDay.difference(prevDay).inDays == 1) {
currentConsecutive++;
} else {
currentConsecutive = 1; // Reset streak
}
if (currentConsecutive > longestStreak) {
longestStreak = currentConsecutive;
}
}
}
return longestStreak;
}
}
4. Implementing Local Notifications
Flutter's flutter_local_notifications package is ideal for scheduling local notifications. Users can set a specific time for their habit reminders.
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz;
class NotificationService {
static final NotificationService _notificationService = NotificationService._internal();
factory NotificationService() {
return _notificationService;
}
NotificationService._internal();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
Future<void> init() async {
tz.initializeTimeZones(); // Initialize timezone data
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const IOSInitializationSettings initializationSettingsIOS =
IOSInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid, iOS: initializationSettingsIOS);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
Future<void> showNotification({
required int id,
required String title,
required String body,
required TimeOfDay scheduledTime,
}) async {
// Schedule daily notification
await flutterLocalNotificationsPlugin.zonedSchedule(
id,
title,
body,
_nextInstanceOfTime(scheduledTime),
const NotificationDetails(
android: AndroidNotificationDetails(
'habit_tracker_channel',
'Habit Reminders',
channelDescription: 'Reminders for your daily habits',
importance: Importance.high,
priority: Priority.high,
ticker: 'ticker',
),
iOS: IOSNotificationDetails(),
),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time, // Repeat daily at the specified time
);
}
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<void> cancelNotification(int id) async {
await flutterLocalNotificationsPlugin.cancel(id);
}
}
Remember to add flutter_local_notifications and timezone to your pubspec.yaml.
dependencies:
flutter:
sdk: flutter
flutter_local_notifications: ^9.x.x
timezone: ^0.8.0
intl: ^0.18.0 # For date formatting in WeeklyProgressIndicator
Also, ensure you configure necessary permissions and settings for Android and iOS. For Android, you might need to update your AndroidManifest.xml. For iOS, requesting permissions is handled by the plugin.
5. Putting It All Together: The Habit Widget
Now, let's combine these elements into a single, interactive habit widget. This will typically be a StatefulWidget to manage the habit's completion state and trigger updates.
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; // For unique IDs
// Assuming Habit, WeeklyProgressIndicator, HabitStreaks, NotificationService are defined above.
class HabitTrackerWidget extends StatefulWidget {
final Habit habit;
final Function(Habit updatedHabit) onHabitUpdated;
const HabitTrackerWidget({
Key? key,
required this.habit,
required this.onHabitUpdated,
}) : super(key: key);
@override
_HabitTrackerWidgetState createState() => _HabitTrackerWidgetState();
}
class _HabitTrackerWidgetState extends State<HabitTrackerWidget> {
late Habit _currentHabit;
final NotificationService _notificationService = NotificationService();
@override
void initState() {
super.initState();
_currentHabit = widget.habit;
_scheduleReminderIfNeeded();
}
void _scheduleReminderIfNeeded() {
if (_currentHabit.reminderTime != null) {
_notificationService.showNotification(
id: int.parse(_currentHabit.id.replaceAll(RegExp('[^0-9]'), '')).abs() % 2147483647, // Generate int from String ID
title: 'Habit Reminder',
body: 'Time to ${_currentHabit.name}!',
scheduledTime: _currentHabit.reminderTime!,
);
} else {
// If reminder time is removed, cancel existing notification
_notificationService.cancelNotification(int.parse(_currentHabit.id.replaceAll(RegExp('[^0-9]'), '')).abs() % 2147483647);
}
}
void _handleToggleCompletion(DateTime date, bool isCompleted) {
setState(() {
_currentHabit.toggleCompletionForDate(date, isCompleted: isCompleted);
widget.onHabitUpdated(_currentHabit); // Notify parent of the change
});
}
Future<void> _pickReminderTime() async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: _currentHabit.reminderTime ?? TimeOfDay.now(),
);
if (picked != null && picked != _currentHabit.reminderTime) {
setState(() {
_currentHabit.reminderTime = picked;
widget.onHabitUpdated(_currentHabit);
_scheduleReminderIfNeeded();
});
}
}
@override
Widget build(BuildContext context) {
final currentStreak = _currentHabit.calculateCurrentStreak();
final longestStreak = _currentHabit.calculateLongestStreak();
return Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentHabit.name,
style: Theme.of(context).textTheme.headline6,
),
const SizedBox(height: 8),
Text(
_currentHabit.description,
style: Theme.of(context).textTheme.bodyText2,
),
const Divider(),
WeeklyProgressIndicator(
habit: _currentHabit,
onToggleCompletion: _handleToggleCompletion,
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: [
Text('Current Streak', style: Theme.of(context).textTheme.bodySmall),
Text('$currentStreak days', style: Theme.of(context).textTheme.titleLarge),
],
),
Column(
children: [
Text('Longest Streak', style: Theme.of(context).textTheme.bodySmall),
Text('$longestStreak days', style: Theme.of(context).textTheme.titleLarge),
],
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: _pickReminderTime,
icon: Icon(_currentHabit.reminderTime == null ? Icons.notifications_off : Icons.notifications_active),
label: Text(
_currentHabit.reminderTime == null
? 'Set Reminder'
: 'Reminder at ${_currentHabit.reminderTime!.format(context)}',
),
),
),
],
),
),
);
}
}
This HabitTrackerWidget takes a Habit object and a callback onHabitUpdated to communicate changes back to its parent (e.g., a list of habits manager). It orchestrates the weekly progress display, streak calculations, and reminder settings. For generating unique IDs, consider using the uuid package.
Conclusion
Building a habit tracker widget in Flutter involves careful data modeling, intuitive UI design for weekly progress, robust logic for streak calculation, and reliable local notification scheduling. By combining these elements, you can create an engaging and functional tool to help users foster positive habits. This article provides a foundational structure; further enhancements could include data persistence (e.g., using Shared Preferences or a local database like Hive/SQLite), advanced statistics, and more customizable UI themes.