How do I remove duplicate values using a HashSet in Java?

A HashSet stores unique elements. You can use it to remove duplicate values from a collection by passing that collection to its constructor.

In this example, you will remove duplicate programming language names from a list.

Source code

Save the following code as RemoveDuplicatesExample.java:

import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class RemoveDuplicatesExample {
    void main() {
        List<String> languages = List.of(
                "Java", "Python", "Java", "Kotlin", "Python");

        Set<String> uniqueLanguages = new HashSet<>(languages);

        IO.println("Original list: " + languages);
        IO.println("Original count: " + languages.size());
        IO.println("Unique count: " + uniqueLanguages.size());

        IO.println("Unique languages:");
        uniqueLanguages.stream()
                .sorted()
                .forEach(IO::println);
    }
}

Explanation

The original list contains five elements, including repeated values:

List<String> languages = List.of(
        "Java", "Python", "Java", "Kotlin", "Python");

Both "Java" and "Python" appear twice.

Creating a set from the list

The following statement copies the list’s elements into a HashSet:

Set<String> uniqueLanguages = new HashSet<>(languages);

A set does not retain duplicate elements, so the resulting set contains three language names.

The original list remains unchanged.

The variable uses the interface type Set<String>, while HashSet provides the implementation.

How duplicates are identified

HashSet uses hashCode() and equals() to identify duplicate elements.

For strings, equality depends on their text and is case-sensitive. For example, "Java" and "java" are different values and would both remain in the set.

When storing objects from your own classes, implement equals() and hashCode() consistently so the set can recognize logically equal objects.

Displaying the unique values

A HashSet does not guarantee iteration order. The example sorts the elements before printing them to produce predictable output:

uniqueLanguages.stream()
        .sorted()
        .forEach(IO::println);

stream() creates a stream of the set’s elements. sorted() arranges the strings in their natural order, and forEach() prints each one.

Sorting the stream does not change the set.

Run the example

Compile and run the file:

javac RemoveDuplicatesExample.java
java RemoveDuplicatesExample

Output

Original list: [Java, Python, Java, Kotlin, Python]
Original count: 5
Unique count: 3
Unique languages:
Java
Kotlin
Python

Preserving insertion order

If you want to remove duplicates while preserving the order in which values first appeared, use a LinkedHashSet:

Set<String> uniqueLanguages = new LinkedHashSet<>(languages);

Import java.util.LinkedHashSet to use it. Iterating over this set produces Java, Python, and Kotlin, in that order.

How Do I Build My First AI Application with Spring AI?

Building AI Applications with Spring AI, Part 1

In this example, you will build a Spring Boot API that answers questions about Java. Send a question to POST /api/ask, and Spring AI’s ChatClient will pass it to an OpenAI chat model. The endpoint returns the model’s answer as JSON.

Set up the project

Use JDK 25, Maven 3.9 or later, and an OpenAI API key with access to gpt-4o-mini. Copy the complete Maven POM at the end of this article into an empty project directory. The example uses Spring Boot 4.1.2 and Spring AI 2.0.2.

Create an OpenAI API key

  1. Sign in to the OpenAI Platform API Keys page.
  2. Select your project, then click Create new secret key. You can name it kodejava-spring-ai.
  3. Copy the secret key and store it securely. The platform displays the full key only when you create it.

API usage is billed separately from ChatGPT Plus. To enable paid API usage, add a payment method in API Billing.

Configure the chat model

Create src/main/resources/application.properties:

server.address=127.0.0.1
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-4o-mini

Spring Boot reads the API key from the environment. Keep it out of source control. The loopback address makes the endpoint accessible on your own machine.

Create the application

Save the following class in src/main/java/org/kodejava/springai/JavaAssistantApplication.java:

package org.kodejava.springai;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JavaAssistantApplication {
    public static void main(String[] args) {
        SpringApplication.run(JavaAssistantApplication.class, args);
    }
}

Create the REST controller

Save the controller in src/main/java/org/kodejava/springai/JavaAssistantController.java:

package org.kodejava.springai;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class JavaAssistantController {
    private final ChatClient chatClient;

    public JavaAssistantController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping("/api/ask")
    public AskResponse ask(@RequestBody AskRequest request) {
        if (request.question() == null || request.question().isBlank()) {
            throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, "Question must not be blank");
        }

        String answer = chatClient.prompt()
                .system("You are a Java tutor. Explain concepts in plain English.")
                .user(request.question())
                .call()
                .content();

        return new AskResponse(answer);
    }

    public record AskRequest(String question) {}

    public record AskResponse(String answer) {}
}

Spring AI supplies the ChatClient.Builder, and the controller uses it to create a client once. The system message describes how the model should answer. The user message contains the question. The call().content() chain gets the model’s text synchronously, and Spring MVC serializes the response record as JSON.

A missing or blank question field returns HTTP 400 before the controller calls the model. Each request is independent, so include the topic when asking a follow-up question.

Run the application

On macOS or Linux:

export OPENAI_API_KEY='your-api-key'
mvn spring-boot:run

