Goal: replace the two ArrayLists with one generic Repository<T>, then add search and a loan report using streams.

1. A repository for anything

src/lms/service/Repository.javaGeneric class backed by a Map
package lms.service;

import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;

/**
 * Repository<T> - a GENERIC class (Week 10).
 *
 * T is a type parameter: "some type, decided by whoever uses me".
 *   Repository<LibraryItem>  stores items
 *   Repository<Member>       stores members
 * One class, written once, type-safe for both. No casts needed.
 *
 * Inside, a Map gives O(1) lookup by id (a List would have to scan).
 * The Function<T,String> tells the repository how to get an id out of a T.
 */
public class Repository<T> {

    private final Map<String, T> store = new LinkedHashMap<>();   // keeps insertion order
    private final Function<T, String> idOf;

    public Repository(Function<T, String> idOf) {
        this.idOf = idOf;
    }

    public void add(T entity) {
        String id = idOf.apply(entity);
        if (store.containsKey(id)) {
            throw new IllegalArgumentException("An entry with id '" + id + "' already exists");
        }
        store.put(id, entity);
    }

    /** Optional says honestly: the answer might be "nothing". */
    public Optional<T> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }

    public boolean exists(String id) { return store.containsKey(id); }

    public List<T> findAll() { return new ArrayList<>(store.values()); }

    /** Streams: describe WHAT you want (filter), not HOW to loop. */
    public List<T> search(Predicate<T> condition) {
        return store.values().stream().filter(condition).toList();
    }

    public int size() { return store.size(); }
}

Repository<LibraryItem> and Repository<Member> come from the same class. Try items.add(new Student(...)) and read the compile error: generics catch mistakes before the program runs.

2. Library uses it, plus three stream methods

Find search, loansPerMember and availableItems. Read each stream like a sentence: "take the items, keep the borrowed ones, group by borrower name, count each group."

src/lms/service/Library.java
package lms.service;

import lms.exception.ItemNotAvailableException;
import lms.exception.MemberNotFoundException;
import lms.model.Book;
import lms.model.DVD;
import lms.model.LibraryItem;
import lms.model.Loanable;
import lms.model.Member;
import lms.model.Reservable;

import java.util.*;
import java.util.stream.Collectors;

/**
 * Library - Week 10 version (Generics, Collections, Streams).
 *
 * Kept from Week 3: printAllLateFees(), the addItem() overloads.
 * New this week: methods that talk to INTERFACE types (Loanable,
 * Reservable) instead of concrete classes, and a Comparable-based sort.
 */
public class Library {

    // Week 10: Lists replaced by generic Repositories (Maps inside)
    private final Repository<LibraryItem> items = new Repository<>(LibraryItem::getId);
    private final Repository<Member> members = new Repository<>(Member::getMemberId);

    // ---- from Week 3 (unchanged) ----

    public void addItem(LibraryItem item) {
        items.add(item);
    }

    public void addItem(Book book) {
        items.add(book);
    }

    public void addItem(DVD dvd) {
        items.add(dvd);
    }

    public void addMember(Member member) { members.add(member); }

    /** True if an item with this id already exists (used to reject duplicates). */
    public boolean hasItem(String itemId) {
        return items.exists(itemId);
    }

    public void printAllLateFees(int daysLate) {
        System.out.println("--- Late fees if " + daysLate + " day(s) late ---");
        for (LibraryItem item : items.findAll()) {
            System.out.println(item.getTitle() + ": " + item.calculateLateFee(daysLate));
        }
    }

    // ---- NEW in Week 4 ----

    /**
     * The parameter type is the INTERFACE Loanable, not LibraryItem.
     * This method would work for anything that promises to be loanable,
     * even a class that is not a LibraryItem at all.
     */
    public void lend(Loanable thing, Member member) throws ItemNotAvailableException {
        thing.loan(member);
    }

    // ---- NEW in Week 5 ----

