No sections found
We couldn't find anything matching your search query. Try adjusting your keywords.
The 2026 Modern Learn Flutter Reference Guide
Welcome to the cutting edge of cross-platform development. Whether you are coming from Python or JavaScript, this guide will teach you the Dart language from scratch, and then walk you through building a production-ready, local-first Habit Tracker.
Part 1: The Dart Crash Course
1. Why Dart?
Before you can build a Flutter app, you must understand Dart. Flutter is simply a UI framework written in the Dart programming language.
If you know JavaScript (TypeScript) or Python, Dart will feel incredibly familiar. It is an object-oriented, C-style syntax language. However, unlike Python and plain JS, Dart is Statically Typed and strictly Null-Safe. If you try to call a method on a variable that might be null, your code will refuse to compile.
2. The Types Rosetta Stone
Here is how modern variable declarations map across the three languages. Notice that Dart requires types (or uses type inference via var/final), much like TypeScript.
| Concept | Dart (Statically Typed) | Python 3.12+ (Type Hints) | JS (ES2026) |
|---|---|---|---|
| Integer | int x = 42; | x: int = 42 | let x = 42; |
| Float | double pi = 3.14; | pi: float = 3.14 | let pi = 3.14; |
| String | String name = 'Hi'; | name: str = "Hi" | let name = "Hi"; |
| List/Array | List<int> nums = [1, 2]; | nums: list[int] = [1, 2] | const nums = [1, 2]; |
| Dictionary | Map<String, int> m = {'k': 1}; | m: dict[str, int] = {"k": 1} | const m = { "k": 1 }; |
| Null/None | int? val = null; | val: int | None = None | let val = null; |
3. Variables & Null Safety
In Python and JS, variables can change types at runtime. In Dart, once a variable is an integer, it is always an integer. Dart also enforces Sound Null Safety.
void main() {
// ---------------------------------------------------------
// 1. MUTABILITY: var vs final vs const
// ---------------------------------------------------------
// 'var' lets Dart infer the type (String). It can be changed later.
// Equivalent to JS 'let'.
var greeting = 'Hello';
greeting = 'Hi'; // Valid!
// 'final' means the variable can only be set ONCE.
// Equivalent to JS 'const'. We use this 95% of the time in modern Flutter!
final String user = 'Alice';
// user = 'Bob'; // ❌ ERROR: The final variable 'user' can only be set once.
// 'const' is a compile-time constant. It must be known before the app even runs.
const double pi = 3.14159;
// ---------------------------------------------------------
// 2. SOUND NULL SAFETY
// ---------------------------------------------------------
// By default, NO variable in Dart can be null. This prevents billions of crashes.
int age = 30;
// age = null; // ❌ ERROR: A value of type 'Null' can't be assigned to type 'int'.
// If you WANT a variable to be nullable, you must append a '?' to the type.
String? nickname = null;
// 🧠 EXPERT INSIGHT: The ?? Operator (Null Coalescing)
// When dealing with nullable variables, we often need a fallback.
// The '??' operator means "Use the left side if it's not null, otherwise use the right side".
String displayName = nickname ?? user;
print(displayName); // Prints 'Alice' because nickname is null.
}
4. Lists, Maps & Loops
Collections in Dart are heavily typed. A List of integers cannot hold a string. Iterating over them is similar to Python's for item in list:.
void main() {
// ---------------------------------------------------------
// 1. LISTS (Arrays)
// ---------------------------------------------------------
// Dart infers this as List.
List scores = [85, 92, 78];
scores.add(100); // Appends to the end
scores.removeAt(0); // Removes the item at index 0 (85)
// 🧠 EXPERT INSIGHT: Collection If
// Dart has a UI-focused feature where you can put 'if' statements DIRECTLY inside lists!
// We use this constantly in Flutter to show/hide widgets.
bool isAdmin = true;
List menuItems = [
'Home',
'Profile',
if (isAdmin) 'Admin Dashboard', // Only added to the list if true!
];
// ---------------------------------------------------------
// 2. MAPS (Dictionaries)
// ---------------------------------------------------------
// Key is String, Value is dynamic (can be anything).
// In real apps, try to avoid 'dynamic' as it bypasses type safety.
Map userJson = {
'name': 'Alice',
'age': 28,
'isActive': true,
};
// Reading a map value. Note: Reading a map ALWAYS returns a nullable type (dynamic?)
// because the key might not exist!
String userName = userJson['name'];
// ---------------------------------------------------------
// 3. LOOPS & ITERATION
// ---------------------------------------------------------
// Standard For-In Loop (Like Python's 'for score in scores:')
for (int score in scores) {
print("Score: $score");
}
// Functional iteration (Like JS 'scores.map()')
// .map() transforms elements. .toList() converts the iterable back to a List.
List scoreLabels = scores.map((s) => "Grade: $s").toList();
}
5. Functions & Classes
Dart is fully object-oriented. Functions require return types, and classes use a very concise constructor syntax compared to Python's __init__.
// ---------------------------------------------------------
// 1. FUNCTIONS & NAMED PARAMETERS
// ---------------------------------------------------------
// Return type is String.
// Wrapping parameters in { } makes them "Named Parameters".
// 'required' means the caller MUST provide this parameter.
String createGreeting({ required String name, String greeting = 'Hello' }) {
// String interpolation uses $variable
return '$greeting, $name!';
}
// ---------------------------------------------------------
// 2. CLASSES
// ---------------------------------------------------------
class User {
// Instance variables
final String id;
final String username;
bool isOnline;
// Constructor
// This concise syntax automatically assigns the arguments to the variables!
// No need for Python's `self.id = id` boilerplate.
User({
required this.id,
required this.username,
this.isOnline = false, // Default value
});
// A method inside the class
void logOff() {
isOnline = false;
print('$username has logged off.');
}
}
void main() {
// Calling a function with named parameters (Very common in Flutter UI widgets)
String msg = createGreeting(name: 'Alice', greeting: 'Welcome');
// Instantiating a class (Notice we do not use the 'new' keyword in modern Dart)
final myUser = User(id: '123', username: 'FlutterFan');
myUser.logOff();
}
You now know enough Dart to build a mobile app.
Let's start the Flutter Masterclass.
Part 2: Modern Flutter Architecture
6. The Architecture Shift
If you learned Flutter a few years ago, you were likely taught to build massive widget trees, pass callbacks down 10 levels, manage state with setState or verbose BLoC boilerplate, and couple your UI directly to Firebase.
In modern Flutter, the paradigm has shifted. We now build Local-First, Offline-Reliable applications.
Fine-Grained Reactivity
We replace Provider/BLoC with Signals. A Signal holds a value, and only the exact widget reading it rebuilds when it changes. No more BuildContext lookups.
Advanced Type Safety
We use Dart 3 Sealed Classes and Pattern Matching. The compiler guarantees we can never be in an invalid UI state.
Local-First Persistence
Apps should work perfectly on an airplane. We write to local storage instantly, and sync in the background later via CRDTs.
7. Project Setup & CLI
Let's create the project and install our modern stack. We will use signals_flutter for state, and flutter_animate for physics-based micro-interactions.
# Create the project
flutter create modern_habit_tracker --platforms=ios,android
cd modern_habit_tracker
# Install our core stack
flutter pub add signals_flutter # Fine-grained reactive state
flutter pub add flutter_animate # Physics-based micro-interactions
flutter pub add shared_preferences # Local persistence (Phase 1)
flutter pub add uuid # Unique ID generation
8. Domain Modeling (Sealed Classes)
Create lib/habit_model.dart. The foundation of any robust app is its data model.
The Old Way: bool isCompleted; bool isSkipped; String? skipReason;
This leads to impossible states. What if isCompleted and isSkipped are both true? The UI crashes.
The Modern Way: Sealed Classes. We define a finite set of states. We also add a Set<DateTime> completedDates to power our Consistency Grid later.
import 'package:uuid/uuid.dart';
// ---------------------------------------------------------
// 1. SEALED CLASSES: The Status Definition
// ---------------------------------------------------------
// 'sealed' ensures that all subclasses must be defined in this file.
// This unlocks exhaustive pattern matching in Dart 3. The compiler will literally
// yell at us if we forget to render UI for one of these states.
sealed class HabitStatus {
// We require all statuses to be serializable to JSON for our database.
Map toJson();
// Factory constructor to rebuild the status from the database JSON.
factory HabitStatus.fromJson(Map json) {
// We use a Dart 3 switch expression on the 'type' string.
return switch (json['type']) {
'pending' => Pending(),
'completed' => Completed(DateTime.parse(json['completedAt'])),
'skipped' => Skipped(json['reason']),
// ⚠️ PITFALL: Always have a fallback for database migrations!
// If we add a new status later and read old DB rows, it shouldn't crash.
_ => Pending(),
};
}
}
class Pending extends HabitStatus {
@override
Map toJson() => {'type': 'pending'};
}
class Completed extends HabitStatus {
final DateTime completedAt;
Completed(this.completedAt);
@override
Map toJson() => {
'type': 'completed',
'completedAt': completedAt.toIso8601String(),
};
}
class Skipped extends HabitStatus {
final String reason;
Skipped(this.reason);
@override
Map toJson() => {
'type': 'skipped',
'reason': reason,
};
}
// ---------------------------------------------------------
// 2. IMMUTABLE DATA MODEL
// ---------------------------------------------------------
class Habit {
final String id;
final String title;
final HabitStatus status;
final Set completedDates; // Powers our consistency grid!
Habit({
String? id,
required this.title,
required this.status,
Set? completedDates,
}) : id = id ?? const Uuid().v4(), // Generate a unique ID if none provided
completedDates = completedDates ?? {};
// 🧠 EXPERT INSIGHT: copyWith
// In reactive programming, we NEVER mutate objects directly (e.g., habit.title = "New").
// We create new instances with updated fields. This allows our State Manager
// to compare memory addresses and trigger pinpoint UI rebuilds.
Habit copyWith({
String? title,
HabitStatus? status,
Set? completedDates,
}) {
return Habit(
id: this.id, // ID never changes
title: title ?? this.title,
status: status ?? this.status,
completedDates: completedDates ?? this.completedDates,
);
}
// --- Serialization ---
Map toJson() => {
'id': id,
'title': title,
'status': status.toJson(), // Delegates to the sealed class
// Convert the Set of Dates to a List of ISO strings for JSON storage
'completedDates': completedDates.map((d) => d.toIso8601String()).toList(),
};
factory Habit.fromJson(Map json) => Habit(
id: json['id'],
title: json['title'],
status: HabitStatus.fromJson(json['status']),
completedDates: (json['completedDates'] as List)
.map((e) => DateTime.parse(e))
.toSet(),
);
}
9. The Repository Pattern (Dependency Inversion)
Create lib/habit_repository.dart.
The Tradeoff: Why not just put SharedPreferences directly in our State Controller? Because in 3 months, you might want to upgrade to SQLite (Drift) or sync to a Cloud server. If your database code is tangled with your UI logic, you have to rewrite the whole app. We use an Interface (Abstract Class) to decouple them.
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'habit_model.dart';
// ---------------------------------------------------------
// 1. THE INTERFACE (The Contract)
// ---------------------------------------------------------
// Any database we ever use in the future must fulfill these two methods.
abstract class HabitRepository {
Future> loadHabits();
Future saveHabits(List habits);
}
// ---------------------------------------------------------
// 2. THE IMPLEMENTATION (Local JSON)
// ---------------------------------------------------------
class LocalPrefsHabitRepository implements HabitRepository {
static const String _key = 'habits_data';
@override
Future> loadHabits() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_key);
if (jsonString == null) return []; // First time opening the app
try {
// Decode string to a List of Dynamic objects, then map them to Habits
final List jsonList = jsonDecode(jsonString);
return jsonList.map((e) => Habit.fromJson(e)).toList();
} catch (e) {
// ⚠️ PITFALL: Silent failures in parsing. Always catch and log!
// If the data is corrupted, return an empty list rather than crashing the app on startup.
print("Error parsing habits: $e");
return [];
}
}
@override
Future saveHabits(List habits) async {
final prefs = await SharedPreferences.getInstance();
// Convert List -> List
Part 3: Reactivity & UI
10. State Management (Signals)
Create lib/habit_controller.dart.
Why Signals over BLoC/Provider? BLoC requires events, states, emitters, and massive context wrappers in the UI. Signals are fundamentally simpler. A Signal holds a value. A Computed signal automatically calculates a derived value based on other signals. A Watch widget listens to them. That's it. It's the standard in SolidJS, Vue, and now modern Flutter.
import 'package:signals_flutter/signals_flutter.dart';
import 'habit_model.dart';
import 'habit_repository.dart';
class HabitController {
final HabitRepository _repository;
// ---------------------------------------------------------
// 1. PRIMARY STATE
// ---------------------------------------------------------
// ListSignal notifies listeners whenever the list itself changes
// (adds, removes, or replacements of items).
final habits = ListSignal([]);
// ---------------------------------------------------------
// 2. DERIVED STATE (Computed Signals)
// ---------------------------------------------------------
// These are pure magic. They automatically recalculate ONLY when 'habits' changes.
// We use this to build a progress bar in the UI without writing manual calculation loops!
late final Computed completedCount = computed(() {
return habits.where((h) => h.status is Completed).length;
});
late final Computed progressPercentage = computed(() {
if (habits.isEmpty) return 0.0;
return completedCount.value / habits.length;
});
// Constructor requires Dependency Injection of the repository
HabitController(this._repository) {
_init();
}
Future _init() async {
final loaded = await _repository.loadHabits();
habits.value = loaded;
_checkMidnightResets(); // We will write this in Part 4
}
// ---------------------------------------------------------
// 3. ACTIONS
// ---------------------------------------------------------
void addHabit(String title) {
final newHabit = Habit(title: title, status: Pending());
habits.add(newHabit);
_syncToDatabase();
}
void toggleHabitStatus(String id) {
final index = habits.indexWhere((h) => h.id == id);
if (index == -1) return;
final habit = habits[index];
final now = DateTime.now();
// Strip hours/mins to get pure 'Today'
final today = DateTime(now.year, now.month, now.day);
// 🧠 EXPERT INSIGHT: Exhaustive Pattern Matching
// We transition the state based on what it currently is.
// No if/else chains. Clean, mathematical state transitions.
final newStatus = switch (habit.status) {
Pending() => Completed(now),
Completed() => Pending(),
Skipped() => Pending(), // Un-skipping returns to pending
};
// Update Consistency Grid Logic
Set newDates = Set.from(habit.completedDates);
if (newStatus is Completed) {
newDates.add(today);
} else {
newDates.remove(today); // If they un-checked it, remove today from history
}
// Replace the item in the ListSignal. This instantly triggers a UI update.
habits[index] = habit.copyWith(
status: newStatus,
completedDates: newDates,
);
_syncToDatabase();
}
void skipHabit(String id, String reason) {
final index = habits.indexWhere((h) => h.id == id);
if (index == -1) return;
habits[index] = habits[index].copyWith(status: Skipped(reason));
_syncToDatabase();
}
void deleteHabit(String id) {
habits.removeWhere((h) => h.id == id);
_syncToDatabase();
}
// Fire-and-forget sync to local storage
void _syncToDatabase() {
_repository.saveHabits(habits.toList());
}
void _checkMidnightResets() {
// Placeholder for Section 13
}
}
// Global instance for our app (In enterprise, use a service locator like get_it)
final repository = LocalPrefsHabitRepository();
final habitController = HabitController(repository);
11. The UI Layer & Animations
Finally, let's tie it all together in lib/main.dart. We will build a beautiful UI utilizing flutter_animate for satisfying micro-interactions. Habit trackers live and die by how good it feels to check a box.
import 'package:flutter/material.dart';
import 'package:signals_flutter/signals_flutter.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'habit_model.dart';
import 'habit_controller.dart';
void main() {
runApp(const ModernHabitApp());
}
class ModernHabitApp extends StatelessWidget {
const ModernHabitApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Modern Habits',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0284C7), // Tailwind primary-600
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const HabitDashboardScreen(),
);
}
}
class HabitDashboardScreen extends StatelessWidget {
const HabitDashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text('My Daily Habits', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.transparent,
elevation: 0,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(4.0),
// ---------------------------------------------------------
// 1. WATCHING COMPUTED SIGNALS
// ---------------------------------------------------------
// The Watch widget binds specifically to the signals read inside it.
// This progress bar automatically animates when the progressPercentage signal changes!
child: Watch((context) {
final progress = habitController.progressPercentage.value;
return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
color: Theme.of(context).colorScheme.primary,
).animate(target: progress).shimmer(duration: 500.ms);
}),
),
),
// ---------------------------------------------------------
// 2. WATCHING THE MAIN LIST
// ---------------------------------------------------------
body: Watch((context) {
final currentHabits = habitController.habits;
if (currentHabits.isEmpty) {
return Center(
child: const Text("No habits yet. Let's build a routine!")
.animate().fadeIn().slideY(begin: 0.5),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: currentHabits.length,
itemBuilder: (context, index) {
final habit = currentHabits[index];
return HabitTile(
// ⚠️ PITFALL: ALWAYS use a Key in dynamic lists.
// Without it, Flutter's element tree mixes up states when deleting items.
key: ValueKey(habit.id),
habit: habit,
)
// Add a staggered entrance animation for list items
.animate()
.fadeIn(delay: (50 * index).ms)
.slideX(begin: 0.1);
},
);
}),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddHabitDialog(context),
icon: const Icon(Icons.add),
label: const Text('New Habit'),
).animate().scale(delay: 500.ms, duration: 400.ms, curve: Curves.easeOutBack),
);
}
void _showAddHabitDialog(BuildContext context) {
final textController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create New Habit'),
content: TextField(
controller: textController,
autofocus: true,
decoration: InputDecoration(
hintText: 'e.g., Read 10 Pages',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (textController.text.isNotEmpty) {
habitController.addHabit(textController.text);
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
}
// ---------------------------------------------------------
// THE HABIT TILE (Pattern Matching & Interactions)
// ---------------------------------------------------------
class HabitTile extends StatelessWidget {
final Habit habit;
const HabitTile({super.key, required this.habit});
@override
Widget build(BuildContext context) {
// 🧠 EXPERT INSIGHT: Destructuring Records
// We map the Domain State directly to UI Properties using a switch expression.
// This removes 20 lines of messy if/else UI code.
final (icon, iconColor, bgColor, textStyle) = switch (habit.status) {
Pending() => (
Icons.circle_outlined,
Colors.grey.shade500,
Colors.white,
const TextStyle(fontWeight: FontWeight.w600, color: Colors.black87)
),
Completed() => (
Icons.check_circle_rounded,
Colors.green,
Colors.green.shade50,
const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
decoration: TextDecoration.lineThrough)
),
Skipped(reason: final r) => (
Icons.next_plan_rounded,
Colors.orange,
Colors.orange.shade50,
const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54)
),
};
// We use a Dismissible to add "Swipe to Skip" and "Swipe to Delete"
return Dismissible(
key: ValueKey('dismiss_${habit.id}'),
background: _buildSwipeBg(Colors.orange, Icons.skip_next, 'Skip', Alignment.centerLeft),
secondaryBackground: _buildSwipeBg(Colors.red, Icons.delete, 'Delete', Alignment.centerRight),
confirmDismiss: (direction) async {
if (direction == DismissDirection.endToStart) {
habitController.deleteHabit(habit.id);
return true; // Proceed with delete animation
} else {
_showSkipDialog(context);
return false; // Don't dismiss the UI row, we are just changing its state to Skipped
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4))
],
),
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
leading: GestureDetector(
onTap: () => habitController.toggleHabitStatus(habit.id),
child: Icon(icon, color: iconColor, size: 32)
// Animate the checkmark specifically when the state TYPE changes!
.animate(key: ValueKey(habit.status.runtimeType))
.scale(duration: 200.ms, curve: Curves.easeOutBack)
.tint(color: Colors.white, duration: 100.ms),
),
title: Text(habit.title, style: textStyle),
subtitle: habit.status is Skipped
? Text("Skipped: ${(habit.status as Skipped).reason}",
style: TextStyle(color: Colors.orange.shade800, fontSize: 12))
: null,
),
// We will render the Consistency Grid here in the next section!
// ConsistencyGrid(habit: habit),
],
),
),
);
}
Widget _buildSwipeBg(Color color, IconData icon, String label, Alignment alignment) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(16)),
alignment: alignment,
child: Icon(icon, color: Colors.white),
);
}
void _showSkipDialog(BuildContext context) {
final textController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Skip Habit'),
content: TextField(
controller: textController,
autofocus: true,
decoration: const InputDecoration(hintText: 'Reason (e.g., Sick today)'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
habitController.skipHabit(habit.id, textController.text.isNotEmpty ? textController.text : "No reason");
Navigator.pop(context);
},
child: const Text('Skip'),
),
],
),
);
}
}
Part 4: Advanced Features
12. The Consistency Grid (GitHub Style)
Power users want to see their streaks. Let's build a 28-day consistency grid (like GitHub's contribution graph) using the completedDates Set we added to our Domain Model.
Implementation Logic:
- Generate a list of the last 28
DateTimeobjects ending in "Today". - Loop through the list and render a small
Container. - If the grid's date exists in
habit.completedDates, color it Green. Otherwise, Grey.
// Add this class to main.dart, and uncomment the ConsistencyGrid in HabitTile!
class ConsistencyGrid extends StatelessWidget {
final Habit habit;
const ConsistencyGrid({super.key, required this.habit});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Generate last 28 days (4 weeks)
final days = List.generate(28, (i) {
// i goes from 0 to 27.
// When i=27, we subtract 0 days (Today).
// When i=0, we subtract 27 days.
final d = today.subtract(Duration(days: 27 - i));
return DateTime(d.year, d.month, d.day); // Strip time for exact matching
});
return Padding(
padding: const EdgeInsets.only(bottom: 12.0, left: 20, right: 20),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: days.map((day) {
// Fast O(1) lookup because completedDates is a Set!
final isDone = habit.completedDates.contains(day);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: isDone ? Colors.green.shade400 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(2),
),
);
}).toList(),
),
);
}
}
13. Background Midnight Reset
A habit marked Completed today shouldn't be completed tomorrow. We need to check for day rollovers. Add this method to your HabitController and call it inside _init().
// Inside HabitController class in habit_controller.dart:
void _checkMidnightResets() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
bool needsSave = false;
// We loop through a standard index loop so we can modify the ListSignal
for (int i = 0; i < habits.length; i++) {
final habit = habits[i];
if (habit.status is Completed) {
final completedAt = (habit.status as Completed).completedAt;
final completedDate = DateTime(completedAt.year, completedAt.month, completedAt.day);
// If the day it was completed is BEFORE today...
if (completedDate.isBefore(today)) {
// It's a new day! Reset the status to pending.
habits[i] = habit.copyWith(status: Pending());
needsSave = true;
}
} else if (habit.status is Skipped) {
// We should also reset skipped habits the next day!
habits[i] = habit.copyWith(status: Pending());
needsSave = true;
}
}
// Only write to the database if something actually changed to save I/O
if (needsSave) _syncToDatabase();
}
14. Top 3 Gotchas & Beginner Mistakes
-
The
ValueKeyTrap in ListsIf you have a dynamic list of StatefulWidgets and delete the first one, Flutter's element tree gets confused and passes the old state to the wrong UI row. Always use
key: ValueKey(id)in lists. -
Mutating Data Directly
Reactive frameworks look at memory addresses to know if data changed. Doing
habit.title = "new"will not trigger a rebuild. You must usecopyWithto create a new instance. -
Imperative vs Declarative
Don't say: "When clicked, turn button green, play animation, update DB."
Do say: "When clicked, update state to Completed." The UI should simply react to whatever the current state is.
15. Top Requested Features in 2026 Apps
If you plan to release this app to the App Store, standard habit trackers are too rigid. Here are the features power-users beg for:
- Vacation / Sick Mode: Pausing a habit without resetting a 300-day streak. (We implemented the foundation for this with our
Skippedsealed class state!) - Dynamic Goals: The ability to change a goal (e.g., Run 1 mile -> Run 3 miles) over time without destroying historical data.
- Local-First / Privacy: Reddit users universally despise mandatory cloud accounts. Storing data via SQLite natively on the device is a massive selling point.
- Interactive Home Screen Widgets: iOS Live Activities and Android Glance widgets. Users will uninstall your app if they have to open it to check off a habit.