Last updated: September 18, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Prerequisites

Types of local storage

A practical rule for choosing storage:

NeedChoiceExample
Small key-value settingsSharedPreferencesdark/light theme, language, last opened time
Structured relational dataSQLite via sqflitenotes, tasks, transactions
Lightweight embedded NoSQLHiveobject cache, simple boxes
Reactive, type-safe relationalDriftlarge 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, not offline-only

Offline-first means the app is always readable and writable without internet, then synchronized when connectivity returns. Three core mechanisms:

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

Repository for local data

The same architecture rules from Week 4 apply, only the data source changes:

Set up the project

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

1. Preferences repository

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);
  }
}

2. Provider and settings page

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;
    });
  }
}

1. Note model

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,
    );
  }
}

2. Database opener

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
        )
      ''');
    },
  );
}

3. Repository as the single data gateway

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');
  }
}

4. Offline notes page

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.

1. Cache-first reads for API data

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;
}

2. Syncing dirty notes

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;
}

3. Deterministic offline simulation

Beyond real airplane mode, provide a forceOffline toggle on the provider so demos and tests never depend on classroom Wi-Fi:

The role of AI in this codelab

AI may propose storage options, but you decide. Grading rewards comparison quality and justification, not AI-generated code.

AI Prompt Challenge

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.

AI Verification Checklist

Before accepting the AI recommendation, verify the following and record your findings in the README:

Refactoring Challenge

Apply the following refactorings to your notes project, then commit with a clear message:

  1. Extract the note row into a dedicated NoteTile widget that shows an "unsynced" badge when dirty == true.
  2. Move the posts-caching logic and syncNotes into lib/data/sync.dart so the repository stays focused on CRUD.
  3. Add a note detail page with GoRouter (/note/:id) that reads from the local repository, not from the list page state.

Testing: model unit tests + a fake repository

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

Common errors and solutions

SymptomLikely causeFix
MissingPluginException for shared_preferences/sqfliteHot restart after adding a plugin without a full rebuildStop the app and run flutter run again (not hot reload)
databaseException: table notes already existsonCreate ran twice / version not bumped after a schema changeBump version + implement onUpgrade, or uninstall the app during dev
Dirty badge never reaches zeromarkAllSynced never called after a successful syncCall it only after the "server" reports success; verify with countDirty
UI does not refresh after adding a noteForgot ref.invalidate(notesProvider) after mutationInvalidate the provider in the repository caller, not in a random widget
Tests touch a real databaseThe real repository is used in testsUse FakeNoteRepository via overrides as in the example above

Self-verification checklist

Mini project / Industry Challenge

Build an Offline Notes app as this week assignment (extend the codelab project or start fresh):

  1. Preferences: dark/light theme toggle + last-opened time via SharedPreferences.
  2. Persistent note CRUD via SQLite (sqflite) through a local repository + Riverpod; list ordered by newest updated_at.
  3. Offline-first: cache-first for reads, dirty flag + syncNotes for writes, and a documented explicit conflict rule.
  4. Airplane-mode proof: screenshots of the note list while offline and of the dirty badge before/after sync.
  5. At least 2 passing tests (1 model unit test + 1 provider test with a fake repository).
  6. Complete the AI Challenge section and document the prompt, the storage comparison table, the final decision, and your technical reasons in docs/.
  7. Push to the portfolio repository under 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.

Reflection

References