Case Study 1: Building an Address Book

Overview

The address book is a classic desktop application that exercises nearly every concept from this chapter: multiple controls for data entry, a grid for displaying contacts, multiple forms for add/edit operations, validation, search, and layout management. We build it step by step, making deliberate design decisions along the way.


Problem Statement

Build an address book application with the following features:

  1. A main form showing all contacts in a TStringGrid (Name, Phone, Email, City).
  2. An "Add Contact" button that opens a modal form for entering new contact details.
  3. An "Edit" button that opens the same modal form pre-populated with the selected contact.
  4. A "Delete" button that removes the selected contact after confirmation.
  5. A search box (TEdit) at the top that filters the grid in real time as the user types.
  6. The main grid should highlight the selected row and support column resizing.

Application Architecture

Data Layer

We store contacts in a simple dynamic array of records, keeping business logic separate from the UI:

type
  TContact = record
    Name: string;
    Phone: string;
    Email: string;
    City: string;
    Notes: string;
  end;

var
  Contacts: array of TContact;
  ContactCount: Integer;

procedure AddContact(const C: TContact);
function FindContacts(const SearchTerm: string): array of Integer;
procedure DeleteContact(Index: Integer);

Main Form

unit AddressBookMain;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs, Grids,
  StdCtrls, ExtCtrls;

