Building a Habit Tracker Widget with Weekly Progress Chart in Flutter
Habit trackers are powerful tools for self-improvement, helping users cultivate positive routines and monitor their progress over time. Developing such a tool in Flutter offers a highly customizable and visually appealing experience. This article will guide you through creating a core habit tracker widget featuring a clear weekly progress chart, demonstrating key Flutter concepts like state management, custom UI, and data modeling.
1. Designing the Habit Data Model
The foundation of any tracker is its data model. We need a way to represent a habit and its completion history. A simple Habit class can store the habit's name, a unique identifier, and a list of DateTime objects indicating when the habit was successfully completed.
import 'package:flutter/material.dart';
class Habit {
final String id;
final String name;
final List<DateTime> completionDates;
Habit({required this.id, required this.name, required this.completionDates});
// Helper to check if habit was completed on a specific date
bool isCompletedOn(DateTime date) {
return completionDates.any((d) =>
d.year == date.year && d.month == date.month && d.day == date.day);
}
// Helper to add or remove a completion date
Habit toggleCompletion(DateTime date) {
final updatedCompletionDates = List<DateTime>.from(completionDates);
if (isCompletedOn(date)) {
// Remove the completion date if it already exists
updatedCompletionDates.removeWhere((d) =>
d.year == date.year && d.month == date.month && d.day == date.day);
} else {
// Add the completion date if it doesn't exist
updatedCompletionDates.add(date);
}
// Return a new Habit instance with updated completion dates
return Habit(
id: id, name: name, completionDates: updatedCompletionDates);
}
}
The toggleCompletion method is particularly useful as it allows us to immutably update the habit's state, returning a new Habit object with the modified completionDates list. This approach aligns well with Flutter's state management principles.
2. Building the Habit Tracker Widget
Our habit tracker will be a StatefulWidget, allowing it to manage its internal state, specifically the completion status of the habit for each day. It will also accept a callback to notify a parent widget about changes, facilitating data persistence.
class HabitTrackerWidget extends StatefulWidget {
final Habit habit;
final ValueChanged<Habit> onHabitChanged;
const HabitTrackerWidget({
Key? key,
required this.habit,
required this.onHabitChanged,
}) : super(key: key);
@override
_HabitTrackerWidgetState createState() => _HabitTrackerWidgetState();
}
class _HabitTrackerWidgetState extends State<HabitTrackerWidget> {
// A local copy of the habit to manage UI updates
late Habit _currentHabit;
@override
void initState() {
super.initState();
_currentHabit = widget.habit;
}
// Update local habit if the parent widget provides a new habit instance
@override
void didUpdateWidget(covariant HabitTrackerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.habit != oldWidget.habit) {
setState(() {
_currentHabit = widget.habit;
});
}
}
// Toggles the completion status for a given day
void _toggleDayCompletion(DateTime date) {
setState(() {
_currentHabit = _currentHabit.toggleCompletion(date);
});
// Notify the parent widget about the change for potential persistence
widget.onHabitChanged(_currentHabit);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.0),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, 3), // changes position of shadow
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentHabit.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.deepPurple,
),
),
const SizedBox(height: 15),
_buildWeeklyProgressChart(), // The chart goes here
],
),
);
}
// Builds the row of 7 day cells for the weekly progress chart
Widget _buildWeeklyProgressChart() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(7, (index) {
final now = DateTime.now();
// Calculate the date for each day cell (last 7 days including today)
final day = DateTime(now.year, now.month, now.day)
.subtract(Duration(days: 6 - index));
return _buildDayCell(day);
}),
);
}
// Builds an individual day cell
Widget _buildDayCell(DateTime day) {
final bool isCompleted = _currentHabit.isCompletedOn(day);
final bool isToday = day.year == DateTime.now().year &&
day.month == DateTime.now().month &&
day.day == DateTime.now().day;
return GestureDetector(
onTap: () => _toggleDayCompletion(day),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isCompleted ? Colors.deepPurpleAccent : Colors.grey[200],
shape: BoxShape.circle,
border: isToday
? Border.all(color: Colors.deepPurple, width: 2.5)
: null,
),
child: Center(
child: Text(
_getWeekdayShortName(day.weekday),
style: TextStyle(
color: isCompleted ? Colors.white : Colors.grey[700],
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
),
);
}
// Helper to get short weekday names
String _getWeekdayShortName(int weekday) {
switch (weekday) {
case DateTime.monday:
return 'Mon';
case DateTime.tuesday:
return 'Tue';
case DateTime.wednesday:
return 'Wed';
case DateTime.thursday:
return 'Thu';
case DateTime.friday:
return 'Fri';
case DateTime.saturday:
return 'Sat';
case DateTime.sunday:
return 'Sun';
default:
return '';
}
}
}
Explanation of Key Components:
_HabitTrackerWidgetState: Manages the mutable state of the widget, including the currentHabitobject._toggleDayCompletion(DateTime date): This method updates the habit's completion status for a specific day. It usessetStateto trigger a UI rebuild and callswidget.onHabitChangedto propagate the change to its parent._buildWeeklyProgressChart(): This private method generates aRowcontaining seven individual day cells. It calculates the last seven days, including the current day, to display in the chart._buildDayCell(DateTime day): This is aGestureDetectorwrapped around aContainer. It visually represents a single day:- Its background color changes based on whether the habit was completed on that day (
_currentHabit.isCompletedOn(day)). - A distinct border highlights the current day.
- Tapping the cell invokes
_toggleDayCompletion, allowing users to mark or unmark a day.
- Its background color changes based on whether the habit was completed on that day (
_getWeekdayShortName(int weekday): A utility function to convert integer weekday values (1 for Monday, 7 for Sunday) into readable short names.
3. Integrating the Widget into an Application
To demonstrate the HabitTrackerWidget, let's embed it within a simple Flutter application. The parent widget (e.g., HabitListScreen) will hold a list of habits and manage their updates, simulating data persistence.
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Habit Tracker Demo',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
scaffoldBackgroundColor: Colors.deepPurple[50],
appBarTheme: const AppBarTheme(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
home: const HabitListScreen(),
);
}
}
class HabitListScreen extends StatefulWidget {
const HabitListScreen({Key? key}) : super(key: key);
@override
_HabitListScreenState createState() => _HabitListScreenState();
}
class _HabitListScreenState extends State<HabitListScreen> {
// Initial dummy habits for demonstration
List<Habit> _habits = [
Habit(
id: '1',
name: 'Drink 8 Glasses of Water',
completionDates: [
DateTime.now().subtract(const Duration(days: 1)), // Yesterday
DateTime.now().subtract(const Duration(days: 3)),
DateTime.now(), // Today
],
),
Habit(
id: '2',
name: 'Exercise for 30 Minutes',
completionDates: [
DateTime.now().subtract(const Duration(days: 0)), // Today
DateTime.now().subtract(const Duration(days: 2)),
],
),
Habit(
id: '3',
name: 'Read a Book',
completionDates: [
DateTime.now().subtract(const Duration(days: 4)),
],
),
];
// Callback function to update a habit in the list
void _updateHabit(Habit updatedHabit) {
setState(() {
_habits = _habits.map((habit) =>
habit.id == updatedHabit.id ? updatedHabit : habit).toList();
// In a real application, you would typically save `updatedHabit`
// to a persistent storage (e.g., SQLite, Firebase, SharedPreferences).
});
// Optional: Show a confirmation message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('"${updatedHabit.name}" updated!')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Habits'),
centerTitle: true,
),
body: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _habits.length,
itemBuilder: (context, index) {
final habit = _habits[index];
return HabitTrackerWidget(
habit: habit,
onHabitChanged: _updateHabit,
);
},
),
);
}
}
In this example, HabitListScreen maintains a list of Habit objects. When HabitTrackerWidget calls onHabitChanged, the _updateHabit method in HabitListScreen receives the modified habit and updates its internal list, triggering a rebuild of the UI to reflect the changes. This callback mechanism is where you would integrate your actual data persistence logic.
Conclusion
By combining a clear data model with Flutter's declarative UI, we've successfully created an interactive habit tracker widget complete with a weekly progress chart. This foundational component can be further enhanced with features like custom date ranges, habit categories, streak tracking, more sophisticated animations, and robust data persistence. Flutter's flexibility and rich widget set make it an excellent choice for building engaging and functional productivity tools.