Habit Tracker: Light and Dark
Part of the Projects section of Coddy's Flutter journey. Lesson 36 of 36.
People check habits late at night, so the last feature is dark mode, with a button in the top bar: a moon to turn it on, a sun to turn it back.
The theme lives in MaterialApp, so the switch does too: MyApp becomes a StatefulWidget with a bool _dark. MaterialApp gets both themes and picks one with themeMode:
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.green,
brightness: Brightness.dark,
),
),
themeMode: _dark ? ThemeMode.dark : ThemeMode.light,The button is in the habit screen, far below MyApp. Lift the state up once more: HabitScreen gets dark to pick its icon and an onToggleTheme callback to call. The screen reads them from its widget as widget.dark and widget.onToggleTheme.
Every color in the app came from the theme, so the cards, the circles and the ring all switch with it. Your habits stay ticked, since they live in the screen's state, not in the theme.
This is the last lesson of the course's projects. The checks use the whole tracker: add, tick, streak, delete, the ring and dark mode.
Challenge
HardAdd dark mode:
- make
MyAppaStatefulWidgetwithbool _dark = false, giveMaterialAppadarkThemefrom the same green seed withBrightness.dark, and setthemeModefrom_dark - give
HabitScreenarequiredbool darkandVoidCallback onToggleTheme;MyApppasses_darkand a function that flips it insetState - in the top bar's
actions, anIconButtonshowsIcons.dark_modein light mode andIcons.light_modein dark mode, and callswidget.onToggleTheme
Try it yourself
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
),
home: const HabitScreen(),
);
}
}
const List<String> dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
class Habit {
Habit(this.name, this.days);
final String name;
final List<bool> days;
int get done => days.where((day) => day).length;
int get streak {
int best = 0;
int run = 0;
for (bool day in days) {
if (day) {
run++;
if (run > best) best = run;
} else {
run = 0;
}
}
return best;
}
}
class HabitScreen extends StatefulWidget {
const HabitScreen({super.key});
@override
State<HabitScreen> createState() => _HabitScreenState();
}
class _HabitScreenState extends State<HabitScreen> {
final List<Habit> _habits = [
Habit('Drink water', [true, true, true, false, true, false, false]),
Habit('Read 10 pages', [true, false, true, true, false, false, false]),
Habit('Stretch', List.filled(7, false)),
];
int get _ticks => _habits.fold(0, (sum, habit) => sum + habit.done);
int get _possible => _habits.length * 7;
void _toggle(Habit habit, int day) {
setState(() {
habit.days[day] = !habit.days[day];
});
}
Future<void> _addHabit() async {
String name = '';
final added = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('New habit'),
content: TextField(
autofocus: true,
decoration: const InputDecoration(labelText: 'Habit name'),
onChanged: (value) => name = value,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Add'),
),
],
),
);
if (added != true || name.trim().isEmpty) return;
setState(() {
_habits.add(Habit(name.trim(), List.filled(7, false)));
});
}
Future<void> _deleteHabit(Habit habit) async {
final delete = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete ${habit.name}?'),
content: const Text('Its ticks for this week go with it.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (delete != true) return;
setState(() {
_habits.remove(habit);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Habits')),
floatingActionButton: FloatingActionButton(
onPressed: _addHabit,
child: const Icon(Icons.add),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
children: [
ProgressCard(ticks: _ticks, possible: _possible),
for (Habit habit in _habits)
HabitCard(
habit: habit,
onToggle: (day) => _toggle(habit, day),
onLongPress: () => _deleteHabit(habit),
),
],
),
);
}
}
class ProgressCard extends StatelessWidget {
const ProgressCard({super.key, required this.ticks, required this.possible});
final int ticks;
final int possible;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final progress = possible == 0 ? 0.0 : ticks / possible;
return Card(
margin: EdgeInsets.zero,
color: colors.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
SizedBox(
width: 72,
height: 72,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 8,
strokeCap: StrokeCap.round,
backgroundColor: colors.surface,
),
Center(
child: Text(
'${(progress * 100).round()}%',
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('This week', style: textTheme.titleLarge),
const SizedBox(height: 4),
Text('$ticks of $possible ticks'),
],
),
),
],
),
),
);
}
}
class HabitCard extends StatelessWidget {
const HabitCard({
super.key,
required this.habit,
required this.onToggle,
required this.onLongPress,
});
final Habit habit;
final void Function(int day) onToggle;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.only(top: 12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(habit.name, style: textTheme.titleMedium)),
Text('${habit.done} of 7 days', style: textTheme.bodySmall),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (int i = 0; i < 7; i++)
DayCircle(
label: dayNames[i],
done: habit.days[i],
onTap: () => onToggle(i),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.local_fire_department,
size: 20,
color: habit.streak > 0 ? Colors.orange : Theme.of(context).colorScheme.outline,
),
const SizedBox(width: 4),
Text(habit.streak == 0 ? 'No streak yet' : '${habit.streak} day streak'),
],
),
],
),
),
),
);
}
}
class DayCircle extends StatelessWidget {
const DayCircle({super.key, required this.label, required this.done, required this.onTap});
final String label;
final bool done;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: done ? colors.primary : colors.surfaceContainerHighest,
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: done ? colors.onPrimary : colors.onSurfaceVariant,
),
),
),
);
}
}
This lesson includes a short quiz. Start the lesson to answer it and track your progress.
All lessons in Projects
1Project: To-do List
To-do List: The Task ListTo-do List: Add a Task4Project: Habit Tracker
Habit Tracker: Your HabitsHabit Tracker: The WeekPractice on your own: Online Flutter compiler