Proyek Mini: Aplikasi Catatan dengan Database
Bangun aplikasi notes lengkap: CRUD catatan, pencarian, dan penyimpanan lokal dengan sqflite.
Ringkasan proyek
Aplikasi catatan (notes) adalah proyek klasik yang melatih CRUD lengkap: buat, baca, ubah, hapus, plus pencarian. Kita pakai sqflite untuk penyimpanan dan Riverpod untuk state.
Model catatan
class Catatan {
final String id;
final String judul;
final String isi;
final DateTime diubahPada;
const Catatan({
required this.id,
required this.judul,
required this.isi,
required this.diubahPada,
});
factory Catatan.fromJson(Map<String, dynamic> json) => Catatan(
id: json['id'] as String? ?? '',
judul: json['judul'] as String? ?? '',
isi: json['isi'] as String? ?? '',
diubahPada: DateTime.tryParse(json['diubah_pada'] as String? ?? '') ??
DateTime.now(),
);
Map<String, dynamic> toJson() => {
'id': id,
'judul': judul,
'isi': isi,
'diubah_pada': diubahPada.toIso8601String(),
};
}Tanggal disimpan sebagai string ISO 8601 karena SQLite tidak punya tipe datetime native.
Database helper
Future<Database> _initDB(String namaFile) async {
final pathFolder = await getDatabasesPath();
final path = join(pathFolder, namaFile);
return await openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE catatan (
id TEXT PRIMARY KEY,
judul TEXT NOT NULL,
isi TEXT NOT NULL,
diubah_pada TEXT NOT NULL
)
''');
},
);
}
Future<List<Catatan>> cariCatatan(String keyword) async {
final db = await database;
final hasil = await db.query(
'catatan',
where: 'judul LIKE ? OR isi LIKE ?',
whereArgs: ['%$keyword%', '%$keyword%'],
orderBy: 'diubah_pada DESC',
);
return hasil.map((e) => Catatan.fromJson(e)).toList();
}Query LIKE dengan whereArgs memberi fitur pencarian yang aman dari SQL injection.
Provider dengan AsyncNotifier
class DaftarCatatan extends AsyncNotifier<List<Catatan>> {
@override
Future<List<Catatan>> build() async {
return DatabaseHelper.instance.ambilSemua();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => DatabaseHelper.instance.ambilSemua(),
);
}
Future<void> hapus(String id) async {
await DatabaseHelper.instance.hapus(id);
await refresh();
}
Future<void> cari(String keyword) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => DatabaseHelper.instance.cariCatatan(keyword),
);
}
}
final daftarCatatanProvider =
AsyncNotifierProvider<DaftarCatatan, List<Catatan>>(DaftarCatatan.new);Setiap aksi (hapus, cari) memuat ulang dari database sehingga UI selalu mencerminkan data asli, bukan tebakan.
Editor catatan
class EditorScreen extends ConsumerStatefulWidget {
final Catatan? catatanAwal;
const EditorScreen({super.key, this.catatanAwal});
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends ConsumerState<EditorScreen> {
late final TextEditingController _judulController;
late final TextEditingController _isiController;
@override
void initState() {
super.initState();
_judulController = TextEditingController(text: widget.catatanAwal?.judul ?? '');
_isiController = TextEditingController(text: widget.catatanAwal?.isi ?? '');
}
@override
void dispose() {
_judulController.dispose();
_isiController.dispose();
super.dispose();
}
Future<void> _simpan() async {
final judul = _judulController.text.trim();
final isi = _isiController.text.trim();
if (judul.isEmpty && isi.isEmpty) {
Navigator.pop(context);
return;
}
final helper = DatabaseHelper.instance;
if (widget.catatanAwal == null) {
await helper.tambah(Catatan(
id: DateTime.now().millisecondsSinceEpoch.toString(),
judul: judul.isEmpty ? 'Tanpa judul' : judul,
isi: isi,
diubahPada: DateTime.now(),
));
} else {
await helper.ubah(Catatan(
id: widget.catatanAwal!.id,
judul: judul.isEmpty ? 'Tanpa judul' : judul,
isi: isi,
diubahPada: DateTime.now(),
));
}
await ref.read(daftarCatatanProvider.notifier).refresh();
if (mounted) Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.catatanAwal == null ? 'Catatan baru' : 'Edit catatan'),
actions: [
IconButton(icon: const Icon(Icons.check), onPressed: _simpan),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _judulController,
decoration: const InputDecoration(
hintText: 'Judul',
border: InputBorder.none,
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const Divider(),
Expanded(
child: TextField(
controller: _isiController,
decoration: const InputDecoration(
hintText: 'Tulis catatanmu...',
border: InputBorder.none,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
),
),
],
),
),
);
}
}Satu layar menangani tambah dan edit: kalau catatanAwal null berarti baru. expands: true membuat field isi mengisi sisa layar.
Pencarian di app bar
Tambahkan TextField pencarian di atas daftar yang memanggil cari() dengan debounce sederhana agar tidak query tiap ketikan. Ini latihan bagus menggabungkan Timer dengan provider.
Catatan teknis: Untuk id unik, contoh memakai timestamp milidetik yang cukup untuk proyek mini. Di produksi pakai package
uuiduntuk menghindari tabrakan. Dan ingat aturan emas: setiap perubahan data selalu diikutirefresh()dari database, jangan mengarang state lokal yang mungkin beda dengan isi database. Single source of truth tetap database.