    /** Looks a member up by id. Throws the UNCHECKED MemberNotFoundException. */
    public Member findMember(String memberId) {
        return members.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId));
    }

    /** Looks an item up by id. Throws the CHECKED ItemNotAvailableException. */
    public LibraryItem findItem(String itemId) throws ItemNotAvailableException {
        Optional<LibraryItem> found = items.findById(itemId);
        if (found.isEmpty()) throw new ItemNotAvailableException("No item with id '" + itemId + "'");
        return found.get();
    }

    /** The one-stop "borrow" operation the GUI will call later. */
    public void borrowItem(String itemId, String memberId) throws ItemNotAvailableException {
        Member member = findMember(memberId);
        LibraryItem item = findItem(itemId);
        item.loan(member);
    }

    public void returnItem(String itemId) throws ItemNotAvailableException {
        findItem(itemId).returnItem();
    }

    /**
     * Only some items are Reservable. Checking for the INTERFACE (not for
     * Book) means a future Reservable class works here with no changes.
     */
    public void reserveIfPossible(LibraryItem item, Member member) {
        if (item instanceof Reservable r) {
            r.reserve(member);
        } else {
            System.out.println(item.getTitle() + " cannot be reserved.");
        }
    }

    /** Prints members ranked by activity, using Member's compareTo(). */
    public void printMembersByActivity() {
        List<Member> sorted = members.findAll();
        Collections.sort(sorted);
        System.out.println("--- Members ranked by activity ---");
        for (Member m : sorted) {
            System.out.println(m.getName() + " (" + m.getBorrowedCount() + " borrowed)");
        }
    }

    public void listItems() {
        System.out.println("--- Items in the library ---");
        for (LibraryItem item : items.findAll()) System.out.println(item);
    }

    public void listMembers() {
        System.out.println("--- Registered members ---");
        for (Member m : members.findAll()) System.out.println(m);
    }

    public List<LibraryItem> getItems() { return items.findAll(); }
    public List<Member> getMembers() { return members.findAll(); }

    // ---- NEW in Week 10: streams ----

    /** Case-insensitive search in title, id, or author (for books). */
    public List<LibraryItem> search(String text) {
        String q = text.trim().toLowerCase();
        return items.search(i -> i.getTitle().toLowerCase().contains(q)
                || i.getId().toLowerCase().contains(q)
                || (i instanceof Book b && b.getAuthor().toLowerCase().contains(q)));
    }

    /** Map: member name -> number of items currently on loan. */
    public Map<String, Long> loansPerMember() {
        return items.findAll().stream()
                .filter(LibraryItem::isBorrowed)
                .collect(Collectors.groupingBy(i -> i.getBorrower().getName(), TreeMap::new, Collectors.counting()));
    }

    /** All items that are currently available, sorted by title. */
    public List<LibraryItem> availableItems() {
        return items.findAll().stream()
                .filter(i -> !i.isBorrowed())
                .sorted(Comparator.comparing(LibraryItem::getTitle))
                .toList();
    }
}

3. Controller and window

The controller gains search and loanReport; the window gets a search bar and a Loan Report button.

src/lms/service/LibraryController.java
package lms.service;

import lms.exception.ItemNotAvailableException;
import lms.model.*;

import java.util.List;

/**
 * LibraryController - the C in MVC (Week 9).
 *
 *   Model      = lms.model + Library   (data and rules; knows nothing about windows)
 *   View       = lms.ui.MainFrame      (windows; knows nothing about the rules)
 *   Controller = this class            (receives what the user typed, calls the model)
 *
 * The view never touches Book/DVD/Library directly. It asks the controller.
 * That is what lets us swap the console for Swing - and later swap the
 * in-memory list for a database - without rewriting the other layers.
 */
public class LibraryController {

    private final Library library;

    public LibraryController(Library library) {
        this.library = library;
    }

    /** Builds the right kind of item from raw text. Throws IllegalArgumentException on bad input. */
    public void addItem(String type, String id, String title, String detail) {
        if (library.hasItem(id)) {
            throw new IllegalArgumentException("An item with id '" + id + "' already exists");
        }
        switch (type) {
            case "Book" -> library.addItem(new Book(id, title, detail));
            case "DVD" -> library.addItem(new DVD(id, title, parseWholeNumber(detail, "Duration")));
            case "Magazine" -> library.addItem(new Magazine(id, title, parseWholeNumber(detail, "Issue number")));
            default -> throw new IllegalArgumentException("Unknown item type: " + type);
        }
    }

    public void borrow(String itemId, String memberId) throws ItemNotAvailableException {
        library.borrowItem(itemId, memberId);
    }

    public void giveBack(String itemId) throws ItemNotAvailableException {
        library.returnItem(itemId);
    }

    public List<LibraryItem> getItems() { return library.getItems(); }

    /** An empty search box means "show everything". */
    public List<LibraryItem> search(String text) {
        return (text == null || text.isBlank()) ? library.getItems() : library.search(text);
    }