Run Maven in the same terminal where you set the key. Then open a second terminal to send a request:

curl http://127.0.0.1:8080/api/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"What is the difference between List and Set in Java?"}'

A possible response:

{
  "answer": "A List is ordered and allows duplicate elements. A Set contains unique elements. Its iteration order depends on the implementation."
}

The exact wording can vary. Try sending {"question":" "} to check the HTTP 400 response.

In Part 2, you will use a prompt template to adapt the explanation to the reader’s skill level.

References

Maven artifacts

The Spring Boot parent manages the Web MVC starter and Maven plugin versions. The Spring AI BOM manages the OpenAI starter version.

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.2</version>
        <relativePath/>
    </parent>

    <groupId>org.kodejava</groupId>
    <artifactId>spring-ai-java-assistant</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>25</java.version>
        <spring-ai.version>2.0.2</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

How do I add and remove items from an ArrayList in Java?

An ArrayList stores elements in order and can grow or shrink as you add or remove items.

In this example, you will create a list of programming languages, add elements, and remove them by value and by index.

Source code

Save the following code as ArrayListExample.java:

import java.util.ArrayList;
import java.util.List;

public class ArrayListExample {
    void main() {
        ArrayList<String> languages = new ArrayList<>(
                List.of("Java", "Python"));

        IO.println("Initial list: " + languages);

        languages.add("JavaScript");
        IO.println("After adding JavaScript: " + languages);

        languages.add(1, "Kotlin");
        IO.println("After inserting Kotlin: " + languages);

        boolean removed = languages.remove("Python");
        IO.println("Python removed: " + removed);
        IO.println("After removing Python: " + languages);

        String removedLanguage = languages.remove(0);
        IO.println("Removed at index 0: " + removedLanguage);
        IO.println("Final list: " + languages);

        IO.println("Total languages: " + languages.size());
    }
}

Explanation

The following statement creates a mutable list containing two strings:

ArrayList<String> languages = new ArrayList<>(List.of("Java", "Python"));

List.of() creates an unmodifiable list. Passing it to the ArrayList constructor copies its elements into a new list that supports adding and removing items.

The type argument <String> restricts this list to strings. The diamond operator <> lets the compiler infer the constructor’s type argument.

Adding an item

Use add(element) to append an item to the end:

languages.add("JavaScript");

The list becomes:

[Java, Python, JavaScript]

To insert an item at a specific position, use add(index, element):

languages.add(1, "Kotlin");

Indexes start at zero. This inserts "Kotlin" at the second position and shifts the following elements to the right.

Removing an item by value

Use remove(Object) to remove the first matching item:

boolean removed = languages.remove("Python");

This method returns true if an item was removed, or false if no matching item was found.

If the list contains duplicates, only the first matching occurrence is removed.

Removing an item by index

Use remove(int) to remove an item at a specific position:

String removedLanguage = languages.remove(0);

This removes the first item and returns it. The remaining elements shift to the left.

The index must refer to an existing element. Otherwise, the method throws an IndexOutOfBoundsException.

Getting the number of items

Use size() to read the number of elements:

languages.size()

Unlike an array’s length field, an ArrayList uses a method with parentheses.

Run the example

Compile and run the file:

javac ArrayListExample.java
java ArrayListExample

Output

Initial list: [Java, Python]
After adding JavaScript: [Java, Python, JavaScript]
After inserting Kotlin: [Java, Kotlin, Python, JavaScript]
Python removed: true
After removing Python: [Java, Kotlin, JavaScript]
Removed at index 0: Java
Final list: [Kotlin, JavaScript]
Total languages: 2

Removing integers by value

For an ArrayList<Integer>, passing an int to remove() selects the index-based overload:

numbers.remove(1);

This removes the element at index 1. To remove the value 1, explicitly pass an Integer:

numbers.remove(Integer.valueOf(1));

How do I build an analog world clock widget in Java Swing?

You can build a desktop world clock in Java Swing by converting one Instant into several time zones and drawing each clock face with Java 2D. This example displays an analog dial, a digital time, the local date, and the UTC offset for each city. The widget can stay above other windows while you work.

Java Swing world clock widget with analog dials for Denpasar, Jakarta, Tokyo, London, and New York

Source code

Save the source as WorldClock.java. The example uses Java 17 or later and runs with the standard JDK.

package org.kodejava.swing;

import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.prefs.Preferences;

