Tutorial Flutter: Membuat Aplikasi Pencatat Stok Barang Offline dengan SQLite (Lengkap)

Punya toko atau usaha kecil dan stok barang masih dicatat di buku tulis yang gampang hilang? Hari ini kita bangun aplikasi pencatat stok barang sendiri pakai Flutter — lengkap dengan database lokal SQLite, jadi aplikasi bisa jalan offline tanpa internet. Tutorial ini lengkap dari nol sampai aplikasi bisa dijalankan di HP kamu, dengan kode yang siap copy-paste.

1. Persiapan: Install Flutter dan Buat Proyek

Pastikan Flutter SDK dan Dart sudah terinstall. Cek dengan perintah:

flutter --version
flutter doctor

Kalau flutter doctor tidak ada masalah besar, buat proyek baru dan masuk ke foldernya:

flutter create aplikasi_stok
cd aplikasi_stok

2. Tambah Package sqflite dan path_provider

Kita butuh package sqflite untuk database SQLite dan path_provider untuk menemukan lokasi penyimpanan file database di HP. Buka pubspec.yaml, cari bagian dependencies:, dan tambahkan dua baris ini:

dependencies:
  flutter:
    sdk: flutter
  sqflite: ^2.3.0
  path_provider: ^2.1.2

Lalu jalankan perintah ini untuk mengunduh package:

flutter pub get

3. Buat Model Barang

Buat folder lib/models, lalu buat file lib/models/barang.dart dengan isi lengkap berikut:

class Barang {
  final int? id;
  final String nama;
  final int harga;
  final int stok;

  Barang({this.id, required this.nama, required this.harga, required this.stok});

  // Ubah objek Barang menjadi Map supaya bisa disimpan ke SQLite
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'nama': nama,
      'harga': harga,
      'stok': stok,
    };
  }

  // Ubah Map dari SQLite menjadi objek Barang
  factory Barang.fromMap(Map<String, dynamic> map) {
    return Barang(
      id: map['id'] as int?,
      nama: map['nama'] as String,
      harga: map['harga'] as int,
      stok: map['stok'] as int,
    );
  }

  // Copy objek dengan beberapa field yang diubah
  Barang copyWith({int? id, String? nama, int? harga, int? stok}) {
    return Barang(
      id: id ?? this.id,
      nama: nama ?? this.nama,
      harga: harga ?? this.harga,
      stok: stok ?? this.stok,
    );
  }
}

4. Buat Database Helper (CRUD Lengkap)

Buat folder lib/db, lalu buat file lib/db/database_helper.dart. File ini berisi semua operasi database: buat tabel, tambah, baca, ubah, dan hapus barang:

import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import '../models/barang.dart';

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('stok_toko.db');
    return _database!;
  }

  Future<Database> _initDB(String fileName) async {
    final dir = await getApplicationDocumentsDirectory();
    final path = '${dir.path}/$fileName';

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDB,
    );
  }

  // Membuat tabel barang saat database pertama kali dibuat
  Future _createDB(Database db, int version) async {
    await db.execute('''
      CREATE TABLE barang(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nama TEXT NOT NULL,
        harga INTEGER NOT NULL,
        stok INTEGER NOT NULL DEFAULT 0
      )
    ''');
  }

  // CREATE: tambah barang baru, mengembalikan id baris
  Future<int> tambahBarang(Barang barang) async {
    final db = await database;
    return await db.insert('barang', barang.toMap());
  }

  // READ: ambil semua barang, diurutkan dari yang terbaru
  Future<List<Barang>> ambilSemuaBarang() async {
    final db = await database;
    final result = await db.query('barang', orderBy: 'id DESC');
    return result.map((map) => Barang.fromMap(map)).toList();
  }

  // UPDATE: ubah data barang, mengembalikan jumlah baris yang berubah
  Future<int> ubahBarang(Barang barang) async {
    final db = await database;
    return await db.update(
      'barang',
      barang.toMap(),
      where: 'id = ?',
      whereArgs: [barang.id],
    );
  }

  // DELETE: hapus barang berdasarkan id
  Future<int> hapusBarang(int id) async {
    final db = await database;
    return await db.delete(
      'barang',
      where: 'id = ?',
      whereArgs: [id],
    );
  }

  // Cari barang yang stoknya di bawah batas (peringatan stok menipis)
  Future<List<Barang>> stokMenipis({int batas = 5}) async {
    final db = await database;
    final result = await db.query(
      'barang',
      where: 'stok < ?',
      whereArgs: [batas],
      orderBy: 'stok ASC',
    );
    return result.map((map) => Barang.fromMap(map)).toList();
  }

  Future close() async {
    final db = await database;
    db.close();
  }
}

