Bagian ini menghubungkan logika yang sudah teruji ke penyimpanan permanen lewat pola DAO (Data Access Object): satu antarmuka KontakDao menjadi kontrak penyimpanan, dan DaftarKontak hanya mengenal kontrak tersebut. Kita membangun dua implementasi — memori untuk pengujian dan berkas teks untuk data nyata — lalu membuktikan bahwa keduanya bisa bertukar tempat tanpa satu baris pun berubah pada logika maupun UI. Bagian ini juga menutup aplikasi menjadi utuh: kelas AplikasiKontak menjadi titik masuk yang memilih penyimpanan dan membuka jendela. Padanan JDBC untuk database sungguhan ditampilkan sebagai konsep karena lingkungan kerja proyek ini tidak menyertakan driver database.
Kontrak Penyimpanan dengan Pola DAO #
DAO memisahkan apa yang disimpan dari bagaimana menyimpannya. DaftarKontak tidak tahu kontaknya berakhir di ArrayList, berkas teks, atau tabel SQL — kelas tersebut hanya memanggil empat method pada antarmuka berikut:
import java.util.List;
/**
* Kontrak penyimpanan kontak — pola DAO (Data Access Object).
* DaftarKontak hanya mengenal antarmuka ini, sehingga jenis penyimpanan
* bisa ditukar (memori, berkas, database) tanpa menyentuh kode lain.
*/
public interface KontakDao {
/** Memuat seluruh kontak yang tersimpan. */
List<Kontak> muatSemua() throws PersistensiGagalException;
/** Menyimpan kontak baru ke penyimpanan. */
void tambah(Kontak kontak) throws PersistensiGagalException;
/** Menimpa kontak lama berdasarkan id dengan data baru. */
void perbarui(Kontak kontak) throws PersistensiGagalException;
/** Menghapus kontak berdasarkan id dari penyimpanan. */
void hapus(int id) throws PersistensiGagalException;
}Seluruh method kontrak menyatakan satu exception penyimpanan. PersistensiGagalException menjadi satu-satunya jenis kegagalan yang boleh lewat dari lapisan penyimpanan, dan konstruktor keduanya menerima Throwable sebab asli agar jejak IOException tidak terputus:
/** Kegagalan saat membaca atau menulis penyimpanan kontak. */
public class PersistensiGagalException extends Exception {
public PersistensiGagalException(String pesan) {
super(pesan);
}
/** Menyimpan sebab asli (misalnya IOException) agar bisa ditelusuri. */
public PersistensiGagalException(String pesan, Throwable sebab) {
super(pesan, sebab);
}
}Implementasi Memori dan Berkas #
Implementasi pertama menempel pada ArrayList — cepat, tanpa berkas, dan menjadi penyimpanan bawaan saat pengujian tidak ingin menyentuh disk. Implementasi kedua menulis satu kontak per baris dengan format id|nama|email|telepon, selalu dengan StandardCharsets.UTF_8:
import java.util.ArrayList;
import java.util.List;
/** Penyimpanan di memori: cepat, tanpa berkas, cocok untuk pengujian. */
public class KontakDaoMemori implements KontakDao {
private final List<Kontak> simpanan = new ArrayList<>();
@Override
public List<Kontak> muatSemua() {
return List.copyOf(simpanan);
}
@Override
public void tambah(Kontak kontak) {
simpanan.add(kontak);
}
@Override
public void perbarui(Kontak kontak) throws PersistensiGagalException {
int posisi = indeksDariId(kontak.id());
if (posisi < 0) {
throw new PersistensiGagalException(
"id " + kontak.id() + " tidak ada di penyimpanan memori");
}
simpanan.set(posisi, kontak);
}
@Override
public void hapus(int id) throws PersistensiGagalException {
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new PersistensiGagalException(
"id " + id + " tidak ada di penyimpanan memori");
}
simpanan.remove(posisi);
}
private int indeksDariId(int id) {
for (int i = 0; i < simpanan.size(); i++) {
if (simpanan.get(i).id() == id) {
return i;
}
}
return -1;
}
}import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Penyimpanan berkas teks: satu kontak per baris dengan format
* id|nama|email|telepon. Setiap perubahan ditulis ulang seluruhnya
* dengan charset UTF-8.
*/
public class KontakDaoBerkas implements KontakDao {
private final Path berkas;
public KontakDaoBerkas(Path berkas) {
this.berkas = berkas;
}
@Override
public List<Kontak> muatSemua() throws PersistensiGagalException {
if (!Files.exists(berkas)) {
return List.of();
}
List<Kontak> hasil = new ArrayList<>();
try {
List<String> barisSemua = Files.readAllLines(berkas, StandardCharsets.UTF_8);
for (int i = 0; i < barisSemua.size(); i++) {
String baris = barisSemua.get(i);
if (baris.isBlank()) {
continue;
}
hasil.add(uraikan(baris, i + 1));
}
return hasil;
} catch (IOException e) {
throw new PersistensiGagalException(
"gagal membaca " + berkas, e);
}
}
@Override
public void tambah(Kontak kontak) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
data.add(kontak);
tulisSemua(data);
}
@Override
public void perbarui(Kontak kontak) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
for (int i = 0; i < data.size(); i++) {
if (data.get(i).id() == kontak.id()) {
data.set(i, kontak);
tulisSemua(data);
return;
}
}
throw new PersistensiGagalException(
"id " + kontak.id() + " tidak ada di " + berkas);
}
@Override
public void hapus(int id) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
for (Kontak k : data) {
if (k.id() == id) {
data.remove(k);
tulisSemua(data);
return;
}
}
throw new PersistensiGagalException(
"id " + id + " tidak ada di " + berkas);
}
/** Mengubah satu baris berkas menjadi objek Kontak. */
private static Kontak uraikan(String baris, int nomor)
throws PersistensiGagalException {
String[] bagian = baris.split("\\|", 4);
if (bagian.length != 4) {
throw new PersistensiGagalException(
"baris rusak pada nomor " + nomor + ": \"" + baris + "\"");
}
try {
int id = Integer.parseInt(bagian[0].strip());
return new Kontak(id, bagian[1], bagian[2], bagian[3]);
} catch (NumberFormatException e) {
throw new PersistensiGagalException(
"id bukan angka pada baris " + nomor + ": " + bagian[0]);
}
}
/** Menulis ulang seluruh isi berkas dari daftar kontak. */
private void tulisSemua(List<Kontak> data) throws PersistensiGagalException {
StringBuilder teks = new StringBuilder();
for (Kontak k : data) {
teks.append(k.id()).append('|')
.append(k.nama()).append('|')
.append(k.email()).append('|')
.append(k.telepon()).append('\n');
}
try {
Files.writeString(berkas, teks.toString(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new PersistensiGagalException(
"gagal menulis " + berkas, e);
}
}
}Empat keputusan pada implementasi berkas patut dibedah:
- Method
muatSemua(): baris kosong dilewati; baris dengan jumlah kolom salah atau id bukan angka ditolak sebagaiPersistensiGagalExceptionyang menyebut nomor barisnya. - Method
tulisSemua(...): setiap perubahan menulis ulang seluruh berkas. Untuk ratusan kontak cara ini sederhana dan aman; bila data membesar, inilah titik yang digantikan database. - Konstruktor
KontakDaoBerkas(Path): lokasi berkas disuntik, bukan ditanam — pengujian memakai berkas sementara tanpa menyentuh data pengguna. - Pemisah
|: format satu baris menuntut aturan baru pada validasi — field tidak boleh memuat karakter pemisah, dan aturan tersebut berlaku sejak masukan diterima.
Menulis Tembus dari DaftarKontak #
DaftarKontak kini menjadi penyimpanan tulis-tembus: konstruktor memuat seluruh kontak dari DAO, dan setiap operasi mengubah data di memori setelah berhasil menulis ke penyimpanan. Konstruktor tanpa argumen memakai KontakDaoMemori, sehingga perilaku Bagian 2 tetap hidup:
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Logika inti aplikasi Buku Kontak: daftar kontak dengan validasi,
* operasi CRUD, dan pencarian. Kelas ini tidak mengenal satu pun
* kelas Swing sehingga bisa diuji tanpa layar.
*/
public class DaftarKontak {
private final KontakDao dao;
private final List<Kontak> kontak = new ArrayList<>();
private int idBerikutnya = 1;
/** Daftar kontak tanpa penyimpanan permanen (penyimpanan memori). */
public DaftarKontak() {
this.dao = new KontakDaoMemori();
}
/**
* Daftar kontak yang dimuat dari penyimpanan dan menulis
* kembali ke penyimpanan yang sama pada setiap perubahan.
*/
public DaftarKontak(KontakDao dao) throws PersistensiGagalException {
this.dao = dao;
kontak.addAll(dao.muatSemua());
for (Kontak k : kontak) {
idBerikutnya = Math.max(idBerikutnya, k.id() + 1);
}
}
/** Menambah kontak baru setelah seluruh field tervalidasi. */
public Kontak tambah(String nama, String email, String telepon)
throws KontakInvalidException, PersistensiGagalException {
periksa(nama, email, telepon);
periksaEmailBelumTerpakai(email, 0);
Kontak baru = new Kontak(idBerikutnya,
nama.strip(), email.strip(), telepon.strip());
dao.tambah(baru);
kontak.add(baru);
idBerikutnya++;
return baru;
}
/** Menimpa kontak berdasarkan id dengan nilai field yang baru. */
public Kontak ubah(int id, String nama, String email, String telepon)
throws KontakInvalidException, KontakTidakDitemukanException,
PersistensiGagalException {
periksa(nama, email, telepon);
periksaEmailBelumTerpakai(email, id);
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new KontakTidakDitemukanException(id);
}
Kontak baru = new Kontak(id,
nama.strip(), email.strip(), telepon.strip());
dao.perbarui(baru);
kontak.set(posisi, baru);
return baru;
}
/** Menghapus kontak berdasarkan id dan mengembalikan kontaknya. */
public Kontak hapus(int id)
throws KontakTidakDitemukanException, PersistensiGagalException {
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new KontakTidakDitemukanException(id);
}
Kontak terhapus = kontak.remove(posisi);
dao.hapus(id);
return terhapus;
}
/** Salinan seluruh kontak, terurut sesuai urutan penambahan. */
public List<Kontak> semua() {
return List.copyOf(kontak);
}
/** Kontak berdasarkan id, atau kosong bila tidak ada. */
public Optional<Kontak> cari(int id) {
for (Kontak k : kontak) {
if (k.id() == id) {
return Optional.of(k);
}
}
return Optional.empty();
}
/** Kontak yang nama, email, atau teleponnya memuat kata kunci. */
public List<Kontak> cari(String kataKunci) {
String kunci = kataKunci.strip().toLowerCase(Locale.ROOT);
List<Kontak> hasil = new ArrayList<>();
for (Kontak k : kontak) {
if (k.nama().toLowerCase(Locale.ROOT).contains(kunci)
|| k.email().toLowerCase(Locale.ROOT).contains(kunci)
|| k.telepon().toLowerCase(Locale.ROOT).contains(kunci)) {
hasil.add(k);
}
}
return hasil;
}
public int jumlah() {
return kontak.size();
}
private int indeksDariId(int id) {
for (int i = 0; i < kontak.size(); i++) {
if (kontak.get(i).id() == id) {
return i;
}
}
return -1;
}
/** Validasi seluruh field sebelum kontak disimpan. */
private static void periksa(String nama, String email, String telepon)
throws KontakInvalidException {
if (nama == null || nama.isBlank()) {
throw new KontakInvalidException("nama tidak boleh kosong");
}
if (email == null || email.isBlank()) {
throw new KontakInvalidException("email tidak boleh kosong");
}
if (telepon == null || telepon.isBlank()) {
throw new KontakInvalidException("telepon tidak boleh kosong");
}
periksaFormatEmail(email.strip());
periksaTanpaPemisah(nama.strip(), email.strip(), telepon.strip());
}
/** Email harus berformat [email protected]. */
private static void periksaFormatEmail(String email)
throws KontakInvalidException {
int posisiAt = email.indexOf('@');
if (posisiAt <= 0 || posisiAt != email.lastIndexOf('@')
|| email.indexOf('.', posisiAt) < 0) {
throw new KontakInvalidException(
"email harus berformat [email protected]: " + email);
}
}
/** Karakter | dipakai sebagai pemisah kolom pada berkas penyimpanan. */
private static void periksaTanpaPemisah(String nama, String email, String telepon)
throws KontakInvalidException {
if (nama.indexOf('|') >= 0 || email.indexOf('|') >= 0
|| telepon.indexOf('|') >= 0) {
throw new KontakInvalidException(
"field tidak boleh memuat karakter |");
}
}
/** Email hanya boleh dipakai satu kontak. */
private void periksaEmailBelumTerpakai(String email, int idKecuali)
throws KontakInvalidException {
for (Kontak k : kontak) {
if (k.id() != idKecuali
&& k.email().equalsIgnoreCase(email.strip())) {
throw new KontakInvalidException(
"email sudah dipakai kontak lain: " + email.strip());
}
}
}
}Tiga perubahan penting pada berkas tersebut:
- Konstruktor
DaftarKontak(KontakDao): memuat kontak lama, lalu menghitungidBerikutnya = max(id) + 1— nomor id dilanjutkan dari data yang tersimpan, bukan mulai dari satu. - Method
tambah(...),ubah(...),hapus(...): memanggildao.tambah/perbarui/hapuslebih dulu; bila penulisan gagal, daftar di memori tidak berubah. - Method
periksaTanpaPemisah(...): aturan baru penolakan karakter|, mengikuti format penyimpanan berkas.
Model dan Komponen yang Ikut Menyesuaikan #
Enam berkas lain mengikuti perubahan kecil. KontakTableModel menambahkan PersistensiGagalException pada klausa throws tiga operasinya. KontakInvalidException memperbarui dokumentasinya menyebut aturan pemisah. Empat berkas sisanya tidak berubah sama sekali:
/**
* Satu baris data kontak pada aplikasi Buku Kontak.
* Record hanya menyimpan nilai; aturan bisnis hidup di DaftarKontak.
*/
public record Kontak(int id, String nama, String email, String telepon) {
}/**
* Masukan kontak yang melanggar aturan validasi:
* field kosong, format email salah, email dipakai kontak lain,
* atau field memuat karakter pemisah penyimpanan.
*/
public class KontakInvalidException extends Exception {
public KontakInvalidException(String pesan) {
super(pesan);
}
}/** Id kontak tidak ada pada daftar saat kontak akan diubah atau dihapus. */
public class KontakTidakDitemukanException extends Exception {
public KontakTidakDitemukanException(int id) {
super("kontak dengan id " + id + " tidak ditemukan");
}
}import javax.swing.table.AbstractTableModel;
/**
* TableModel kustom yang membaca DaftarKontak sebagai sumber data.
* Operasi CRUD diteruskan ke DaftarKontak, lalu notifikasi perubahan
* dikirim ke view lewat keluarga method fire... .
*/
public class KontakTableModel extends AbstractTableModel {
private static final String[] KOLUM = { "Id", "Nama", "Email", "Telepon" };
private final DaftarKontak daftar;
public KontakTableModel(DaftarKontak daftar) {
this.daftar = daftar;
}
@Override
public int getRowCount() {
return daftar.jumlah();
}
@Override
public int getColumnCount() {
return KOLUM.length;
}
@Override
public String getColumnName(int kolom) {
return KOLUM[kolom];
}
@Override
public Object getValueAt(int baris, int kolom) {
Kontak k = daftar.semua().get(baris);
return switch (kolom) {
case 0 -> k.id();
case 1 -> k.nama();
case 2 -> k.email();
default -> k.telepon();
};
}
/** Menambah kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak tambah(String nama, String email, String telepon)
throws KontakInvalidException, PersistensiGagalException {
Kontak baru = daftar.tambah(nama, email, telepon);
int baris = daftar.jumlah() - 1;
fireTableRowsInserted(baris, baris);
return baru;
}
/** Mengubah kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak ubah(int id, String nama, String email, String telepon)
throws KontakInvalidException, KontakTidakDitemukanException,
PersistensiGagalException {
int baris = barisDariId(id);
Kontak baru = daftar.ubah(id, nama, email, telepon);
fireTableRowsUpdated(baris, baris);
return baru;
}
/** Menghapus kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak hapus(int id)
throws KontakTidakDitemukanException, PersistensiGagalException {
int baris = barisDariId(id);
Kontak terhapus = daftar.hapus(id);
fireTableRowsDeleted(baris, baris);
return terhapus;
}
/** Kontak pada baris model — dipakai aksi terhadap baris terpilih. */
public Kontak kontakDiBaris(int baris) {
return daftar.semua().get(baris);
}
/** Jumlah baris saat ini pada model. */
public int jumlahBaris() {
return daftar.jumlah();
}
private int barisDariId(int id) {
for (int i = 0; i < daftar.jumlah(); i++) {
if (daftar.semua().get(i).id() == id) {
return i;
}
}
return -1;
}
}import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
/**
* Panel formulir kontak: tiga isian berjajar dengan label rata kanan.
* Panel hanya membaca dan menulis komponen; validasi tetap di DaftarKontak.
*/
public class PanelFormKontak extends JPanel {
private final JTextField isianNama = new JTextField(16);
private final JTextField isianEmail = new JTextField(16);
private final JTextField isianTelepon = new JTextField(16);
public PanelFormKontak() {
super(new GridBagLayout());
GridBagConstraints k = new GridBagConstraints();
k.insets = new Insets(4, 4, 4, 4);
k.anchor = GridBagConstraints.LINE_END;
String[] label = { "Nama:", "Email:", "Telepon:" };
JTextField[] isian = { isianNama, isianEmail, isianTelepon };
for (int i = 0; i < label.length; i++) {
k.gridx = 0;
k.gridy = i;
add(new JLabel(label[i]), k);
k.gridx = 1;
k.fill = GridBagConstraints.HORIZONTAL;
k.weightx = 1.0;
add(isian[i], k);
k.fill = GridBagConstraints.NONE;
k.weightx = 0.0;
}
}
/** Nilai isian nama, sudah dipangkas spasinya. */
public String nama() {
return isianNama.getText().strip();
}
/** Nilai isian email, sudah dipangkas spasinya. */
public String email() {
return isianEmail.getText().strip();
}
/** Nilai isian telepon, sudah dipangkas spasinya. */
public String telepon() {
return isianTelepon.getText().strip();
}
/** Mengisi formulir dari satu kontak — dipakai saat mengubah data. */
public void isi(Kontak kontak) {
isianNama.setText(kontak.nama());
isianEmail.setText(kontak.email());
isianTelepon.setText(kontak.telepon());
}
/** Mengosongkan seluruh isian setelah kontak selesai disimpan. */
public void kosongkan() {
isianNama.setText("");
isianEmail.setText("");
isianTelepon.setText("");
}
}import java.util.Locale;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.RowFilter;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;
/**
* Panel tabel kontak: JTable di dalam JScrollPane.
* Panel memasang TableRowSorter agar pencarian bisa menyaring baris
* yang tampil tanpa mengubah data pada model.
*/
public class PanelTabelKontak extends JScrollPane {
private final JTable tabel;
private final TableRowSorter<TableModel> sorter;
public PanelTabelKontak(KontakTableModel model) {
super();
tabel = new JTable(model);
tabel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
sorter = new TableRowSorter<>(model);
tabel.setRowSorter(sorter);
setViewportView(tabel);
}
/** Tabel yang dipakai panel — pembacaan nilai lewat view ini. */
public JTable tabel() {
return tabel;
}
/** Id kontak pada baris terpilih, atau -1 bila tidak ada yang terpilih. */
public int idTerpilih() {
int barisTampil = tabel.getSelectedRow();
if (barisTampil < 0) {
return -1;
}
int barisModel = tabel.convertRowIndexToModel(barisTampil);
return (int) tabel.getModel().getValueAt(barisModel, 0);
}
/** Menandai baris model tertentu pada tampilan. */
public void pilihBaris(int barisModel) {
int barisTampil = tabel.convertRowIndexToView(barisModel);
if (barisTampil >= 0) {
tabel.setRowSelectionInterval(barisTampil, barisTampil);
} else {
tabel.clearSelection();
}
}
/**
* Menyaring baris yang tampil dengan kata kunci pada kolom nama,
* email, atau telepon. Kata kunci kosong menghapus saringan.
*/
public void saring(String kataKunci) {
String kunci = kataKunci == null ? "" : kataKunci.strip();
if (kunci.isEmpty()) {
sorter.setRowFilter(null);
return;
}
sorter.setRowFilter(new RowFilter<TableModel, Integer>() {
@Override
public boolean include(Entry<? extends TableModel, ? extends Integer> entri) {
for (int kolom = 1; kolom < entri.getValueCount(); kolom++) {
if (entri.getStringValue(kolom)
.toLowerCase(Locale.ROOT).contains(kunci)) {
return true;
}
}
return false;
}
});
}
}Titik Masuk Aplikasi Lengkap #
Jendela utama kini menerima DaftarKontak jadi dari luar — jendela tidak lagi memilih penyimpanannya sendiri:
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
/**
* Jendela utama aplikasi Buku Kontak.
* JFrame tidak bisa dibuat pada lingkungan tanpa layar, sehingga
* perakitan isi jendela dipisahkan ke method static bangunPanelTombol
* yang bisa diuji tanpa membuka jendela nyata.
*/
public class JendelaUtama extends JFrame {
public JendelaUtama(DaftarKontak daftar) {
super("Buku Kontak");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
KontakTableModel model = new KontakTableModel(daftar);
PanelFormKontak form = new PanelFormKontak();
PanelTabelKontak tabel = new PanelTabelKontak(model);
JLabel status = new JLabel("Siap.");
JPanel tombol = bangunPanelTombol(form, model, tabel, status);
JPanel selatan = new JPanel(new BorderLayout(4, 4));
selatan.add(tombol, BorderLayout.NORTH);
selatan.add(status, BorderLayout.SOUTH);
JPanel isi = new JPanel(new BorderLayout(8, 8));
isi.add(form, BorderLayout.NORTH);
isi.add(tabel, BorderLayout.CENTER);
isi.add(selatan, BorderLayout.SOUTH);
setContentPane(isi);
pack();
setLocationRelativeTo(null);
}
/**
* Membangun panel tombol beserta seluruh aksinya.
* Setiap tombol hanya membaca formulir, memanggil satu operasi
* KontakTableModel, lalu melaporkan hasil pada label status.
*/
static JPanel bangunPanelTombol(PanelFormKontak form, KontakTableModel model,
PanelTabelKontak tabel, JLabel status) {
JPanel tombol = new JPanel(new FlowLayout(FlowLayout.LEADING));
JButton tambah = new JButton("Tambah");
tambah.addActionListener(e -> {
try {
Kontak baru = model.tambah(form.nama(), form.email(), form.telepon());
form.kosongkan();
tabel.pilihBaris(model.jumlahBaris() - 1);
status.setText("Kontak " + baru.nama() + " (id " + baru.id() + ") ditambahkan.");
} catch (KontakInvalidException | PersistensiGagalException ex) {
status.setText("Gagal menambah: " + ex.getMessage());
}
});
JButton ubah = new JButton("Ubah");
ubah.addActionListener(e -> {
int id = tabel.idTerpilih();
if (id < 0) {
status.setText("Pilih kontak pada tabel terlebih dahulu.");
return;
}
try {
Kontak baru = model.ubah(id, form.nama(), form.email(), form.telepon());
status.setText("Kontak id " + baru.id() + " diubah menjadi " + baru.nama() + ".");
} catch (KontakInvalidException | KontakTidakDitemukanException
| PersistensiGagalException ex) {
status.setText("Gagal mengubah: " + ex.getMessage());
}
});
JButton hapus = new JButton("Hapus");
hapus.addActionListener(e -> {
int id = tabel.idTerpilih();
if (id < 0) {
status.setText("Pilih kontak pada tabel terlebih dahulu.");
return;
}
try {
Kontak terhapus = model.hapus(id);
status.setText("Kontak " + terhapus.nama() + " (id " + id + ") dihapus.");
} catch (KontakTidakDitemukanException | PersistensiGagalException ex) {
status.setText("Gagal menghapus: " + ex.getMessage());
}
});
JTextField isianCari = new JTextField(10);
JButton cari = new JButton("Cari");
cari.addActionListener(e -> {
tabel.saring(isianCari.getText());
status.setText(tabel.tabel().getRowCount() + " kontak cocok untuk \""
+ isianCari.getText().strip() + "\".");
});
tombol.add(tambah);
tombol.add(ubah);
tombol.add(hapus);
tombol.add(isianCari);
tombol.add(cari);
return tombol;
}
}Pemilihan penyimpanan berpindah ke AplikasiKontak, titik masuk aplikasi. Method main memeriksa GraphicsEnvironment.isHeadless() lebih dulu: pada komputer tanpa tampilan grafis, aplikasi melaporkan kondisi tersebut dan keluar dengan rapi alih-alih melempar HeadlessException dari dalam EDT. Pembukaan jendela tetap dibungkus SwingUtilities.invokeLater agar seluruh komponen dibangun di EDT:
import java.awt.GraphicsEnvironment;
import java.nio.file.Path;
import javax.swing.SwingUtilities;
/**
* Titik masuk aplikasi Buku Kontak: memilih penyimpanan berkas,
* memuat data, lalu membuka jendela utama pada Event Dispatch Thread.
*/
public class AplikasiKontak {
public static void main(String[] args) {
if (GraphicsEnvironment.isHeadless()) {
System.out.println("Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.");
System.out.println("Jalankan aplikasi ini pada komputer dengan tampilan grafis.");
return;
}
SwingUtilities.invokeLater(AplikasiKontak::bukaJendela);
}
private static void bukaJendela() {
DaftarKontak daftar;
try {
daftar = new DaftarKontak(new KontakDaoBerkas(Path.of("kontak.txt")));
} catch (PersistensiGagalException e) {
System.err.println("Gagal memuat penyimpanan: " + e.getMessage());
daftar = new DaftarKontak();
}
new JendelaUtama(daftar).setVisible(true);
}
}Output yang Dihasilkan:
Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.
Jalankan aplikasi ini pada komputer dengan tampilan grafis.Keluaran tersebut berasal dari lingkungan eksekusi tanpa layar tempat seluruh pengujian proyek ini berjalan. Pada komputer berkantor grafis, baris yang sama berlanjut menjadi jendela Buku Kontak yang terbuka, dan kontak.txt dimuat sebagai data awal.
Penukaran Penyimpanan dalam Praktik #
Entri berikut menjalankan logika yang sama di atas dua penyimpanan berbeda, lalu memeriksa isi berkas secara langsung — termasuk pemuatan ulang untuk membuktikan data bertahan melewati akhir eksekusi. Berkas uji-kontak.txt dihapus lebih dulu bila masih ada sisa eksekusi sebelumnya, sehingga setiap eksekusi dimulai dari keadaan kosong dan keluarannya selalu identik:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Menguji pola DAO tanpa membuka jendela: penyimpanan memori,
* penyimpanan berkas, pemuatan ulang, dan penolakan berkas rusak.
* Berkas uji dihapus lebih dulu bila ada, sehingga setiap eksekusi
* selalu dimulai dari keadaan kosong dan keluarannya identik.
*/
public class DemoDao {
public static void main(String[] args) throws Exception {
System.out.println("== penyimpanan memori ==");
DaftarKontak memori = new DaftarKontak(new KontakDaoMemori());
memori.tambah("Budi", "[email protected]", "0812-1000-1001");
memori.tambah("Siti", "[email protected]", "0812-1000-1002");
memori.ubah(1, "Budi Santoso", "[email protected]", "0812-1000-1001");
System.out.println("jumlah = " + memori.jumlah());
System.out.println("baris pertama = " + memori.semua().get(0));
System.out.println();
System.out.println("== penyimpanan berkas ==");
Path berkas = Path.of("uji-kontak.txt");
Files.deleteIfExists(berkas); // mulai kosong agar eksekusi berulang identik
DaftarKontak daftar = new DaftarKontak(new KontakDaoBerkas(berkas));
daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");
System.out.println("isi " + berkas + ":");
for (String baris : Files.readAllLines(berkas, StandardCharsets.UTF_8)) {
System.out.println(" " + baris);
}
System.out.println("pemuatan ulang dari berkas yang sama:");
DaftarKontak termuat = new DaftarKontak(new KontakDaoBerkas(berkas));
System.out.println(" jumlah = " + termuat.jumlah());
System.out.println(" kontak id 2 termuat kembali = "
+ termuat.cari(2).map(Kontak::nama).orElse("-"));
System.out.println();
System.out.println("== perubahan menulis tembus ke berkas ==");
daftar.ubah(2, "Siti Rahayu", "[email protected]", "0815-3000-3004");
daftar.hapus(1);
System.out.println("setelah ubah id 2 dan hapus id 1:");
for (String baris : Files.readAllLines(berkas, StandardCharsets.UTF_8)) {
System.out.println(" " + baris);
}
System.out.println();
System.out.println("== aturan dan kegagalan ==");
try {
daftar.tambah("Budi|Cadangan", "[email protected]", "0812");
} catch (KontakInvalidException e) {
System.out.println("tolak: " + e.getMessage());
}
Path rusak = Path.of("uji-rusak.txt");
Files.writeString(rusak, "baris tanpa pemisah\n", StandardCharsets.UTF_8);
try {
new DaftarKontak(new KontakDaoBerkas(rusak));
} catch (PersistensiGagalException e) {
System.out.println("gagal: " + e.getMessage());
}
}
}Output yang Dihasilkan:
== penyimpanan memori ==
jumlah = 2
baris pertama = Kontak[id=1, nama=Budi Santoso, [email protected], telepon=0812-1000-1001]
== penyimpanan berkas ==
isi uji-kontak.txt:
1|Budi|[email protected]|0812-1000-1001
2|Siti|[email protected]|0812-1000-1002
3|Dewi|[email protected]|0813-2000-2003
pemuatan ulang dari berkas yang sama:
jumlah = 3
kontak id 2 termuat kembali = Siti
== perubahan menulis tembus ke berkas ==
setelah ubah id 2 dan hapus id 1:
2|Siti Rahayu|[email protected]|0815-3000-3004
3|Dewi|[email protected]|0813-2000-2003
== aturan dan kegagalan ==
tolak: field tidak boleh memuat karakter |
gagal: baris rusak pada nomor 1: "baris tanpa pemisah"Baris 1|Budi|[email protected]|0812-1000-1001 membuktikan format berkas; bagian pemuatan ulang menunjukkan jumlah 3 dan kontak id 2 yang termuat kembali utuh. Setelah ubah dan hapus, isi berkas menyusut menjadi dua baris — penulisan tembus bekerja pada setiap operasi, bukan hanya saat aplikasi ditutup. Dua baris terakhir memperlihatkan dua jalur kegagalan: field pemisah ditolak validasi, dan berkas rusak ditolak penyimpanan dengan nomor barisnya. Satu baris Files.deleteIfExists(berkas) menjadikan demo ini aman diulang: tanpa penghapusan tersebut, kontak sisa eksekusi lama akan dimuat kembali, lalu tambah yang sama ditolak sebagai email duplikat.
Padanan JDBC #
Aplikasi dunia nyata biasanya menitipkan data pada database SQL lewat JDBC. Bentuk implementasi KontakDao untuk JDBC tidak berbeda secara konsep — hanya sumber kegagalannya yang berupa SQLException:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/** Penyimpanan kontak pada database SQL lewat JDBC. */
public class KontakDaoJdbc implements KontakDao {
private final String url;
public KontakDaoJdbc(String url) {
this.url = url;
}
@Override
public List<Kontak> muatSemua() throws PersistensiGagalException {
String sql = "SELECT id, nama, email, telepon FROM kontak ORDER BY id";
try (Connection koneksi = DriverManager.getConnection(url);
PreparedStatement pernyataan = koneksi.prepareStatement(sql);
ResultSet hasil = pernyataan.executeQuery()) {
List<Kontak> daftar = new ArrayList<>();
while (hasil.next()) {
daftar.add(new Kontak(hasil.getInt("id"), hasil.getString("nama"),
hasil.getString("email"), hasil.getString("telepon")));
}
return daftar;
} catch (SQLException e) {
throw new PersistensiGagalException("gagal memuat dari " + url, e);
}
}
@Override
public void tambah(Kontak kontak) throws PersistensiGagalException {
String sql = "INSERT INTO kontak (id, nama, email, telepon) VALUES (?, ?, ?, ?)";
try (Connection koneksi = DriverManager.getConnection(url);
PreparedStatement pernyataan = koneksi.prepareStatement(sql)) {
pernyataan.setInt(1, kontak.id());
pernyataan.setString(2, kontak.nama());
pernyataan.setString(3, kontak.email());
pernyataan.setString(4, kontak.telepon());
pernyataan.executeUpdate();
} catch (SQLException e) {
throw new PersistensiGagalException("gagal menambah ke " + url, e);
}
}
// perbarui dan hapus mengikuti pola yang sama.
}Berkas kontak.txt kini menjadi sumber data aplikasi, dan seluruh perilaku penyimpanan sudah teruji. Pekerjaan tersisa bersifat teknik rilis: memastikan perilaku inti tetap benar secara otomatis lewat harness pengujian, lalu mengemas seluruh berkas menjadi JAR dan image aplikasi yang bisa dibagikan — pekerjaan Bagian 4.
Referensi #
- Oracle: Kelas Files (JDK 25)
- Oracle: Kelas DriverManager (JDK 25)
- Oracle: Tutorial JDBC
- Oracle: Kelas StandardCharsets (JDK 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
AplikasiKontak.java
Unduh berkasimport java.awt.GraphicsEnvironment;
import java.nio.file.Path;
import javax.swing.SwingUtilities;
/**
* Titik masuk aplikasi Buku Kontak: memilih penyimpanan berkas,
* memuat data, lalu membuka jendela utama pada Event Dispatch Thread.
*/
public class AplikasiKontak {
public static void main(String[] args) {
if (GraphicsEnvironment.isHeadless()) {
System.out.println("Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.");
System.out.println("Jalankan aplikasi ini pada komputer dengan tampilan grafis.");
return;
}
SwingUtilities.invokeLater(AplikasiKontak::bukaJendela);
}
private static void bukaJendela() {
DaftarKontak daftar;
try {
daftar = new DaftarKontak(new KontakDaoBerkas(Path.of("kontak.txt")));
} catch (PersistensiGagalException e) {
System.err.println("Gagal memuat penyimpanan: " + e.getMessage());
daftar = new DaftarKontak();
}
new JendelaUtama(daftar).setVisible(true);
}
}
DaftarKontak.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Logika inti aplikasi Buku Kontak: daftar kontak dengan validasi,
* operasi CRUD, dan pencarian. Kelas ini tidak mengenal satu pun
* kelas Swing sehingga bisa diuji tanpa layar.
*/
public class DaftarKontak {
private final KontakDao dao;
private final List<Kontak> kontak = new ArrayList<>();
private int idBerikutnya = 1;
/** Daftar kontak tanpa penyimpanan permanen (penyimpanan memori). */
public DaftarKontak() {
this.dao = new KontakDaoMemori();
}
/**
* Daftar kontak yang dimuat dari penyimpanan dan menulis
* kembali ke penyimpanan yang sama pada setiap perubahan.
*/
public DaftarKontak(KontakDao dao) throws PersistensiGagalException {
this.dao = dao;
kontak.addAll(dao.muatSemua());
for (Kontak k : kontak) {
idBerikutnya = Math.max(idBerikutnya, k.id() + 1);
}
}
/** Menambah kontak baru setelah seluruh field tervalidasi. */
public Kontak tambah(String nama, String email, String telepon)
throws KontakInvalidException, PersistensiGagalException {
periksa(nama, email, telepon);
periksaEmailBelumTerpakai(email, 0);
Kontak baru = new Kontak(idBerikutnya,
nama.strip(), email.strip(), telepon.strip());
dao.tambah(baru);
kontak.add(baru);
idBerikutnya++;
return baru;
}
/** Menimpa kontak berdasarkan id dengan nilai field yang baru. */
public Kontak ubah(int id, String nama, String email, String telepon)
throws KontakInvalidException, KontakTidakDitemukanException,
PersistensiGagalException {
periksa(nama, email, telepon);
periksaEmailBelumTerpakai(email, id);
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new KontakTidakDitemukanException(id);
}
Kontak baru = new Kontak(id,
nama.strip(), email.strip(), telepon.strip());
dao.perbarui(baru);
kontak.set(posisi, baru);
return baru;
}
/** Menghapus kontak berdasarkan id dan mengembalikan kontaknya. */
public Kontak hapus(int id)
throws KontakTidakDitemukanException, PersistensiGagalException {
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new KontakTidakDitemukanException(id);
}
Kontak terhapus = kontak.remove(posisi);
dao.hapus(id);
return terhapus;
}
/** Salinan seluruh kontak, terurut sesuai urutan penambahan. */
public List<Kontak> semua() {
return List.copyOf(kontak);
}
/** Kontak berdasarkan id, atau kosong bila tidak ada. */
public Optional<Kontak> cari(int id) {
for (Kontak k : kontak) {
if (k.id() == id) {
return Optional.of(k);
}
}
return Optional.empty();
}
/** Kontak yang nama, email, atau teleponnya memuat kata kunci. */
public List<Kontak> cari(String kataKunci) {
String kunci = kataKunci.strip().toLowerCase(Locale.ROOT);
List<Kontak> hasil = new ArrayList<>();
for (Kontak k : kontak) {
if (k.nama().toLowerCase(Locale.ROOT).contains(kunci)
|| k.email().toLowerCase(Locale.ROOT).contains(kunci)
|| k.telepon().toLowerCase(Locale.ROOT).contains(kunci)) {
hasil.add(k);
}
}
return hasil;
}
public int jumlah() {
return kontak.size();
}
private int indeksDariId(int id) {
for (int i = 0; i < kontak.size(); i++) {
if (kontak.get(i).id() == id) {
return i;
}
}
return -1;
}
/** Validasi seluruh field sebelum kontak disimpan. */
private static void periksa(String nama, String email, String telepon)
throws KontakInvalidException {
if (nama == null || nama.isBlank()) {
throw new KontakInvalidException("nama tidak boleh kosong");
}
if (email == null || email.isBlank()) {
throw new KontakInvalidException("email tidak boleh kosong");
}
if (telepon == null || telepon.isBlank()) {
throw new KontakInvalidException("telepon tidak boleh kosong");
}
periksaFormatEmail(email.strip());
periksaTanpaPemisah(nama.strip(), email.strip(), telepon.strip());
}
/** Email harus berformat [email protected]. */
private static void periksaFormatEmail(String email)
throws KontakInvalidException {
int posisiAt = email.indexOf('@');
if (posisiAt <= 0 || posisiAt != email.lastIndexOf('@')
|| email.indexOf('.', posisiAt) < 0) {
throw new KontakInvalidException(
"email harus berformat [email protected]: " + email);
}
}
/** Karakter | dipakai sebagai pemisah kolom pada berkas penyimpanan. */
private static void periksaTanpaPemisah(String nama, String email, String telepon)
throws KontakInvalidException {
if (nama.indexOf('|') >= 0 || email.indexOf('|') >= 0
|| telepon.indexOf('|') >= 0) {
throw new KontakInvalidException(
"field tidak boleh memuat karakter |");
}
}
/** Email hanya boleh dipakai satu kontak. */
private void periksaEmailBelumTerpakai(String email, int idKecuali)
throws KontakInvalidException {
for (Kontak k : kontak) {
if (k.id() != idKecuali
&& k.email().equalsIgnoreCase(email.strip())) {
throw new KontakInvalidException(
"email sudah dipakai kontak lain: " + email.strip());
}
}
}
}
DemoDao.java
Unduh berkasimport java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Menguji pola DAO tanpa membuka jendela: penyimpanan memori,
* penyimpanan berkas, pemuatan ulang, dan penolakan berkas rusak.
* Berkas uji dihapus lebih dulu bila ada, sehingga setiap eksekusi
* selalu dimulai dari keadaan kosong dan keluarannya identik.
*/
public class DemoDao {
public static void main(String[] args) throws Exception {
System.out.println("== penyimpanan memori ==");
DaftarKontak memori = new DaftarKontak(new KontakDaoMemori());
memori.tambah("Budi", "[email protected]", "0812-1000-1001");
memori.tambah("Siti", "[email protected]", "0812-1000-1002");
memori.ubah(1, "Budi Santoso", "[email protected]", "0812-1000-1001");
System.out.println("jumlah = " + memori.jumlah());
System.out.println("baris pertama = " + memori.semua().get(0));
System.out.println();
System.out.println("== penyimpanan berkas ==");
Path berkas = Path.of("uji-kontak.txt");
Files.deleteIfExists(berkas); // mulai kosong agar eksekusi berulang identik
DaftarKontak daftar = new DaftarKontak(new KontakDaoBerkas(berkas));
daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");
System.out.println("isi " + berkas + ":");
for (String baris : Files.readAllLines(berkas, StandardCharsets.UTF_8)) {
System.out.println(" " + baris);
}
System.out.println("pemuatan ulang dari berkas yang sama:");
DaftarKontak termuat = new DaftarKontak(new KontakDaoBerkas(berkas));
System.out.println(" jumlah = " + termuat.jumlah());
System.out.println(" kontak id 2 termuat kembali = "
+ termuat.cari(2).map(Kontak::nama).orElse("-"));
System.out.println();
System.out.println("== perubahan menulis tembus ke berkas ==");
daftar.ubah(2, "Siti Rahayu", "[email protected]", "0815-3000-3004");
daftar.hapus(1);
System.out.println("setelah ubah id 2 dan hapus id 1:");
for (String baris : Files.readAllLines(berkas, StandardCharsets.UTF_8)) {
System.out.println(" " + baris);
}
System.out.println();
System.out.println("== aturan dan kegagalan ==");
try {
daftar.tambah("Budi|Cadangan", "[email protected]", "0812");
} catch (KontakInvalidException e) {
System.out.println("tolak: " + e.getMessage());
}
Path rusak = Path.of("uji-rusak.txt");
Files.writeString(rusak, "baris tanpa pemisah\n", StandardCharsets.UTF_8);
try {
new DaftarKontak(new KontakDaoBerkas(rusak));
} catch (PersistensiGagalException e) {
System.out.println("gagal: " + e.getMessage());
}
}
}
JendelaUtama.java
Unduh berkasimport java.awt.BorderLayout;
import java.awt.FlowLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
/**
* Jendela utama aplikasi Buku Kontak.
* JFrame tidak bisa dibuat pada lingkungan tanpa layar, sehingga
* perakitan isi jendela dipisahkan ke method static bangunPanelTombol
* yang bisa diuji tanpa membuka jendela nyata.
*/
public class JendelaUtama extends JFrame {
public JendelaUtama(DaftarKontak daftar) {
super("Buku Kontak");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
KontakTableModel model = new KontakTableModel(daftar);
PanelFormKontak form = new PanelFormKontak();
PanelTabelKontak tabel = new PanelTabelKontak(model);
JLabel status = new JLabel("Siap.");
JPanel tombol = bangunPanelTombol(form, model, tabel, status);
JPanel selatan = new JPanel(new BorderLayout(4, 4));
selatan.add(tombol, BorderLayout.NORTH);
selatan.add(status, BorderLayout.SOUTH);
JPanel isi = new JPanel(new BorderLayout(8, 8));
isi.add(form, BorderLayout.NORTH);
isi.add(tabel, BorderLayout.CENTER);
isi.add(selatan, BorderLayout.SOUTH);
setContentPane(isi);
pack();
setLocationRelativeTo(null);
}
/**
* Membangun panel tombol beserta seluruh aksinya.
* Setiap tombol hanya membaca formulir, memanggil satu operasi
* KontakTableModel, lalu melaporkan hasil pada label status.
*/
static JPanel bangunPanelTombol(PanelFormKontak form, KontakTableModel model,
PanelTabelKontak tabel, JLabel status) {
JPanel tombol = new JPanel(new FlowLayout(FlowLayout.LEADING));
JButton tambah = new JButton("Tambah");
tambah.addActionListener(e -> {
try {
Kontak baru = model.tambah(form.nama(), form.email(), form.telepon());
form.kosongkan();
tabel.pilihBaris(model.jumlahBaris() - 1);
status.setText("Kontak " + baru.nama() + " (id " + baru.id() + ") ditambahkan.");
} catch (KontakInvalidException | PersistensiGagalException ex) {
status.setText("Gagal menambah: " + ex.getMessage());
}
});
JButton ubah = new JButton("Ubah");
ubah.addActionListener(e -> {
int id = tabel.idTerpilih();
if (id < 0) {
status.setText("Pilih kontak pada tabel terlebih dahulu.");
return;
}
try {
Kontak baru = model.ubah(id, form.nama(), form.email(), form.telepon());
status.setText("Kontak id " + baru.id() + " diubah menjadi " + baru.nama() + ".");
} catch (KontakInvalidException | KontakTidakDitemukanException
| PersistensiGagalException ex) {
status.setText("Gagal mengubah: " + ex.getMessage());
}
});
JButton hapus = new JButton("Hapus");
hapus.addActionListener(e -> {
int id = tabel.idTerpilih();
if (id < 0) {
status.setText("Pilih kontak pada tabel terlebih dahulu.");
return;
}
try {
Kontak terhapus = model.hapus(id);
status.setText("Kontak " + terhapus.nama() + " (id " + id + ") dihapus.");
} catch (KontakTidakDitemukanException | PersistensiGagalException ex) {
status.setText("Gagal menghapus: " + ex.getMessage());
}
});
JTextField isianCari = new JTextField(10);
JButton cari = new JButton("Cari");
cari.addActionListener(e -> {
tabel.saring(isianCari.getText());
status.setText(tabel.tabel().getRowCount() + " kontak cocok untuk \""
+ isianCari.getText().strip() + "\".");
});
tombol.add(tambah);
tombol.add(ubah);
tombol.add(hapus);
tombol.add(isianCari);
tombol.add(cari);
return tombol;
}
}
Kontak.java
Unduh berkas/**
* Satu baris data kontak pada aplikasi Buku Kontak.
* Record hanya menyimpan nilai; aturan bisnis hidup di DaftarKontak.
*/
public record Kontak(int id, String nama, String email, String telepon) {
}
KontakDao.java
Unduh berkasimport java.util.List;
/**
* Kontrak penyimpanan kontak — pola DAO (Data Access Object).
* DaftarKontak hanya mengenal antarmuka ini, sehingga jenis penyimpanan
* bisa ditukar (memori, berkas, database) tanpa menyentuh kode lain.
*/
public interface KontakDao {
/** Memuat seluruh kontak yang tersimpan. */
List<Kontak> muatSemua() throws PersistensiGagalException;
/** Menyimpan kontak baru ke penyimpanan. */
void tambah(Kontak kontak) throws PersistensiGagalException;
/** Menimpa kontak lama berdasarkan id dengan data baru. */
void perbarui(Kontak kontak) throws PersistensiGagalException;
/** Menghapus kontak berdasarkan id dari penyimpanan. */
void hapus(int id) throws PersistensiGagalException;
}
KontakDaoBerkas.java
Unduh berkasimport java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Penyimpanan berkas teks: satu kontak per baris dengan format
* id|nama|email|telepon. Setiap perubahan ditulis ulang seluruhnya
* dengan charset UTF-8.
*/
public class KontakDaoBerkas implements KontakDao {
private final Path berkas;
public KontakDaoBerkas(Path berkas) {
this.berkas = berkas;
}
@Override
public List<Kontak> muatSemua() throws PersistensiGagalException {
if (!Files.exists(berkas)) {
return List.of();
}
List<Kontak> hasil = new ArrayList<>();
try {
List<String> barisSemua = Files.readAllLines(berkas, StandardCharsets.UTF_8);
for (int i = 0; i < barisSemua.size(); i++) {
String baris = barisSemua.get(i);
if (baris.isBlank()) {
continue;
}
hasil.add(uraikan(baris, i + 1));
}
return hasil;
} catch (IOException e) {
throw new PersistensiGagalException(
"gagal membaca " + berkas, e);
}
}
@Override
public void tambah(Kontak kontak) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
data.add(kontak);
tulisSemua(data);
}
@Override
public void perbarui(Kontak kontak) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
for (int i = 0; i < data.size(); i++) {
if (data.get(i).id() == kontak.id()) {
data.set(i, kontak);
tulisSemua(data);
return;
}
}
throw new PersistensiGagalException(
"id " + kontak.id() + " tidak ada di " + berkas);
}
@Override
public void hapus(int id) throws PersistensiGagalException {
List<Kontak> data = new ArrayList<>(muatSemua());
for (Kontak k : data) {
if (k.id() == id) {
data.remove(k);
tulisSemua(data);
return;
}
}
throw new PersistensiGagalException(
"id " + id + " tidak ada di " + berkas);
}
/** Mengubah satu baris berkas menjadi objek Kontak. */
private static Kontak uraikan(String baris, int nomor)
throws PersistensiGagalException {
String[] bagian = baris.split("\\|", 4);
if (bagian.length != 4) {
throw new PersistensiGagalException(
"baris rusak pada nomor " + nomor + ": \"" + baris + "\"");
}
try {
int id = Integer.parseInt(bagian[0].strip());
return new Kontak(id, bagian[1], bagian[2], bagian[3]);
} catch (NumberFormatException e) {
throw new PersistensiGagalException(
"id bukan angka pada baris " + nomor + ": " + bagian[0]);
}
}
/** Menulis ulang seluruh isi berkas dari daftar kontak. */
private void tulisSemua(List<Kontak> data) throws PersistensiGagalException {
StringBuilder teks = new StringBuilder();
for (Kontak k : data) {
teks.append(k.id()).append('|')
.append(k.nama()).append('|')
.append(k.email()).append('|')
.append(k.telepon()).append('\n');
}
try {
Files.writeString(berkas, teks.toString(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new PersistensiGagalException(
"gagal menulis " + berkas, e);
}
}
}
KontakDaoMemori.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
/** Penyimpanan di memori: cepat, tanpa berkas, cocok untuk pengujian. */
public class KontakDaoMemori implements KontakDao {
private final List<Kontak> simpanan = new ArrayList<>();
@Override
public List<Kontak> muatSemua() {
return List.copyOf(simpanan);
}
@Override
public void tambah(Kontak kontak) {
simpanan.add(kontak);
}
@Override
public void perbarui(Kontak kontak) throws PersistensiGagalException {
int posisi = indeksDariId(kontak.id());
if (posisi < 0) {
throw new PersistensiGagalException(
"id " + kontak.id() + " tidak ada di penyimpanan memori");
}
simpanan.set(posisi, kontak);
}
@Override
public void hapus(int id) throws PersistensiGagalException {
int posisi = indeksDariId(id);
if (posisi < 0) {
throw new PersistensiGagalException(
"id " + id + " tidak ada di penyimpanan memori");
}
simpanan.remove(posisi);
}
private int indeksDariId(int id) {
for (int i = 0; i < simpanan.size(); i++) {
if (simpanan.get(i).id() == id) {
return i;
}
}
return -1;
}
}
KontakInvalidException.java
Unduh berkas/**
* Masukan kontak yang melanggar aturan validasi:
* field kosong, format email salah, email dipakai kontak lain,
* atau field memuat karakter pemisah penyimpanan.
*/
public class KontakInvalidException extends Exception {
public KontakInvalidException(String pesan) {
super(pesan);
}
}
KontakTableModel.java
Unduh berkasimport javax.swing.table.AbstractTableModel;
/**
* TableModel kustom yang membaca DaftarKontak sebagai sumber data.
* Operasi CRUD diteruskan ke DaftarKontak, lalu notifikasi perubahan
* dikirim ke view lewat keluarga method fire... .
*/
public class KontakTableModel extends AbstractTableModel {
private static final String[] KOLUM = { "Id", "Nama", "Email", "Telepon" };
private final DaftarKontak daftar;
public KontakTableModel(DaftarKontak daftar) {
this.daftar = daftar;
}
@Override
public int getRowCount() {
return daftar.jumlah();
}
@Override
public int getColumnCount() {
return KOLUM.length;
}
@Override
public String getColumnName(int kolom) {
return KOLUM[kolom];
}
@Override
public Object getValueAt(int baris, int kolom) {
Kontak k = daftar.semua().get(baris);
return switch (kolom) {
case 0 -> k.id();
case 1 -> k.nama();
case 2 -> k.email();
default -> k.telepon();
};
}
/** Menambah kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak tambah(String nama, String email, String telepon)
throws KontakInvalidException, PersistensiGagalException {
Kontak baru = daftar.tambah(nama, email, telepon);
int baris = daftar.jumlah() - 1;
fireTableRowsInserted(baris, baris);
return baru;
}
/** Mengubah kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak ubah(int id, String nama, String email, String telepon)
throws KontakInvalidException, KontakTidakDitemukanException,
PersistensiGagalException {
int baris = barisDariId(id);
Kontak baru = daftar.ubah(id, nama, email, telepon);
fireTableRowsUpdated(baris, baris);
return baru;
}
/** Menghapus kontak lewat DaftarKontak lalu memberi tahu view. */
public Kontak hapus(int id)
throws KontakTidakDitemukanException, PersistensiGagalException {
int baris = barisDariId(id);
Kontak terhapus = daftar.hapus(id);
fireTableRowsDeleted(baris, baris);
return terhapus;
}
/** Kontak pada baris model — dipakai aksi terhadap baris terpilih. */
public Kontak kontakDiBaris(int baris) {
return daftar.semua().get(baris);
}
/** Jumlah baris saat ini pada model. */
public int jumlahBaris() {
return daftar.jumlah();
}
private int barisDariId(int id) {
for (int i = 0; i < daftar.jumlah(); i++) {
if (daftar.semua().get(i).id() == id) {
return i;
}
}
return -1;
}
}
KontakTidakDitemukanException.java
Unduh berkas/** Id kontak tidak ada pada daftar saat kontak akan diubah atau dihapus. */
public class KontakTidakDitemukanException extends Exception {
public KontakTidakDitemukanException(int id) {
super("kontak dengan id " + id + " tidak ditemukan");
}
}
PanelFormKontak.java
Unduh berkasimport java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
/**
* Panel formulir kontak: tiga isian berjajar dengan label rata kanan.
* Panel hanya membaca dan menulis komponen; validasi tetap di DaftarKontak.
*/
public class PanelFormKontak extends JPanel {
private final JTextField isianNama = new JTextField(16);
private final JTextField isianEmail = new JTextField(16);
private final JTextField isianTelepon = new JTextField(16);
public PanelFormKontak() {
super(new GridBagLayout());
GridBagConstraints k = new GridBagConstraints();
k.insets = new Insets(4, 4, 4, 4);
k.anchor = GridBagConstraints.LINE_END;
String[] label = { "Nama:", "Email:", "Telepon:" };
JTextField[] isian = { isianNama, isianEmail, isianTelepon };
for (int i = 0; i < label.length; i++) {
k.gridx = 0;
k.gridy = i;
add(new JLabel(label[i]), k);
k.gridx = 1;
k.fill = GridBagConstraints.HORIZONTAL;
k.weightx = 1.0;
add(isian[i], k);
k.fill = GridBagConstraints.NONE;
k.weightx = 0.0;
}
}
/** Nilai isian nama, sudah dipangkas spasinya. */
public String nama() {
return isianNama.getText().strip();
}
/** Nilai isian email, sudah dipangkas spasinya. */
public String email() {
return isianEmail.getText().strip();
}
/** Nilai isian telepon, sudah dipangkas spasinya. */
public String telepon() {
return isianTelepon.getText().strip();
}
/** Mengisi formulir dari satu kontak — dipakai saat mengubah data. */
public void isi(Kontak kontak) {
isianNama.setText(kontak.nama());
isianEmail.setText(kontak.email());
isianTelepon.setText(kontak.telepon());
}
/** Mengosongkan seluruh isian setelah kontak selesai disimpan. */
public void kosongkan() {
isianNama.setText("");
isianEmail.setText("");
isianTelepon.setText("");
}
}
PanelTabelKontak.java
Unduh berkasimport java.util.Locale;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.RowFilter;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;
/**
* Panel tabel kontak: JTable di dalam JScrollPane.
* Panel memasang TableRowSorter agar pencarian bisa menyaring baris
* yang tampil tanpa mengubah data pada model.
*/
public class PanelTabelKontak extends JScrollPane {
private final JTable tabel;
private final TableRowSorter<TableModel> sorter;
public PanelTabelKontak(KontakTableModel model) {
super();
tabel = new JTable(model);
tabel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
sorter = new TableRowSorter<>(model);
tabel.setRowSorter(sorter);
setViewportView(tabel);
}
/** Tabel yang dipakai panel — pembacaan nilai lewat view ini. */
public JTable tabel() {
return tabel;
}
/** Id kontak pada baris terpilih, atau -1 bila tidak ada yang terpilih. */
public int idTerpilih() {
int barisTampil = tabel.getSelectedRow();
if (barisTampil < 0) {
return -1;
}
int barisModel = tabel.convertRowIndexToModel(barisTampil);
return (int) tabel.getModel().getValueAt(barisModel, 0);
}
/** Menandai baris model tertentu pada tampilan. */
public void pilihBaris(int barisModel) {
int barisTampil = tabel.convertRowIndexToView(barisModel);
if (barisTampil >= 0) {
tabel.setRowSelectionInterval(barisTampil, barisTampil);
} else {
tabel.clearSelection();
}
}
/**
* Menyaring baris yang tampil dengan kata kunci pada kolom nama,
* email, atau telepon. Kata kunci kosong menghapus saringan.
*/
public void saring(String kataKunci) {
String kunci = kataKunci == null ? "" : kataKunci.strip();
if (kunci.isEmpty()) {
sorter.setRowFilter(null);
return;
}
sorter.setRowFilter(new RowFilter<TableModel, Integer>() {
@Override
public boolean include(Entry<? extends TableModel, ? extends Integer> entri) {
for (int kolom = 1; kolom < entri.getValueCount(); kolom++) {
if (entri.getStringValue(kolom)
.toLowerCase(Locale.ROOT).contains(kunci)) {
return true;
}
}
return false;
}
});
}
}
PersistensiGagalException.java
Unduh berkas/** Kegagalan saat membaca atau menulis penyimpanan kontak. */
public class PersistensiGagalException extends Exception {
public PersistensiGagalException(String pesan) {
super(pesan);
}
/** Menyimpan sebab asli (misalnya IOException) agar bisa ditelusuri. */
public PersistensiGagalException(String pesan, Throwable sebab) {
super(pesan, sebab);
}
}