public final class WorldClock {
    private static final Color BACKGROUND = new Color(13, 21, 35);
    private static final Color CARD = new Color(24, 36, 54);
    private static final Color TEXT = new Color(240, 245, 250);
    private static final Color MUTED = new Color(166, 184, 205);
    private static final Color[] ACCENTS = {
            new Color(121, 224, 196), new Color(247, 190, 118),
            new Color(165, 186, 255), new Color(250, 158, 175)
    };
    private static final DateTimeFormatter DATE =
            DateTimeFormatter.ofPattern("EEE, d MMM", Locale.ENGLISH);
    private static final DateTimeFormatter[] TIME_FORMATS = {
            DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT),
            DateTimeFormatter.ofPattern("HH:mm:ss", Locale.ROOT),
            DateTimeFormatter.ofPattern("h:mm a", Locale.ENGLISH),
            DateTimeFormatter.ofPattern("h:mm:ss a", Locale.ENGLISH)
    };
    private static final String[] CITY_DATA = {
            "Amsterdam|Europe/Amsterdam", "Auckland|Pacific/Auckland", "Bangkok|Asia/Bangkok",
            "Beijing|Asia/Shanghai", "Berlin|Europe/Berlin", "Denpasar|Asia/Makassar",
            "Dubai|Asia/Dubai", "Hong Kong|Asia/Hong_Kong", "Honolulu|Pacific/Honolulu",
            "Istanbul|Europe/Istanbul", "Jakarta|Asia/Jakarta", "Johannesburg|Africa/Johannesburg",
            "Kuala Lumpur|Asia/Kuala_Lumpur", "London|Europe/London",
            "Los Angeles|America/Los_Angeles", "Manila|Asia/Manila",
            "Mexico City|America/Mexico_City", "Moscow|Europe/Moscow", "New Delhi|Asia/Kolkata",
            "New York|America/New_York", "Paris|Europe/Paris", "Perth|Australia/Perth",
            "São Paulo|America/Sao_Paulo", "Seoul|Asia/Seoul", "Singapore|Asia/Singapore",
            "Sydney|Australia/Sydney", "Tokyo|Asia/Tokyo", "Toronto|America/Toronto",
            "UTC|UTC", "Vancouver|America/Vancouver"
    };
    private static final String DEFAULTS = "Denpasar|Jakarta|Tokyo|London|New York";
    private static final List<City> CATALOG = java.util.Arrays.stream(CITY_DATA)
            .map(entry -> entry.split("\\|"))
            .map(parts -> new City(parts[0], ZoneId.of(parts[1])))
            .toList();

    private record City(String name, ZoneId zone) {
        @Override
        public String toString() {
            return name;
        }
    }

    private final JFrame frame = new JFrame("World Clock");
    private final JPanel content = new JPanel(new BorderLayout(0, 10));
    private final JPanel list = new JPanel();
    private final JScrollPane scroll = new JScrollPane(list);
    private final List<City> cities = new ArrayList<>();
    private final List<CityCard> cards = new ArrayList<>();
    private final Preferences settings;
    private final Timer timer = new Timer(50, event -> refreshClocks());
    private final JLabel empty = label("Use + City to add a clock.", 13, MUTED);
    private TrayIcon trayIcon;
    private boolean seconds;
    private boolean twelveHour;
    private boolean compact;

    private WorldClock(boolean preview) {
        settings = preview ? null : Preferences.userNodeForPackage(WorldClock.class)
                .node("world-clock-swing");
        boolean pinned = settings == null || settings.getBoolean("pinned", true);
        seconds = settings == null || settings.getBoolean("seconds", true);
        twelveHour = settings != null && settings.getBoolean("twelveHour", false);
        compact = settings != null && settings.getBoolean("compact", false);
        String names = settings == null ? DEFAULTS : settings.get("cities", DEFAULTS);
        for (String name : names.split("\\|")) {
            CATALOG.stream().filter(city -> city.name().equals(name) && !cities.contains(city))
                    .findFirst().ifPresent(cities::add);
        }

        frame.setUndecorated(true);
        frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
        frame.setAlwaysOnTop(pinned);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent event) {
                hideWidget();
            }
        });
        content.setBackground(BACKGROUND);
        content.setBorder(new EmptyBorder(10, 12, 12, 12));
        frame.setContentPane(content);

        JPanel header = new JPanel(new BorderLayout(8, 0));
        header.setOpaque(false);
        JLabel title = label("World Clock", 20, TEXT);
        title.setFont(title.getFont().deriveFont(Font.BOLD));
        header.add(title, BorderLayout.CENTER);
        JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
        actions.setOpaque(false);
        JButton add = button("+ City");
        JButton options = button("Options");
        JButton hide = button("−");
        hide.setToolTipText("Hide widget");
        hide.getAccessibleContext().setAccessibleName("Hide widget");
        add.addActionListener(event -> addCity());
        options.addActionListener(event -> showOptions(options));
        hide.addActionListener(event -> hideWidget());
        actions.add(add);
        actions.add(options);
        actions.add(hide);
        header.add(actions, BorderLayout.EAST);
        enableDrag(title);
        enableDrag(header);
        content.add(header, BorderLayout.NORTH);

        list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
        list.setBackground(BACKGROUND);
        scroll.setBorder(null);
        scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        scroll.getVerticalScrollBar().setUnitIncrement(24);
        content.add(scroll, BorderLayout.CENTER);
        frame.getRootPane().registerKeyboardAction(event -> hideWidget(),
                KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_IN_FOCUSED_WINDOW);

        Rectangle area = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
        frame.setBounds(area.x + area.width - 384, area.y + 24, 360, 200);
        if (settings != null) {
            frame.setLocation(settings.getInt("x", frame.getX()), settings.getInt("y", frame.getY()));
        }
        rebuildCards();
        if (!preview) installTray();
    }

    private static JLabel label(String text, int size, Color color) {
        JLabel label = new JLabel(text);
        label.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, size));
        label.setForeground(color);
        return label;
    }

    private static JButton button(String text) {
        JButton button = new JButton(text);
        button.setBackground(CARD);
        button.setForeground(TEXT);
        button.setMargin(new Insets(4, 8, 4, 8));
        return button;
    }

    private void enableDrag(Component target) {
        MouseAdapter drag = new MouseAdapter() {
            private Point offset;

            @Override
            public void mousePressed(MouseEvent event) {
                if (!SwingUtilities.isLeftMouseButton(event)) return;
                Point pointer = event.getLocationOnScreen();
                offset = new Point(pointer.x - frame.getX(), pointer.y - frame.getY());
            }

            @Override
            public void mouseDragged(MouseEvent event) {
                if (offset == null) return;
                Point pointer = event.getLocationOnScreen();
                frame.setLocation(pointer.x - offset.x, pointer.y - offset.y);
            }

            @Override
            public void mouseReleased(MouseEvent event) {
                if (offset == null) return;
                offset = null;
                fitToScreen();
                save();
            }
        };
        target.addMouseListener(drag);
        target.addMouseMotionListener(drag);
    }

    private void fitToScreen() {
        GraphicsConfiguration screen = frame.getGraphicsConfiguration();
        Rectangle area = new Rectangle(screen.getBounds());
        Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(screen);
        area.x += insets.left;
        area.y += insets.top;
        area.width -= insets.left + insets.right;
        area.height -= insets.top + insets.bottom;
        Dimension preferred = content.getPreferredSize();
        int width = Math.min(area.width, 360);
        int height = Math.min(area.height, preferred.height);
        int x = Math.max(area.x, Math.min(frame.getX(), area.x + area.width - width));
        int y = Math.max(area.y, Math.min(frame.getY(), area.y + area.height - height));
        frame.setBounds(x, y, width, height);
    }

    private void addCity() {
        City[] available = CATALOG.stream().filter(city -> !cities.contains(city)).toArray(City[]::new);
        if (available.length == 0) {
            JOptionPane.showMessageDialog(frame, "All cities are already displayed.");
            return;
        }
        City choice = (City) JOptionPane.showInputDialog(frame, "Choose a city or time zone",
                "Add City", JOptionPane.PLAIN_MESSAGE, null, available, available[0]);
        if (choice == null) return;
        cities.add(choice);
        rebuildCards();
        save();
        CityCard added = cards.get(cards.size() - 1);
        SwingUtilities.invokeLater(() -> list.scrollRectToVisible(added.getBounds()));
    }

    private void showOptions(JButton anchor) {
        JPopupMenu menu = new JPopupMenu();
        toggle(menu, "Always on top", frame.isAlwaysOnTop(), frame::setAlwaysOnTop);
        toggle(menu, "Show seconds", seconds, value -> seconds = value);
        toggle(menu, "12-hour format", twelveHour, value -> twelveHour = value);
        toggle(menu, "Compact layout", compact, value -> {
            compact = value;
            rebuildCards();
        });
        menu.addSeparator();
        item(menu, "Exit", true, this::exit);
        menu.show(anchor, 0, anchor.getHeight());
    }

    private void toggle(JPopupMenu menu, String title, boolean checked,
                        java.util.function.Consumer<Boolean> change) {
        JCheckBoxMenuItem item = new JCheckBoxMenuItem(title, checked);
        item.addActionListener(event -> {
            change.accept(item.isSelected());
            refreshClocks();
            save();
        });
        menu.add(item);
    }

    private static void item(JPopupMenu menu, String title, boolean enabled, Runnable action) {
        JMenuItem item = new JMenuItem(title);
        item.setEnabled(enabled);
        item.addActionListener(event -> action.run());
        menu.add(item);
    }

    private void showCityMenu(JButton anchor, City city) {
        int index = cities.indexOf(city);
        JPopupMenu menu = new JPopupMenu();
        item(menu, "Move up", index > 0, () -> moveCity(index, index - 1));
        item(menu, "Move down", index < cities.size() - 1, () -> moveCity(index, index + 1));
        item(menu, "Remove", true, () -> {
            cities.remove(city);
            rebuildCards();
            save();
        });
        menu.show(anchor, 0, anchor.getHeight());
    }

    private void moveCity(int index, int destination) {
        City city = cities.remove(index);
        cities.add(destination, city);
        rebuildCards();
        save();
        CityCard moved = cards.get(destination);
        SwingUtilities.invokeLater(() -> list.scrollRectToVisible(moved.getBounds()));
    }

    private void rebuildCards() {
        list.removeAll();
        cards.clear();
        if (cities.isEmpty()) {
            empty.setBorder(new EmptyBorder(12, 8, 12, 8));
            list.add(empty);
        }
        for (int index = 0; index < cities.size(); index++) {
            if (index > 0) list.add(Box.createVerticalStrut(6));
            City city = cities.get(index);
            Color accent = ACCENTS[CATALOG.indexOf(city) % ACCENTS.length];
            CityCard card = new CityCard(city, accent);
            cards.add(card);
            list.add(card);
        }
        list.revalidate();
        fitToScreen();
        list.repaint();
        refreshClocks();
    }

    private void refreshClocks() {
        Instant now = Instant.now();
        LocalDate localDate = now.atZone(ZoneId.systemDefault()).toLocalDate();
        int formatIndex = (twelveHour ? 2 : 0) + (seconds ? 1 : 0);
        for (CityCard card : cards) {
            ZonedDateTime cityTime = now.atZone(card.city.zone());
            card.refresh(cityTime, localDate, TIME_FORMATS[formatIndex]);
        }
    }

    private void save() {
        if (settings == null) return;
        settings.put("cities", String.join("|", cities.stream().map(City::name).toList()));
        settings.putInt("x", frame.getX());
        settings.putInt("y", frame.getY());
        settings.putBoolean("pinned", frame.isAlwaysOnTop());
        settings.putBoolean("seconds", seconds);
        settings.putBoolean("twelveHour", twelveHour);
        settings.putBoolean("compact", compact);
    }

    private void installTray() {
        if (!SystemTray.isSupported()) return;
        BufferedImage icon = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = icon.createGraphics();
        try {
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g.setColor(CARD);
            g.fillOval(0, 0, 32, 32);
            g.setColor(ACCENTS[0]);
            g.setStroke(new BasicStroke(2.4f));
            g.drawOval(3, 3, 26, 26);
            g.drawLine(16, 16, 16, 7);
            g.drawLine(16, 16, 23, 20);
        } finally {
            g.dispose();
        }
        PopupMenu menu = new PopupMenu();
        MenuItem show = new MenuItem("Show World Clock");
        show.addActionListener(event -> SwingUtilities.invokeLater(this::showWidget));
        menu.add(show);
        MenuItem close = new MenuItem("Exit");
        close.addActionListener(event -> SwingUtilities.invokeLater(this::exit));
        menu.add(close);
        TrayIcon iconCandidate = new TrayIcon(icon, "World Clock", menu);
        iconCandidate.setImageAutoSize(true);
        iconCandidate.addActionListener(event -> SwingUtilities.invokeLater(this::showWidget));
        try {
            SystemTray.getSystemTray().add(iconCandidate);
            trayIcon = iconCandidate;
        } catch (AWTException error) {
            // The taskbar remains available if a tray icon cannot be registered.
        }
    }

    private void showWidget() {
        fitToScreen();
        frame.setExtendedState(JFrame.NORMAL);
        frame.setVisible(true);
        frame.toFront();
        refreshClocks();
        timer.start();
    }

    private void hideWidget() {
        if (trayIcon != null) {
            timer.stop();
            frame.setVisible(false);
        } else {
            frame.setExtendedState(JFrame.ICONIFIED);
        }
    }

    private void exit() {
        timer.stop();
        save();
        if (trayIcon != null) SystemTray.getSystemTray().remove(trayIcon);
        frame.dispose();
    }

    private final class CityCard extends JPanel {
        private final City city;
        private final Dial dial;
        private final JLabel digital = label("", compact ? 17 : 21, TEXT);
        private final JLabel date = label("", 12, MUTED);

        CityCard(City city, Color accent) {
            super(new BorderLayout(10, 0));
            this.city = city;
            setOpaque(false);
            setBorder(new EmptyBorder(6, 9, 6, 8));
            int height = compact ? 70 : 104;
            setPreferredSize(new Dimension(320, height));
            setMaximumSize(new Dimension(Integer.MAX_VALUE, height));
            setAlignmentX(Component.LEFT_ALIGNMENT);
            dial = new Dial(accent, height - 12);
            add(dial, BorderLayout.WEST);

            JPanel info = new JPanel(new GridBagLayout());
            info.setOpaque(false);
            JPanel text = new JPanel();
            text.setOpaque(false);
            text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS));
            JLabel name = label(city.name(), 13, accent);
            name.setFont(name.getFont().deriveFont(Font.BOLD));
            digital.setFont(new Font(Font.MONOSPACED, Font.BOLD, compact ? 17 : 21));
            text.add(name);
            text.add(digital);
            text.add(date);
            GridBagConstraints layout = new GridBagConstraints();
            layout.weightx = 1;
            layout.fill = GridBagConstraints.HORIZONTAL;
            info.add(text, layout);
            add(info, BorderLayout.CENTER);
            JButton actions = button("⋯");
            actions.setMargin(new Insets(2, 5, 2, 5));
            actions.getAccessibleContext().setAccessibleName("Options for " + city.name());
            actions.addActionListener(event -> showCityMenu(actions, city));
            JPanel actionPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 2));
            actionPanel.setOpaque(false);
            actionPanel.add(actions);
            add(actionPanel, BorderLayout.EAST);
        }

        void refresh(ZonedDateTime time, LocalDate localDate, DateTimeFormatter format) {
            long difference = ChronoUnit.DAYS.between(localDate, time.toLocalDate());
            String day = difference == 0 ? "" : String.format(Locale.ROOT, "  ·  %+dd", difference);
            String offset = time.getOffset().equals(ZoneOffset.UTC) ? "+00:00" : time.getOffset().getId();
            digital.setText(time.format(format));
            date.setText(time.format(DATE) + "  ·  UTC" + offset + day);
            dial.update(time, seconds);
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            super.paintComponent(graphics);
            Graphics2D g = (Graphics2D) graphics.create();
            try {
                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
                g.setColor(CARD);
                g.fillRoundRect(0, 0, getWidth(), getHeight(), 20, 20);
            } finally {
                g.dispose();
            }
        }
    }

    private static final class Dial extends JComponent {
        private final Color accent;
        private ZonedDateTime time;
        private boolean showSeconds;

        Dial(Color accent, int size) {
            this.accent = accent;
            setPreferredSize(new Dimension(size, size));
        }

        void update(ZonedDateTime nextTime, boolean seconds) {
            boolean minuteChanged = time == null ||
                                    !time.truncatedTo(ChronoUnit.MINUTES).equals(nextTime.truncatedTo(ChronoUnit.MINUTES));
            boolean repaintNeeded = seconds || minuteChanged || showSeconds != seconds;
            time = nextTime;
            showSeconds = seconds;
            if (repaintNeeded) repaint();
        }

        @Override
        protected void paintComponent(Graphics graphics) {
            super.paintComponent(graphics);
            if (time == null) return;
            Graphics2D g = (Graphics2D) graphics.create();
            try {
                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
                double size = Math.min(getWidth(), getHeight());
                g.translate(getWidth() / 2.0, getHeight() / 2.0);
                g.scale(size / 100.0, size / 100.0);

                g.setPaint(new GradientPaint(-35, -48, new Color(49, 70, 93),
                        30, 49, new Color(11, 19, 33)));
                g.fill(new Ellipse2D.Double(-48, -48, 96, 96));
                g.setColor(accent);
                g.setStroke(new BasicStroke(1.6f));
                g.draw(new Ellipse2D.Double(-46, -46, 92, 92));
                g.setPaint(new RadialGradientPaint(new Point2D.Double(-13, -17), 65,
                        new float[]{0, 1}, new Color[]{new Color(42, 59, 79), new Color(9, 16, 29)}));
                g.fill(new Ellipse2D.Double(-43, -43, 86, 86));

                for (int tick = 0; tick < 60; tick++) {
                    boolean hour = tick % 5 == 0;
                    double angle = tick * Math.PI / 30;
                    g.setColor(hour ? accent : new Color(102, 124, 149));
                    g.setStroke(new BasicStroke(hour ? 1.7f : 0.7f, BasicStroke.CAP_ROUND,
                            BasicStroke.JOIN_ROUND));
                    g.draw(radialLine(angle, hour ? 35 : 38, 40));
                }

                g.setColor(TEXT);
                g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 9));
                numeral(g, "12", 0, -27);
                numeral(g, "3", 27, 0);
                numeral(g, "6", 0, 27);
                numeral(g, "9", -27, 0);

                double second = showSeconds ? time.getSecond() + time.getNano() / 1_000_000_000.0 : 0;
                double minute = time.getMinute() + second / 60;
                double hour = time.getHour() % 12 + minute / 60;
                hand(g, hour / 12, 23, 4.3f, TEXT);
                hand(g, minute / 60, 33, 2.9f, TEXT);
                if (showSeconds) hand(g, second / 60, 36, 1.4f, accent);
                g.setColor(accent);
                g.fill(new Ellipse2D.Double(-3.5, -3.5, 7, 7));
                g.setColor(BACKGROUND);
                g.fill(new Ellipse2D.Double(-1.3, -1.3, 2.6, 2.6));
            } finally {
                g.dispose();
            }
        }

        private static void numeral(Graphics2D g, String text, int x, int y) {
            FontMetrics metrics = g.getFontMetrics();
            g.drawString(text, x - metrics.stringWidth(text) / 2.0f,
                    y + (metrics.getAscent() - metrics.getDescent()) / 2.0f);
        }

        private static void hand(Graphics2D g, double turn, double length, float width, Color color) {
            g.setColor(color);
            g.setStroke(new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
            g.draw(radialLine(turn * 2 * Math.PI, -5, length));
        }

        private static Line2D radialLine(double angle, double start, double end) {
            double x = Math.sin(angle);
            double y = -Math.cos(angle);
            return new Line2D.Double(x * start, y * start, x * end, y * end);
        }
    }

    private static void verifyAndRender(Path output) throws IOException {
        Instant winter = Instant.parse("2026-01-15T19:30:00Z");
        Instant summer = Instant.parse("2026-07-15T19:30:00Z");
        if (CATALOG.size() != 30 ||
            winter.atZone(ZoneId.of("Asia/Makassar")).getDayOfMonth() != 16 ||
            !winter.atZone(ZoneId.of("America/New_York")).getOffset().equals(ZoneOffset.ofHours(-5)) ||
            !summer.atZone(ZoneId.of("America/New_York")).getOffset().equals(ZoneOffset.ofHours(-4)) ||
            !summer.atZone(ZoneId.of("Europe/London")).getOffset().equals(ZoneOffset.ofHours(1))) {
            throw new IllegalStateException("Time zone check failed");
        }
        WorldClock widget = new WorldClock(true);
        try {
            widget.frame.validate();
            widget.frame.addNotify();
            widget.frame.validate();
            if (widget.cards.size() != 5 || !widget.frame.isAlwaysOnTop() ||
                widget.cards.get(0).dial.getWidth() <= 0) {
                throw new IllegalStateException("Initial widget layout check failed");
            }

            widget.moveCity(0, 1);
            if (!widget.cards.get(1).city.name().equals("Denpasar")) {
                throw new IllegalStateException("Reorder check failed");
            }
            widget.moveCity(1, 0);
            widget.frame.validate();
            for (CityCard card : widget.cards) {
                card.refresh(winter.atZone(card.city.zone()), LocalDate.of(2026, 1, 16), TIME_FORMATS[1]);
            }
            if (!widget.cards.get(0).digital.getText().equals("03:30:00") ||
                !widget.cards.get(3).date.getText().endsWith("-1d")) {
                throw new IllegalStateException("Displayed time or date check failed");
            }
            widget.frame.validate();
            if (widget.cards.get(0).digital.getWidth() <= 0 || widget.cards.get(0).date.getWidth() <= 0) {
                throw new IllegalStateException("Clock label layout check failed");
            }
            BufferedImage image = new BufferedImage(widget.content.getWidth(), widget.content.getHeight(),
                    BufferedImage.TYPE_INT_RGB);
            Graphics2D g = image.createGraphics();
            try {
                widget.content.printAll(g);
            } finally {
                g.dispose();
            }
            javax.imageio.ImageIO.write(image, "png", output.toFile());
            widget.compact = true;
            widget.rebuildCards();
            widget.cities.clear();
            widget.rebuildCards();
            if (!widget.cards.isEmpty() || widget.list.getComponentCount() != 1) {
                throw new IllegalStateException("Empty state check failed");
            }
        } finally {
            widget.exit();
        }
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 2 && args[0].equals("--verify")) {
            SwingUtilities.invokeAndWait(() -> {
                try {
                    verifyAndRender(Path.of(args[1]));
                } catch (IOException error) {
                    throw new java.io.UncheckedIOException(error);
                }
            });
            return;
        }
        SwingUtilities.invokeLater(() -> new WorldClock(false).showWidget());
    }
}

