flutterproyeknotesdatabaseMahir4 mnt baca

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

dart
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

dart
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

dart
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

dart
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 uuid untuk menghindari tabrakan. Dan ingat aturan emas: setiap perubahan data selalu diikuti refresh() dari database, jangan mengarang state lokal yang mungkin beda dengan isi database. Single source of truth tetap database.