Last updated: September 18, 2026
After completing this codelab, students will be able to:
A practical rule for choosing storage:
| Need | Choice | Example |
|---|---|---|
| Small key-value settings | SharedPreferences | dark/light theme, language, last opened time |
| Structured relational data | SQLite via sqflite | notes, tasks, transactions |
| Lightweight embedded NoSQL | Hive | object cache, simple boxes |
| Reactive, type-safe relational | Drift | large apps with complex queries + streams |
This codelab uses SharedPreferences + SQLite (sqflite): the most common industry combination for offline notes apps. You must fill in the Hive vs Drift comparison table in the AI Challenge as evidence of your decision-making.
Offline-first means the app is always readable and writable without internet, then synchronized when connectivity returns. Three core mechanisms:
dirty = 1) so it can sync later.updated_at).UI (ConsumerWidget) --watch--> Provider (AsyncValue)
Provider --calls--> LocalRepository --CRUD--> SQLite
LocalRepository --sync--> Remote (simulated) --success--> dirty = 0
Cache: read local first, refresh in background, persist
The same architecture rules from Week 4 apply, only the data source changes:
AsyncValue (loading/error/data) plus an invalidate function for refresh.flutter create week5_offline_notes
cd week5_offline_notes
flutter pub add flutter_riverpod shared_preferences sqflite path
Folder structure:
lib/
├── main.dart
├── data/
│ ├── local/
│ │ ├── db.dart
│ │ └── note.dart
│ ├── prefs.dart
│ └── repositories/
│ └── note_repository.dart
└── pages/
├── settings_page.dart
└── notes_page.dart
Create lib/data/prefs.dart. All key-value access lives here, never scattered across widgets:
import 'package:shared_preferences/shared_preferences.dart';
class PrefsRepository {
static const _darkModeKey = 'dark_mode';
static const _lastOpenedKey = 'last_opened_at';
Future<bool> getDarkMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_darkModeKey) ?? false;
}
Future<void> setDarkMode(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_darkModeKey, value);
}
Future<void> markOpenedNow() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastOpenedKey, DateTime.now().toIso8601String());
}
Future<String?> getLastOpened() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_lastOpenedKey);
}
}
A minimal Riverpod wiring in lib/pages/settings_page.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/prefs.dart';
final prefsRepositoryProvider = Provider((ref) => PrefsRepository());
final darkModeProvider =
AsyncNotifierProvider<DarkModeNotifier, bool>(DarkModeNotifier.new);
class DarkModeNotifier extends AsyncNotifier<bool> {
@override
Future<bool> build() =>
ref.watch(prefsRepositoryProvider).getDarkMode();
Future<void> toggle() async {
final next = !(state.value ?? false);
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(prefsRepositoryProvider).setDarkMode(next);
return next;
});
}
}
Create lib/data/local/note.dart. The dirty field marks notes that are not yet synchronized:
class Note {
const Note({
this.id,
required this.title,
this.body = '',
required this.updatedAt,
this.dirty = false,
});
final int? id;
final String title;
final String body;
final DateTime updatedAt;
final bool dirty;
Map<String, Object?> toMap() => {
'id': id,
'title': title,
'body': body,
'updated_at': updatedAt.toIso8601String(),
'dirty': dirty ? 1 : 0,
};
factory Note.fromMap(Map<String, Object?> map) {
return Note(
id: (map['id'] as num?)?.toInt(),
title: map['title'] as String? ?? '',
body: map['body'] as String? ?? '',
updatedAt: DateTime.tryParse(map['updated_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
dirty: ((map['dirty'] as num?)?.toInt() ?? 0) == 1,
);
}
}
Create lib/data/local/db.dart. A single opener function is shared by every repository:
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
Future<Database> openNotesDb() async {
final dir = await getDatabasesPath();
return openDatabase(
p.join(dir, 'offline_notes.db'),
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
dirty INTEGER NOT NULL DEFAULT 0
)
''');
await db.execute('''
CREATE TABLE cached_posts(
id INTEGER PRIMARY KEY,
payload TEXT NOT NULL,
cached_at TEXT NOT NULL
)
''');
},
);
}
Create lib/data/repositories/note_repository.dart:
import 'package:sqflite/sqflite.dart';
import '../local/db.dart';
import '../local/note.dart';
class NoteRepository {
NoteRepository({Future<Database> Function()? openDb})
: _openDb = openDb ?? openNotesDb;
final Future<Database> Function() _openDb;
Future<List<Note>> fetchNotes() async {
final db = await _openDb();
final rows = await db.query('notes', orderBy: 'updated_at DESC');
return rows.map(Note.fromMap).toList();
}
Future<Note> addNote({required String title, String body = ''}) async {
final db = await _openDb();
final note = Note(
title: title,
body: body,
updatedAt: DateTime.now(),
dirty: true,
);
final id = await db.insert('notes', note.toMap());
return Note(
id: id,
title: note.title,
body: note.body,
updatedAt: note.updatedAt,
dirty: true,
);
}
Future<void> deleteNote(int id) async {
final db = await _openDb();
await db.delete('notes', where: 'id = ?', whereArgs: [id]);
}
Future<int> countDirty() async {
final db = await _openDb();
final rows = await db.rawQuery(
'SELECT COUNT(*) AS c FROM notes WHERE dirty = 1');
return ((rows.first['c'] as num?)?.toInt() ?? 0);
}
Future<void> markAllSynced() async {
final db = await _openDb();
await db.update('notes', {'dirty': 0}, where: 'dirty = 1');
}
}
Notes live on the device, so this page works fully in airplane mode. Show a badge with the count of unsynchronized (dirty) notes as the sync-queue indicator.
Reuse the Week 4 endpoint (GET /posts from JSONPlaceholder). The flow: render the local cache instantly, refresh from the network in the background, persist the result for the next visit:
Future<List<Post>> loadPostsCacheFirst() async {
final cached = await readCachedPosts(); // from the cached_posts table
// 1. Return the cache immediately so the UI is never blank offline.
// 2. In the background: fetch via Dio -> save to cached_posts
// -> invalidate the provider.
refreshPostsInBackground();
return cached;
}
Since this codelab has no write backend yet, simulate the server with a delay. What is graded is the mechanism, not the server:
Future<int> syncNotes(NoteRepository repo) async {
final dirtyCount = await repo.countDirty();
if (dirtyCount == 0) return 0;
// Simulate upload: in a real project, send each dirty note
// to the REST API here, then mark it clean on a 2xx response.
await Future.delayed(const Duration(seconds: 1));
await repo.markAllSynced();
return dirtyCount;
}
Beyond real airplane mode, provide a forceOffline toggle on the provider so demos and tests never depend on classroom Wi-Fi:
syncNotes: the badge returns to 0.screenshots/ folder.AI may propose storage options, but you decide. Grading rewards comparison quality and justification, not AI-generated code.
Ask an AI coding assistant (Cursor, Copilot, Claude Code, or an equivalent tool) with the following prompt:
Flutter Offline Notes app: note CRUD + theme preference.
Compare SharedPreferences, Hive, sqflite (SQLite), and Drift
for these two needs. Requirements:
- Criteria: query complexity, relational needs, reactivity (streams),
type-safety, boilerplate size, and testability.
- Give a final recommendation: which for preferences, which for notes,
with reasons in one table.
- Show the table/box schema for 1000+ notes.
Explain the trade-off of each choice.
Before accepting the AI recommendation, verify the following and record your findings in the README:
flutter pub add + schema migration)?Apply the following refactorings to your notes project, then commit with a clear message:
NoteTile widget that shows an "unsynced" badge when dirty == true.syncNotes into lib/data/sync.dart so the repository stays focused on CRUD./note/:id) that reads from the local repository, not from the list page state.Create test/note_test.dart. Test null-safe mapping and providers with a fake repository (no real SQLite):
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:week5_offline_notes/data/local/note.dart';
import 'package:week5_offline_notes/data/repositories/note_repository.dart';
class FakeNoteRepository extends NoteRepository {
FakeNoteRepository({this.items = const [], this.throwError = false})
: super(openDb: () => throw UnimplementedError());
final List<Note> items;
final bool throwError;
@override
Future<List<Note>> fetchNotes() async {
if (throwError) throw Exception('db locked (simulated)');
return items;
}
@override
Future<int> countDirty() =>
Future.value(items.where((n) => n.dirty).length);
}
void main() {
test('fromMap is safe against missing fields', () {
final note = Note.fromMap({'title': 'Groceries'});
expect(note.title, 'Groceries');
expect(note.body, '');
expect(note.dirty, isFalse);
});
test('dirty flag survives serialization', () {
final note = Note(
title: 'a',
updatedAt: DateTime(2026, 9, 18),
dirty: true,
);
final restored = Note.fromMap(note.toMap());
expect(restored.dirty, isTrue);
});
test('provider succeeds with a fake repository', () async {
final container = ProviderContainer(
overrides: [
noteRepositoryProvider.overrideWithValue(
FakeNoteRepository(items: [
Note(title: 'Test', updatedAt: DateTime.now()),
]),
),
],
);
addTearDown(container.dispose);
final notes = await container.read(notesProvider.future);
expect(notes.length, 1);
expect(notes.first.title, 'Test');
});
test('provider fails with a fake repository', () async {
final container = ProviderContainer(
overrides: [
noteRepositoryProvider.overrideWithValue(
FakeNoteRepository(throwError: true),
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(notesProvider.future),
throwsA(isA<Exception>()),
);
});
}
Run:
flutter analyze
flutter test
| Symptom | Likely cause | Fix |
|---|---|---|
MissingPluginException for shared_preferences/sqflite | Hot restart after adding a plugin without a full rebuild | Stop the app and run flutter run again (not hot reload) |
databaseException: table notes already exists | onCreate ran twice / version not bumped after a schema change | Bump version + implement onUpgrade, or uninstall the app during dev |
| Dirty badge never reaches zero | markAllSynced never called after a successful sync | Call it only after the "server" reports success; verify with countDirty |
| UI does not refresh after adding a note | Forgot ref.invalidate(notesProvider) after mutation | Invalidate the provider in the repository caller, not in a random widget |
| Tests touch a real database | The real repository is used in tests | Use FakeNoteRepository via overrides as in the example above |
flutter analyze is clean and all tests pass.docs/ folder.Build an Offline Notes app as this week assignment (extend the codelab project or start fresh):
updated_at.syncNotes for writes, and a documented explicit conflict rule.docs/.05-week-5-local-storage-offline-first/ with lib/, test/, docs/, README.md, and screenshots/. The README explains the objective, main features, tech stack, how to run it, and the result.