Use one instant for every city

A world clock displays the same moment in different places. The refresh method takes one snapshot of the system clock. Each card then converts that snapshot into a ZonedDateTime using its city’s IANA time-zone ID.

Instant now = Instant.now();
ZonedDateTime denpasar = now.atZone(ZoneId.of("Asia/Makassar"));
ZonedDateTime newYork = now.atZone(ZoneId.of("America/New_York"));

Using a region-based ZoneId lets Java apply the correct offset for that instant. New York, for example, can show either UTC−05:00 or UTC−04:00 depending on the date. The offset in each card comes from ZonedDateTime.getOffset().

To show whether a city is on a different calendar day, compare its LocalDate with the date in ZoneId.systemDefault(). A card shows -1d when its date is one day behind the computer’s local date.

Draw the clock face with Java 2D

The Dial component overrides paintComponent(). It moves the graphics origin to the center of the component and scales a 100-by-100 drawing area to fit. The same drawing code therefore works for both the regular and compact layouts.

The dial uses a gradient bezel, a shaded face, and sixty tick marks. Every fifth tick is longer and uses the city’s accent color. For each tick or hand, the following helper projects two points onto the same radial line.

private static Line2D radialLine(double angle, double start, double end) {
    double x = Math.sin(angle);
    double y = -Math.cos(angle);
    return new Line2D.Double(x * start, y * start, x * end, y * end);
}