    /** Plain-text report for a dialog. */
    public String loanReport() {
        var counts = library.loansPerMember();
        if (counts.isEmpty()) return "No items are currently on loan.";
        StringBuilder sb = new StringBuilder("Items currently on loan:\n");
        counts.forEach((name, n) -> sb.append("  ").append(name).append(": ").append(n).append('\n'));
        sb.append("\nAvailable: ").append(library.availableItems().size());
        return sb.toString();
    }
    public List<Member> getMembers() { return library.getMembers(); }

    private int parseWholeNumber(String text, String label) {
        try {
            return Integer.parseInt(text.trim());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(label + " must be a whole number");
        }
    }
}
src/lms/ui/MainFrame.java
package lms.ui;

import lms.exception.ItemNotAvailableException;
import lms.exception.MemberNotFoundException;
import lms.model.*;
import lms.service.LibraryController;

import javax.swing.*;
import javax.swing.table.DefaultTableModel;
import java.awt.*;

/**
 * MainFrame - Week 10: adds search + report using generics/streams (via the controller).
 * (Week 9 notes: events, dialogs and MVC.)
 *
 * Same layout as Week 8. New this week:
 *   - ActionListeners on the buttons (event-driven programming)
 *   - JOptionPane messages for errors
 *   - the frame talks to a LibraryController, never to the model directly
 */
public class MainFrame extends JFrame {

    private final LibraryController controller;
    private final DefaultTableModel tableModel;
    private final JTable table;

    private final JTextField idField = new JTextField(12);
    private final JTextField titleField = new JTextField(12);
    private final JTextField detailField = new JTextField(12);
    private final JComboBox<String> typeBox = new JComboBox<>(new String[]{"Book", "DVD", "Magazine"});
    private final JComboBox<String> memberBox = new JComboBox<>();

    private final JButton addButton = new JButton("Add Item");
    private final JButton borrowButton = new JButton("Borrow");
    private final JButton returnButton = new JButton("Return");
    private final JButton reportButton = new JButton("Loan Report");
    private final JTextField searchField = new JTextField(16);
    private final JButton searchButton = new JButton("Search");

    private final JLabel statusLabel = new JLabel(" Ready");

