Terakhir diperbarui: 18 September 2026
Setelah menyelesaikan codelab ini, mahasiswa mampu:
Aturan praktis memilih storage:
| Kebutuhan | Pilihan | Contoh |
|---|---|---|
| Pengaturan kecil key-value | SharedPreferences | tema gelap/terang, bahasa, waktu terakhir dibuka |
| Data terstruktur relasional | SQLite via sqflite | catatan, tugas, transaksi |
| NoSQL ringan embedded | Hive | cache objek, kotak (box) sederhana |
| Relasional reaktif & type-safe | Drift | aplikasi besar dengan query kompleks + stream |
Codelab ini memakai SharedPreferences + SQLite (sqflite): kombinasi paling umum di industri untuk aplikasi offline notes. Tabel perbandingan Hive vs Drift wajib Anda isi pada AI Challenge sebagai bukti pengambilan keputusan.
Offline-first berarti aplikasi selalu bisa dibaca dan ditulis meski tanpa internet, lalu disinkronkan saat koneksi kembali. Tiga mekanisme intinya:
dirty = 1) agar bisa di-sync belakangan.updated_at).UI (ConsumerWidget) --watch--> Provider (AsyncValue)
Provider --panggil--> LocalRepository --CRUD--> SQLite
LocalRepository --sync--> Remote (simulasi) --sukses--> dirty = 0
Cache: baca lokal dulu, refresh background, simpan
Aturan arsitektur yang sama seperti Minggu 4 tetap berlaku, hanya sumber datanya berubah:
AsyncValue (loading/error/data) dan fungsi invalidate untuk refresh.flutter create week5_offline_notes
cd week5_offline_notes
flutter pub add flutter_riverpod shared_preferences sqflite path
Struktur folder:
lib/
├── main.dart
├── data/
│ ├── local/
│ │ ├── db.dart
│ │ └── note.dart
│ ├── prefs.dart
│ └── repositories/
│ └── note_repository.dart
└── pages/
├── settings_page.dart
└── notes_page.dart
Buat lib/data/prefs.dart. Seluruh akses key-value terpusat di sini, bukan tersebar di widget:
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);
}
}
Contoh penyambungan minimal dengan Riverpod di 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;
});
}
}
Buat lib/data/local/note.dart. Field dirty menandai catatan yang belum tersinkron:
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,
);
}
}
Buat lib/data/local/db.dart. Satu fungsi pembuka dipakai seluruh 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
)
''');
},
);
}
Buat 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');
}
}
Catatan tersimpan di perangkat, jadi halaman ini tetap berfungsi penuh dalam mode pesawat. Tampilkan badge jumlah catatan yang belum tersinkron (dirty) sebagai indikator antrean sync.
Gunakan kembali endpoint Minggu 4 (GET /posts JSONPlaceholder). Alurnya: tampilkan cache lokal seketika, refresh dari jaringan di background, simpan hasilnya untuk kunjungan berikutnya:
Future<List<Post>> loadPostsCacheFirst() async {
final cached = await readCachedPosts(); // dari tabel cached_posts
// 1. Segera kembalikan cache agar UI tidak blank saat offline.
// 2. Di background: fetch Dio -> simpan ke cached_posts -> invalidate provider.
refreshPostsInBackground();
return cached;
}
Karena codelab ini belum punya backend tulis, simulasikan server dengan delay. Yang dinilai adalah mekanismenya, bukan servernya:
Future<int> syncNotes(NoteRepository repo) async {
final dirtyCount = await repo.countDirty();
if (dirtyCount == 0) return 0;
// Simulasi upload: pada project nyata, kirim tiap catatan dirty
// ke REST API di sini, lalu tandai bersih bila server menjawab 2xx.
await Future.delayed(const Duration(seconds: 1));
await repo.markAllSynced();
return dirtyCount;
}
Selain mode pesawat sungguhan, sediakan toggle forceOffline pada provider agar demo dan testing tidak bergantung pada kondisi Wi-Fi kelas:
syncNotes: badge kembali ke 0.screenshots/.AI boleh mengusulkan pilihan storage, tetapi Anda yang memutuskan. Nilai ada pada kualitas perbandingan dan justifikasi, bukan pada kode yang dihasilkan AI.
Minta AI coding assistant (Cursor, Copilot, Claude Code, atau tool setara) dengan prompt berikut:
Aplikasi Flutter Offline Notes: CRUD catatan + preferensi tema.
Bandingkan SharedPreferences, Hive, sqflite (SQLite), dan Drift
untuk dua kebutuhan ini. Requirements:
- Kriteria: kompleksitas query, kebutuhan relasi, reaktivitas (stream),
type-safety, ukuran boilerplate, dan kemudahan testing.
- Beri rekomendasi final: mana untuk preferensi, mana untuk catatan,
beserta alasannya dalam 1 tabel.
- Tunjukkan skema tabel/kotak untuk 1000+ catatan.
Jelaskan trade-off setiap pilihan.
Sebelum rekomendasi AI diterima, verifikasi dan catat temuan Anda di README:
flutter pub add + migrasi skema)?Lakukan refactoring berikut pada project catatan Anda, lalu commit dengan pesan yang jelas:
NoteTile tersendiri yang menampilkan badge "belum tersinkron" bila dirty == true.syncNotes ke file lib/data/sync.dart agar repository tetap fokus pada CRUD./note/:id) yang membaca dari repository lokal, bukan dari state halaman list.Buat test/note_test.dart. Uji mapping aman null dan provider dengan repository palsu (tanpa SQLite sungguhan):
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 (simulasi)');
return items;
}
@override
Future<int> countDirty() =>
Future.value(items.where((n) => n.dirty).length);
}
void main() {
test('fromMap aman terhadap field yang hilang', () {
final note = Note.fromMap({'title': 'Belanja'});
expect(note.title, 'Belanja');
expect(note.body, '');
expect(note.dirty, isFalse);
});
test('flag dirty bertahan pada serialisasi', () {
final note = Note(
title: 'a',
updatedAt: DateTime(2026, 9, 18),
dirty: true,
);
final restored = Note.fromMap(note.toMap());
expect(restored.dirty, isTrue);
});
test('provider sukses dengan repository palsu', () async {
final container = ProviderContainer(
overrides: [
noteRepositoryProvider.overrideWithValue(
FakeNoteRepository(items: [
Note(title: 'Tes', updatedAt: DateTime.now()),
]),
),
],
);
addTearDown(container.dispose);
final notes = await container.read(notesProvider.future);
expect(notes.length, 1);
expect(notes.first.title, 'Tes');
});
test('provider error dengan repository palsu', () async {
final container = ProviderContainer(
overrides: [
noteRepositoryProvider.overrideWithValue(
FakeNoteRepository(throwError: true),
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(notesProvider.future),
throwsA(isA<Exception>()),
);
});
}
Jalankan:
flutter analyze
flutter test
| Gejala | Penyebab umum | Solusi |
|---|---|---|
MissingPluginException untuk shared_preferences/sqflite | Hot restart setelah tambah plugin tanpa rebuild penuh | Hentikan aplikasi, jalankan ulang flutter run (bukan hot reload) |
databaseException: table notes already exists | onCreate dijalankan dua kali / versi tidak naik setelah ubah skema | Naikkan version + implementasikan onUpgrade, atau uninstall aplikasi saat dev |
| Badge dirty tidak pernah nol | markAllSynced tidak dipanggil setelah sync sukses | Panggil hanya setelah "server" menjawab sukses; uji dengan countDirty |
| UI tidak refresh setelah tambah catatan | Lupa ref.invalidate(notesProvider) setelah mutasi | Invalidasi provider di repository caller, bukan di widget acak |
| Test menyentuh database sungguhan | Repository asli dipakai di test | Gunakan FakeNoteRepository via override seperti contoh di atas |
flutter analyze tanpa issue dan semua test lulus.docs/.Bangun aplikasi Offline Notes sebagai tugas minggu ini (kembangkan project codelab atau buat baru):
updated_at terbaru.syncNotes untuk tulisan, dan aturan konflik eksplisit yang didokumentasikan.docs/.05-week-5-local-storage-offline-first/ dengan struktur lib/, test/, docs/, README.md, dan screenshots/. README menjelaskan tujuan, fitur utama, stack teknologi, cara menjalankan, dan hasil yang dicapai.