At zero radians, sin(0) is zero and -cos(0) is negative, so the line points toward twelve o’clock. A quarter turn points right toward three o’clock. A negative starting radius extends the hand a little behind the center.

When seconds are enabled, the fractional part of the second comes from getNano(). The minute hand uses the fraction of the current minute, and the hour hand uses the fraction of the current hour.

double second = showSeconds ? time.getSecond() + time.getNano() / 1_000_000_000.0 : 0;
double minute = time.getMinute() + second / 60;
double hour = time.getHour() % 12 + minute / 60;

hand(g, hour / 12, 23, 4.3f, TEXT);
hand(g, minute / 60, 33, 2.9f, TEXT);
if (showSeconds) hand(g, second / 60, 36, 1.4f, accent);

The example uses a Swing Timer with a 50 ms interval to repaint the moving second hand. Each tick reads the current time. If the event dispatch thread is briefly busy, the next refresh uses the current instant and the clock catches up automatically.

Keep the widget visible

The widget uses an undecorated JFrame and calls setAlwaysOnTop(true) by default. A mouse listener on the title lets you drag the window. The Options menu lets you switch off always-on-top, hide seconds, choose a 12-hour format, or switch to a compact layout.

+ City opens the city picker. Each card’s menu can move the city up or down or remove it. Java’s Preferences API stores the city order, window position, and display settings. When the system tray is available, the hide button moves the widget there. Otherwise, the widget minimizes to the taskbar.