    public MainFrame(LibraryController controller) {
        super("Library Management System");
        this.controller = controller;

        // ---- table (CENTER) ----
        String[] columns = {"ID", "Title", "Type", "Detail", "Status"};
        tableModel = new DefaultTableModel(columns, 0) {
            @Override public boolean isCellEditable(int row, int col) { return false; }
        };
        table = new JTable(tableModel);
        table.setRowHeight(24);
        table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
        table.getColumnModel().getColumn(1).setPreferredWidth(200);
        table.getColumnModel().getColumn(3).setPreferredWidth(140);
        table.getColumnModel().getColumn(4).setPreferredWidth(190);

        // ---- form (WEST) ----
        JPanel form = new JPanel(new GridLayout(0, 2, 6, 8));
        form.setBorder(BorderFactory.createTitledBorder("New item"));
        form.add(new JLabel("Type:"));    form.add(typeBox);
        form.add(new JLabel("ID:"));      form.add(idField);
        form.add(new JLabel("Title:"));   form.add(titleField);
        form.add(new JLabel("Detail:"));  form.add(detailField);
        form.add(new JLabel(""));         form.add(addButton);
        JPanel west = new JPanel(new BorderLayout());
        west.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 4));
        west.add(form, BorderLayout.NORTH);

        // ---- title (NORTH) ----
        JLabel heading = new JLabel("Library Management System", SwingConstants.CENTER);
        heading.setFont(heading.getFont().deriveFont(Font.BOLD, 20f));
        heading.setBorder(BorderFactory.createEmptyBorder(10, 0, 6, 0));
        JPanel searchBar = new JPanel(new FlowLayout(FlowLayout.CENTER, 8, 2));
        searchBar.add(new JLabel("Search:"));
        searchBar.add(searchField);
        searchBar.add(searchButton);
        JPanel north = new JPanel(new BorderLayout());
        north.add(heading, BorderLayout.NORTH);
        north.add(searchBar, BorderLayout.CENTER);

        // ---- buttons + member picker + status (SOUTH) ----
        JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 6));
        buttons.add(new JLabel("Member:"));
        buttons.add(memberBox);
        buttons.add(borrowButton);
        buttons.add(returnButton);
        buttons.add(reportButton);
        JPanel south = new JPanel(new BorderLayout());
        south.add(statusLabel, BorderLayout.WEST);
        south.add(buttons, BorderLayout.EAST);

        setLayout(new BorderLayout());
        add(north, BorderLayout.NORTH);
        add(west, BorderLayout.WEST);
        add(new JScrollPane(table), BorderLayout.CENTER);
        add(south, BorderLayout.SOUTH);

        wireEvents();

        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setSize(1020, 480);
        setLocationRelativeTo(null);

        loadMembers();
        refreshTable();
    }

    // ------------------------------------------------------------------
    // EVENTS: "when this happens, run that"
    // ------------------------------------------------------------------
    private void wireEvents() {
        addButton.addActionListener(e -> onAdd());
        borrowButton.addActionListener(e -> onBorrow());
        returnButton.addActionListener(e -> onReturn());
        searchButton.addActionListener(e -> refreshTable());
        searchField.addActionListener(e -> refreshTable());   // Enter key
        reportButton.addActionListener(e -> JOptionPane.showMessageDialog(this,
                controller.loanReport(), "Loan report", JOptionPane.INFORMATION_MESSAGE));
    }

    private void onAdd() {
        try {
            controller.addItem((String) typeBox.getSelectedItem(),
                    idField.getText().trim(), titleField.getText(), detailField.getText());
            setStatus("Added " + titleField.getText().trim());
            idField.setText(""); titleField.setText(""); detailField.setText("");
            refreshTable();
        } catch (IllegalArgumentException ex) {
            showError("Cannot add item", ex.getMessage());
        }
    }

    private void onBorrow() {
        String itemId = selectedItemId();
        if (itemId == null) return;
        String memberId = ((String) memberBox.getSelectedItem()).split(" - ")[0];
        try {
            controller.borrow(itemId, memberId);
            setStatus(itemId + " borrowed by " + memberId);
            refreshTable();
        } catch (ItemNotAvailableException ex) {
            showError("Cannot borrow", ex.getMessage());
        } catch (MemberNotFoundException ex) {
            showError("Unknown member", ex.getMessage());
        }
    }

    private void onReturn() {
        String itemId = selectedItemId();
        if (itemId == null) return;
        try {
            controller.giveBack(itemId);
            setStatus(itemId + " returned");
            refreshTable();
        } catch (ItemNotAvailableException ex) {
            showError("Cannot return", ex.getMessage());
        }
    }

    // ------------------------------------------------------------------
    // helpers
    // ------------------------------------------------------------------
    private String selectedItemId() {
        int row = table.getSelectedRow();
        if (row < 0) {
            JOptionPane.showMessageDialog(this, "Select a row in the table first.",
                    "Nothing selected", JOptionPane.INFORMATION_MESSAGE);
            return null;
        }
        return (String) tableModel.getValueAt(row, 0);
    }

    private void showError(String title, String message) {
        JOptionPane.showMessageDialog(this, message, title, JOptionPane.WARNING_MESSAGE);
        setStatus(title + ": " + message);
    }

    private void setStatus(String text) { statusLabel.setText(" " + text); }

    private void loadMembers() {
        memberBox.removeAllItems();
        for (Member m : controller.getMembers()) {
            memberBox.addItem(m.getMemberId() + " - " + m.getName());
        }
    }

    private void refreshTable() {
        tableModel.setRowCount(0);
        for (LibraryItem item : controller.search(searchField.getText())) {
            tableModel.addRow(new Object[]{
                    item.getId(), item.getTitle(), item.getClass().getSimpleName(),
                    detailOf(item),
                    item.isBorrowed() ? "On loan: " + item.getBorrower().getName() : "Available"});
        }
    }

    private String detailOf(LibraryItem item) {
        if (item instanceof Book b) return b.getAuthor();
        if (item instanceof DVD d) return d.getDurationMinutes() + " min";
        if (item instanceof Magazine m) return "Issue " + m.getIssueNumber();
        return "";
    }
}
mkdir -p out javac -d out $(find src -name "*.java") java -cp out lms.GuiMain
Loan Report: grouped by member with a stream
Loan Report: grouped by member with a stream
Search "martin": matches the author field
Search "martin": matches the author field

Tip Your own stream query is part of the marks: titles of borrowed books sorted alphabetically is a good start.

Checkpoint: is it done?

  • No List<LibraryItem> field is left in Library
  • Search is case-insensitive and also matches authors
  • The loan report is correct after a return
  • PR: Week 10: Generics, Collections & Streams with docs/uml-week10.png