type

  { TfrmAddressBook }

  TfrmAddressBook = class(TForm)
    pnlTop: TPanel;
    lblSearch: TLabel;
    edtSearch: TEdit;
    pnlButtons: TPanel;
    btnAdd: TButton;
    btnEdit: TButton;
    btnDelete: TButton;
    sgContacts: TStringGrid;
    procedure btnAddClick(Sender: TObject);
    procedure btnEditClick(Sender: TObject);
    procedure btnDeleteClick(Sender: TObject);
    procedure edtSearchChange(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure sgContactsDblClick(Sender: TObject);
  private
    FContacts: array of TContact;
    FFilteredIndices: array of Integer;
    procedure RefreshGrid;
    procedure ApplyFilter(const SearchText: string);
    function GetSelectedContactIndex: Integer;
  public
  end;

implementation

uses ContactDetailUnit;

{$R *.lfm}

procedure TfrmAddressBook.FormCreate(Sender: TObject);
begin
  Caption := 'Address Book';
  Width := 750;
  Height := 500;
  Position := poScreenCenter;

  { Grid setup }
  sgContacts.ColCount := 4;
  sgContacts.RowCount := 1;
  sgContacts.FixedRows := 1;
  sgContacts.FixedCols := 0;
  sgContacts.Options := sgContacts.Options
    + [goRowHighlight, goColSizing] - [goEditing, goRangeSelect];
  sgContacts.Cells[0, 0] := 'Name';
  sgContacts.Cells[1, 0] := 'Phone';
  sgContacts.Cells[2, 0] := 'Email';
  sgContacts.Cells[3, 0] := 'City';
  sgContacts.ColWidths[0] := 180;
  sgContacts.ColWidths[1] := 140;
  sgContacts.ColWidths[2] := 200;
  sgContacts.ColWidths[3] := 140;

  { Layout }
  pnlTop.Align := alTop;
  pnlTop.Height := 40;
  pnlButtons.Align := alBottom;
  pnlButtons.Height := 45;
  sgContacts.Align := alClient;
  sgContacts.BorderSpacing.Around := 4;

  { Add some sample data }
  SetLength(FContacts, 3);
  FContacts[0].Name := 'Rosa Martinelli';
  FContacts[0].Phone := '555-0101';
  FContacts[0].Email := 'rosa@example.com';
  FContacts[0].City := 'Portland';
  FContacts[1].Name := 'Tomas Vieira';
  FContacts[1].Phone := '555-0202';
  FContacts[1].Email := 'tomas@example.com';
  FContacts[1].City := 'Austin';
  FContacts[2].Name := 'Kenji Nakamura';
  FContacts[2].Phone := '555-0303';
  FContacts[2].Email := 'kenji@example.com';
  FContacts[2].City := 'Seattle';

  RefreshGrid;
end;

procedure TfrmAddressBook.RefreshGrid;
var
  I: Integer;
begin
  if Length(FFilteredIndices) = 0 then
  begin
    { Show all contacts }
    SetLength(FFilteredIndices, Length(FContacts));
    for I := 0 to High(FContacts) do
      FFilteredIndices[I] := I;
  end;

  sgContacts.RowCount := Length(FFilteredIndices) + 1;
  for I := 0 to High(FFilteredIndices) do
  begin
    sgContacts.Cells[0, I + 1] := FContacts[FFilteredIndices[I]].Name;
    sgContacts.Cells[1, I + 1] := FContacts[FFilteredIndices[I]].Phone;
    sgContacts.Cells[2, I + 1] := FContacts[FFilteredIndices[I]].Email;
    sgContacts.Cells[3, I + 1] := FContacts[FFilteredIndices[I]].City;
  end;
end;

procedure TfrmAddressBook.ApplyFilter(const SearchText: string);
var
  I, Count: Integer;
  LowerSearch: string;
begin
  if SearchText = '' then
  begin
    SetLength(FFilteredIndices, Length(FContacts));
    for I := 0 to High(FContacts) do
      FFilteredIndices[I] := I;
  end
  else
  begin
    LowerSearch := LowerCase(SearchText);
    SetLength(FFilteredIndices, Length(FContacts));
    Count := 0;
    for I := 0 to High(FContacts) do
    begin
      if (Pos(LowerSearch, LowerCase(FContacts[I].Name)) > 0) or
         (Pos(LowerSearch, LowerCase(FContacts[I].Email)) > 0) or
         (Pos(LowerSearch, LowerCase(FContacts[I].City)) > 0) or
         (Pos(LowerSearch, LowerCase(FContacts[I].Phone)) > 0) then
      begin
        FFilteredIndices[Count] := I;
        Inc(Count);
      end;
    end;
    SetLength(FFilteredIndices, Count);
  end;
  RefreshGrid;
end;

procedure TfrmAddressBook.edtSearchChange(Sender: TObject);
begin
  ApplyFilter(edtSearch.Text);
end;

function TfrmAddressBook.GetSelectedContactIndex: Integer;
var
  GridRow: Integer;
begin
  GridRow := sgContacts.Row;
  if (GridRow >= 1) and (GridRow - 1 <= High(FFilteredIndices)) then
    Result := FFilteredIndices[GridRow - 1]
  else
    Result := -1;
end;

procedure TfrmAddressBook.btnAddClick(Sender: TObject);
var
  DetailForm: TfrmContactDetail;
  NewContact: TContact;
begin
  DetailForm := TfrmContactDetail.Create(Self);
  try
    DetailForm.Caption := 'Add Contact';
    if DetailForm.ShowModal = mrOK then
    begin
      NewContact.Name := DetailForm.ContactName;
      NewContact.Phone := DetailForm.Phone;
      NewContact.Email := DetailForm.Email;
      NewContact.City := DetailForm.City;
      NewContact.Notes := DetailForm.Notes;
      SetLength(FContacts, Length(FContacts) + 1);
      FContacts[High(FContacts)] := NewContact;
      ApplyFilter(edtSearch.Text);
    end;
  finally
    DetailForm.Free;
  end;
end;

procedure TfrmAddressBook.btnEditClick(Sender: TObject);
var
  Idx: Integer;
  DetailForm: TfrmContactDetail;
begin
  Idx := GetSelectedContactIndex;
  if Idx < 0 then
  begin
    ShowMessage('Please select a contact to edit.');
    Exit;
  end;

  DetailForm := TfrmContactDetail.Create(Self);
  try
    DetailForm.Caption := 'Edit Contact';
    DetailForm.ContactName := FContacts[Idx].Name;
    DetailForm.Phone := FContacts[Idx].Phone;
    DetailForm.Email := FContacts[Idx].Email;
    DetailForm.City := FContacts[Idx].City;
    DetailForm.Notes := FContacts[Idx].Notes;

    if DetailForm.ShowModal = mrOK then
    begin
      FContacts[Idx].Name := DetailForm.ContactName;
      FContacts[Idx].Phone := DetailForm.Phone;
      FContacts[Idx].Email := DetailForm.Email;
      FContacts[Idx].City := DetailForm.City;
      FContacts[Idx].Notes := DetailForm.Notes;
      ApplyFilter(edtSearch.Text);
    end;
  finally
    DetailForm.Free;
  end;
end;

procedure TfrmAddressBook.btnDeleteClick(Sender: TObject);
var
  Idx, I: Integer;
begin
  Idx := GetSelectedContactIndex;
  if Idx < 0 then
  begin
    ShowMessage('Please select a contact to delete.');
    Exit;
  end;

  if MessageDlg('Confirm Delete',
    Format('Delete contact "%s"?', [FContacts[Idx].Name]),
    mtConfirmation, [mbYes, mbNo], 0) = mrYes then
  begin
    for I := Idx to High(FContacts) - 1 do
      FContacts[I] := FContacts[I + 1];
    SetLength(FContacts, Length(FContacts) - 1);
    ApplyFilter(edtSearch.Text);
  end;
end;

procedure TfrmAddressBook.sgContactsDblClick(Sender: TObject);
begin
  btnEditClick(Sender);  { double-click opens edit }
end;

end.

Key Design Observations

  1. Separation of data and display: The FContacts array holds the data. The grid displays it. The FFilteredIndices array maps visible rows to data indices, enabling search without modifying the underlying data.

  2. Real-time search: The edtSearchChange handler fires on every keystroke, calling ApplyFilter which rebuilds the filtered indices and refreshes the grid. For small datasets this is instant; for large datasets you might add a debounce timer.

  3. Double-click to edit: The grid's OnDblClick handler simply calls btnEditClick, reusing the same code path. This is a common usability pattern — users expect to double-click items to open them.

  4. Modal form reuse: The same TfrmContactDetail form serves both Add and Edit operations. For Add, it opens empty. For Edit, it opens pre-populated. This avoids duplicating the form.


Extensions to Try

  1. Sort by column: Click a column header to sort the contacts by that column.
  2. Persistent storage: Save contacts to a file (CSV or JSON) and load them on startup.
  3. Import/Export: Add buttons to import contacts from a CSV file and export the current list.
  4. Contact groups: Add a TListBox sidebar showing groups (Family, Work, Friends) and filter the grid by group.
  5. Photo support: Add a TImage to the contact detail form for a contact photo.