5. Tulis Halaman Utama Aplikasi (main.dart)

Ganti seluruh isi lib/main.dart dengan kode berikut. Aplikasi ini punya daftar barang, tombol tambah, edit, hapus, pencarian, dan peringatan stok menipis:

import 'package:flutter/material.dart';
import 'models/barang.dart';
import 'db/database_helper.dart';

void main() {
  runApp(const AplikasiStok());
}

class AplikasiStok extends StatelessWidget {
  const AplikasiStok({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Stok Toko',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const HalamanStok(),
    );
  }
}

class HalamanStok extends StatefulWidget {
  const HalamanStok({super.key});

  @override
  State<HalamanStok> createState() => _HalamanStokState();
}

class _HalamanStokState extends State<HalamanStok> {
  final DatabaseHelper _db = DatabaseHelper.instance;
  List<Barang> _semuaBarang = [];
  List<Barang> _tampilBarang = [];
  final TextEditingController _cariController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _muatBarang();
  }

  // Muat ulang data dari database
  Future<void> _muatBarang() async {
    final data = await _db.ambilSemuaBarang();
    setState(() {
      _semuaBarang = data;
      _tampilBarang = data;
    });
  }

  // Filter daftar berdasarkan kata kunci pencarian
  void _cari(String kataKunci) {
    final k = kataKunci.toLowerCase();
    setState(() {
      _tampilBarang = _semuaBarang
          .where((b) => b.nama.toLowerCase().contains(k))
          .toList();
    });
  }

  // Format angka menjadi Rp, misal 15000 -> Rp15.000
  String _rupiah(int angka) {
    final s = angka.toString();
    final buffer = StringBuffer();
    int hitung = 0;
    for (int i = s.length - 1; i >= 0; i--) {
      buffer.write(s[i]);
      hitung++;
      if (hitung % 3 == 0 && i != 0) buffer.write('.');
    }
    return 'Rp${buffer.toString().split('').reversed.join()}';
  }

  // Dialog tambah / edit barang
  void _formBarang({Barang? barang}) {
    final namaC = TextEditingController(text: barang?.nama ?? '');
    final hargaC = TextEditingController(
        text: barang != null ? barang.harga.toString() : '');
    final stokC = TextEditingController(
        text: barang != null ? barang.stok.toString() : '');

    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: Text(barang == null ? 'Tambah Barang' : 'Ubah Barang'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: namaC,
              decoration: const InputDecoration(labelText: 'Nama barang'),
            ),
            TextField(
              controller: hargaC,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(labelText: 'Harga (Rp)'),
            ),
            TextField(
              controller: stokC,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(labelText: 'Stok'),
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Batal'),
          ),
          ElevatedButton(
            onPressed: () async {
              final nama = namaC.text.trim();
              final harga = int.tryParse(hargaC.text) ?? 0;
              final stok = int.tryParse(stokC.text) ?? 0;

              if (nama.isEmpty) return; // nama wajib diisi

              if (barang == null) {
                await _db.tambahBarang(
                    Barang(nama: nama, harga: harga, stok: stok));
              } else {
                await _db.ubahBarang(barang.copyWith(
                    nama: nama, harga: harga, stok: stok));
              }

              if (mounted) Navigator.pop(context);
              _muatBarang();
            },
            child: const Text('Simpan'),
          ),
        ],
      ),
    );
  }

  // Konfirmasi sebelum menghapus barang
  void _konfirmasiHapus(Barang barang) {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Hapus barang?'),
        content: Text('''Yakin hapus "${barang.nama}" dari daftar?'''),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Batal'),
          ),
          ElevatedButton(
            style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
            onPressed: () async {
              await _db.hapusBarang(barang.id!);
              if (mounted) Navigator.pop(context);
              _muatBarang();
            },
            child: const Text('Hapus',
                style: TextStyle(color: Colors.white)),
          ),
        ],
      ),
    );
  }

  // Tambah / kurangi stok dengan cepat (tombol + dan -)
  Future<void> _ubahStok(Barang barang, int delta) async {
    final baru = barang.stok + delta;
    if (baru < 0) return;
    await _db.ubahBarang(barang.copyWith(stok: baru));
    _muatBarang();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Stok Toko'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextField(
              controller: _cariController,
              onChanged: _cari,
              decoration: const InputDecoration(
                labelText: 'Cari barang...',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
            ),
          ),
          Expanded(
            child: _tampilBarang.isEmpty
                ? const Center(
                    child: Text('Belum ada barang. Tekan + untuk menambah.'),
                  )
                : ListView.builder(
                    itemCount: _tampilBarang.length,
                    itemBuilder: (context, i) {
                      final b = _tampilBarang[i];
                      final menipis = b.stok < 5;
                      return Card(
                        margin: const EdgeInsets.symmetric(
                            horizontal: 12, vertical: 4),
                        child: ListTile(
                          leading: CircleAvatar(
                            backgroundColor:
                                menipis ? Colors.red[100] : Colors.teal[100],
                            child: Text(
                              b.stok.toString(),
                              style: const TextStyle(
                                  fontWeight: FontWeight.bold),
                            ),
                          ),
                          title: Text(b.nama,
                              style: const TextStyle(
                                  fontWeight: FontWeight.bold)),
                          subtitle: Text(
                            '${_rupiah(b.harga)}${menipis ? "  •  STOK MENIPIS!" : ""}',
                            style: TextStyle(
                                color: menipis ? Colors.red : null),
                          ),
                          trailing: Row(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              IconButton(
                                icon: const Icon(Icons.remove),
                                onPressed: () => _ubahStok(b, -1),
                              ),
                              IconButton(
                                icon: const Icon(Icons.add),
                                onPressed: () => _ubahStok(b, 1),
                              ),
                              IconButton(
                                icon: const Icon(Icons.edit),
                                onPressed: () => _formBarang(barang: b),
                              ),
                              IconButton(
                                icon: const Icon(Icons.delete,
                                    color: Colors.red),
                                onPressed: () => _konfirmasiHapus(b),
                              ),
                            ],
                          ),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _formBarang(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

6. Jalankan dan Uji Aplikasi

Hubungkan HP Android (aktifkan USB Debugging di Opsi Pengembang) atau jalankan emulator, lalu:

flutter run

Coba alur ini untuk memastikan semuanya berfungsi:

1. Tekan tombol +, isi nama "Indomie Goreng", harga 3500, stok 10, lalu Simpan. Barang harus muncul di daftar dengan lingkaran angka 10.

2. Tekan tombol - enam kali sampai stok jadi 4. Angka lingkaran berubah merah dan muncul tulisan STOK MENIPIS! — fitur peringatan stok berjalan.

3. Ketik "indomie" di kolom pencarian. Daftar harus tersaring hanya menampilkan barang tersebut.

4. Tekan ikon pencil untuk mengubah harga, dan ikon tempat sampah untuk menghapus (ada dialog konfirmasi dulu).

5. Tutup aplikasi sepenuhnya lalu buka lagi — data harus tetap ada karena tersimpan di database SQLite di HP. Artinya aplikasi sudah jalan offline.

Penutup

Selamat, kamu baru saja membuat aplikasi stok barang yang jalan offline dengan database asli! Dari sini kamu bisa kembangkan sendiri: tambah kolom kategori, buat halaman laporan penjualan harian, atau ekspor data ke file CSV. Kalau ada error saat flutter pub get, biasanya karena koneksi internet — coba lagi sampai selesai. Selamat ngoding! 🚀

Komentar