Run the example

To compile and run from source, use a JDK.

javac --release 17 -encoding UTF-8 WorldClock.java
java WorldClock

How do I implement the Abstract Factory pattern in Java?

A report exporter that supports CSV and TSV needs to make two decisions together. A CSV file should have comma-separated rows and a .csv extension. A TSV file should have tab-separated rows and a .tsv extension. If the exporter chooses these independently, it can give a tab-separated file a CSV name.

Abstract Factory is a Gang of Four creational pattern that groups the creation of related products behind a shared interface. In this example, the application chooses an export factory once and uses it to obtain a matching row formatter and file naming policy. Design Patterns: Abstract Factory

This article follows Factory Method in the creational patterns series, which began with Singleton.

A complete example

Save the following as AbstractFactoryExample.java. The example previews one row with two integer fields, so it can focus on how the factory selects the delimiter and file extension.

package org.kodejava.pattern.abstractfactory;

public class AbstractFactoryExample {
    interface RowFormatter {
        String format(int orderId, int quantity);
    }

    interface FileNaming {
        String fileName(String baseName);
    }

    interface ExportFactory {
        RowFormatter createRowFormatter();
        FileNaming createFileNaming();
    }

    static final class CsvRowFormatter implements RowFormatter {
        @Override
        public String format(int orderId, int quantity) {
            return orderId + "," + quantity;
        }
    }

