diff --git a/recorder/pom.xml b/recorder/pom.xml index 3ae0933..a1241c4 100644 --- a/recorder/pom.xml +++ b/recorder/pom.xml @@ -25,6 +25,11 @@ flatlaf 3.1 + + org.jfree + jfreechart + 1.5.3 + \ No newline at end of file diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java new file mode 100644 index 0000000..4e994fb --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java @@ -0,0 +1,208 @@ +package com.github.andrewlalis.running_every_day.view; + +import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.RunRecord; + +import javax.swing.*; +import java.awt.*; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class AddRunRecordDialog extends JDialog { + private final DataSource dataSource; + + private final JTextField dateField; + private final JTextField timeField; + private final JTextField distanceField; + private final JTextField durationField; + private final JTextField weightField; + private final JTextArea commentField; + + private boolean added = false; + + public AddRunRecordDialog(Window owner, DataSource dataSource) { + super(owner, "Add a Record", ModalityType.APPLICATION_MODAL); + + this.dataSource = dataSource; + + this.dateField = new JTextField(LocalDate.now().toString(), 0); + this.timeField = new JTextField(LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")), 0); + this.distanceField = new JTextField("5.0", 0); + this.durationField = new JTextField("00:00:00", 0); + this.weightField = new JTextField("85.0", 0); + this.commentField = new JTextArea(3, 0); + + JPanel mainPanel = new JPanel(new BorderLayout()); + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.PAGE_AXIS)); + contentPanel.add(buildFieldsPanel()); + mainPanel.add(contentPanel, BorderLayout.NORTH); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton addButton = new JButton("Add"); + addButton.addActionListener(e -> onAdd()); + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> this.dispose()); + buttonPanel.add(addButton); + buttonPanel.add(cancelButton); + mainPanel.add(buttonPanel, BorderLayout.SOUTH); + this.setContentPane(mainPanel); + + this.setPreferredSize(new Dimension(400, 300)); + this.pack(); + this.setLocationRelativeTo(owner); + } + + public boolean isAdded() { + return added; + } + + private Container buildFieldsPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + var c = new GridBagConstraints(); + c.insets = new Insets(5, 5, 5, 5); + c.weightx = 0; + c.weighty = 0; + c.gridx = 0; + c.gridy = 0; + c.anchor = GridBagConstraints.NORTHWEST; + String[] labels = new String[]{"Date", "Start Time", "Distance (Km)", "Duration (HH:MM:SS)", "Weight (Kg)", "Comments"}; + for (var label : labels) { + panel.add(new JLabel(label), c); + c.gridy++; + } + Component[] fields = new Component[]{dateField, timeField, distanceField, durationField, weightField, commentField}; + c.weightx = 1; + c.weighty = 1; + c.gridx = 1; + c.gridy = 0; + c.anchor = GridBagConstraints.NORTHEAST; + for (var field : fields) { + if (field instanceof JTextArea) { + c.fill = GridBagConstraints.BOTH; + } else { + c.fill = GridBagConstraints.HORIZONTAL; + } + panel.add(field, c); + c.gridy++; + } + return panel; + } + + private List validateForm() { + List errorMessages = new ArrayList<>(); + LocalDate date = null; + try { + date = LocalDate.parse(dateField.getText()); + if (date.isAfter(LocalDate.now())) { + errorMessages.add("Cannot add a run for a date in the future."); + } + } catch (DateTimeParseException e) { + errorMessages.add("Invalid date format. Should be YYYY-MM-DD."); + } + try { + var time = LocalTime.parse(timeField.getText()); + if (date != null && date.atTime(time).isAfter(LocalDateTime.now())) { + errorMessages.add("Cannot add a run for a time in the future."); + } + } catch (DateTimeParseException e) { + errorMessages.add("Invalid start time format. Should be HH:MM."); + } + + try { + BigDecimal distanceKm = new BigDecimal(distanceField.getText()); + if (distanceKm.compareTo(BigDecimal.ZERO) < 0 || distanceKm.scale() > 3) { + errorMessages.add("Invalid distance."); + } + } catch (NumberFormatException e) { + errorMessages.add("Invalid or missing distance."); + } + + try { + Duration duration = parseDuration(durationField.getText()); + if (duration.isNegative() || duration.isZero()) { + errorMessages.add("Duration must be positive."); + } + } catch (IllegalArgumentException e) { + errorMessages.add(e.getMessage()); + } + + try { + BigDecimal weightKg = new BigDecimal(weightField.getText()); + if (weightKg.compareTo(BigDecimal.ZERO) < 0 || weightKg.scale() > 3) { + errorMessages.add("Invalid weight."); + } + } catch (NumberFormatException e) { + errorMessages.add("Invalid or missing weight."); + } + + if (commentField.getText().isBlank()) { + errorMessages.add("Comments should not be empty."); + } + + return errorMessages; + } + + private void onAdd() { + var messages = validateForm(); + if (!messages.isEmpty()) { + JOptionPane.showMessageDialog( + this, + "Form validation failed:\n" + String.join("\n", messages), + "Validation Failed", + JOptionPane.WARNING_MESSAGE + ); + } else { + try { + this.dataSource.runRecords().save(new RunRecord( + 0, + LocalDate.parse(dateField.getText()), + LocalTime.parse(timeField.getText()), + new BigDecimal(distanceField.getText()), + parseDuration(durationField.getText()), + new BigDecimal(weightField.getText()), + commentField.getText().strip() + )); + this.added = true; + this.dispose(); + } catch (SQLException e) { + e.printStackTrace(); + JOptionPane.showMessageDialog( + this, + "An SQL Exception occurred: " + e.getMessage(), + "Error", + JOptionPane.ERROR_MESSAGE + ); + } + + } + } + + private static Duration parseDuration(String s) { + String[] parts = s.strip().split(":"); + if (parts.length < 2 || parts.length > 3) { + throw new IllegalArgumentException("Invalid or missing duration."); + } + int hours = 0; + int minutes = 0; + int seconds = 0; + if (parts.length == 3) { + hours = Integer.parseInt(parts[0]); + minutes = Integer.parseInt(parts[1]); + seconds = Integer.parseInt(parts[2]); + } else { + minutes = Integer.parseInt(parts[0]); + seconds = Integer.parseInt(parts[1]); + } + return Duration.ofSeconds(hours * 60L * 60 + minutes * 60L + seconds); + } +} diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/ChartsPanel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/ChartsPanel.java new file mode 100644 index 0000000..b23af7f --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/ChartsPanel.java @@ -0,0 +1,48 @@ +package com.github.andrewlalis.running_every_day.view; + +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.data.category.DefaultCategoryDataset; +import org.jfree.data.time.Day; +import org.jfree.data.time.TimeSeries; +import org.jfree.data.time.TimeSeriesCollection; +import org.jfree.data.xy.XYDataset; + +import javax.swing.*; +import java.awt.*; +import java.awt.geom.Rectangle2D; + +public class ChartsPanel extends JPanel { + private final JPanel drawingPanel = new JPanel(); + + public ChartsPanel() { + super(new BorderLayout()); + this.add(drawingPanel, BorderLayout.CENTER); + + JPanel buttonPanel = new JPanel(); + JButton drawButton = new JButton("Draw"); + drawButton.addActionListener(e -> draw()); + buttonPanel.add(drawButton); + this.add(buttonPanel, BorderLayout.NORTH); + } + + public void draw() { + TimeSeriesCollection ds = new TimeSeriesCollection(); + TimeSeries ts = new TimeSeries("Data"); + ts.add(new Day(16, 4, 2023), 45); + ts.add(new Day(17, 4, 2023), 50); + ts.add(new Day(18, 4, 2023), 52); + ts.add(new Day(19, 4, 2023), 65); + ds.addSeries(ts); + + JFreeChart chart = ChartFactory.createXYLineChart( + "Test XY Line Chart", + "Date", + "Value", + ds + ); + Graphics2D g2 = (Graphics2D) drawingPanel.getGraphics(); + Rectangle2D area = new Rectangle2D.Float(0, 0, drawingPanel.getWidth(), drawingPanel.getHeight()); + chart.draw(g2, area); + } +} diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java index 9400c8e..780a1fd 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java @@ -18,16 +18,8 @@ public class RecorderAppWindow extends JFrame { private Container buildGui(DataSource dataSource) { JTabbedPane tabbedPane = new JTabbedPane(); tabbedPane.addTab("Run Records", new RunRecordsPanel(dataSource)); - tabbedPane.addTab("Aggregate Statistics", buildAggregateStatisticsPanel(dataSource)); - tabbedPane.addTab("Charts", buildChartsPanel(dataSource)); + tabbedPane.addTab("Aggregate Statistics", new JPanel()); + tabbedPane.addTab("Charts", new ChartsPanel()); return tabbedPane; } - - private Container buildAggregateStatisticsPanel(DataSource dataSource) { - return new JPanel(); - } - - private Container buildChartsPanel(DataSource dataSource) { - return new JPanel(); - } } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java index 078be60..c59157f 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java @@ -68,7 +68,7 @@ public class RunRecordTableModel extends AbstractTableModel { return currentPage; } - private void loadPage() { + public void loadPage() { try { var page = dataSource.runRecords().findAll(currentPage); cachedPageCount = (int) dataSource.runRecords().pageCount(currentPage.size()); diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java index 07297ca..d8ee9e6 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java @@ -28,8 +28,12 @@ public class RunRecordsPanel extends JPanel { table.getColumnModel().getColumn(1).setMaxWidth(80); table.getColumnModel().getColumn(2).setMaxWidth(80); table.getColumnModel().getColumn(3).setMaxWidth(100); + table.getColumnModel().getColumn(3).setPreferredWidth(100); table.getColumnModel().getColumn(4).setMaxWidth(80); table.getColumnModel().getColumn(5).setMaxWidth(80); + for (int i = 0; i < 6; i++) { + table.getColumnModel().getColumn(i).setResizable(false); + } var scrollPane = new JScrollPane(table, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); this.add(scrollPane, BorderLayout.CENTER); @@ -86,6 +90,13 @@ public class RunRecordsPanel extends JPanel { JPanel actionsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); JButton addActionButton = new JButton("Add a Record"); + addActionButton.addActionListener(e -> { + var dialog = new AddRunRecordDialog(SwingUtilities.getWindowAncestor(this), dataSource); + dialog.setVisible(true); + if (dialog.isAdded()) { + tableModel.firstPage(); + } + }); actionsPanel.add(addActionButton); this.add(actionsPanel, BorderLayout.NORTH); }