    static final class CsvFileNaming implements FileNaming {
        @Override
        public String fileName(String baseName) {
            return baseName + ".csv";
        }
    }

    static final class CsvExportFactory implements ExportFactory {
        @Override
        public RowFormatter createRowFormatter() {
            return new CsvRowFormatter();
        }

        @Override
        public FileNaming createFileNaming() {
            return new CsvFileNaming();
        }
    }

    static final class TsvRowFormatter implements RowFormatter {
        @Override
        public String format(int orderId, int quantity) {
            return orderId + "\t" + quantity;
        }
    }

    static final class TsvFileNaming implements FileNaming {
        @Override
        public String fileName(String baseName) {
            return baseName + ".tsv";
        }
    }

    static final class TsvExportFactory implements ExportFactory {
        @Override
        public RowFormatter createRowFormatter() {
            return new TsvRowFormatter();
        }

        @Override
        public FileNaming createFileNaming() {
            return new TsvFileNaming();
        }
    }

    static void previewExport(ExportFactory factory) {
        RowFormatter formatter = factory.createRowFormatter();
        FileNaming naming = factory.createFileNaming();

        System.out.println(naming.fileName("orders"));
        System.out.println(formatter.format(1042, 3));
    }

    public static void main(String[] args) {
        previewExport(new CsvExportFactory());
        previewExport(new TsvExportFactory());
    }
}

Compile and run the example.

javac -d out AbstractFactoryExample.java
java -cp out org.kodejava.pattern.abstractfactory.AbstractFactoryExample

The first row uses a comma. The second uses a tab.

orders.csv
1042,3
orders.tsv
1042    3

How the products stay in sync

GoF role Java example Responsibility
Abstract factory ExportFactory Declares an operation for each product type.
Concrete factories CsvExportFactory, TsvExportFactory Supply the products for one export format.
Abstract products RowFormatter, FileNaming Define the operations the client uses.
Concrete products CsvRowFormatter, CsvFileNaming, TsvRowFormatter, TsvFileNaming Implement the delimiter and extension for their format.
Client previewExport() Uses both products from the factory it receives.

Follow the CSV call from main(). The client receives a CsvExportFactory through the ExportFactory interface. It asks the factory for both products, then calls them through their respective interfaces. The TSV call runs the same client code with a different factory. The matching pair comes from using the same factory for both requests. A factory implementation still has to honor that contract.

The previous article used Factory Method to let a subclass choose the product used by a shared notification workflow. Here, the exporter receives a factory object that supplies two related product types. The GoF describe factory methods as one way to implement the creation operations of an Abstract Factory. Design Patterns: Abstract Factory

When should I use it?

Use Abstract Factory when several components vary together and the client needs to switch among complete families. For example, a real exporter might pair a row formatter with a format-specific escaping policy and media type.

Adding a pipe-delimited family requires a new factory and its products. The previewExport() method can already use that factory through ExportFactory. Adding a new product type, such as a separate header formatter, means adding a creation method to ExportFactory and implementing it for every format. This structure fits applications where the set of product types is fairly stable.

For an exporter whose only differences are a delimiter and a file extension, one configuration object can hold both values. Separate product interfaces earn their place when each format supplies behavior that varies independently of those settings.

The next article covers Builder.