Added basic data for run records, and table view with pagination.

This commit is contained in:
Andrew Lalis 2023-04-19 14:47:07 +02:00
parent 8269804f9b
commit c94bac792c
11 changed files with 413 additions and 21 deletions

View File

@ -10,14 +10,10 @@ import java.sql.SQLException;
* The main application entrypoint. * The main application entrypoint.
*/ */
public class RecorderApp { public class RecorderApp {
public static void main(String[] args) { public static void main(String[] args) throws SQLException {
try (var dataSource = new DataSource("jdbc:sqlite:runs.db")) { DataSource dataSource = new DataSource("jdbc:sqlite:runs.db");
FlatLightLaf.setup(); FlatLightLaf.setup();
var window = new RecorderAppWindow(dataSource); var window = new RecorderAppWindow(dataSource);
window.setVisible(true); window.setVisible(true);
} catch (SQLException e) {
System.err.println("An SQL error occurred: " + e.getMessage());
e.printStackTrace();
}
} }
} }

View File

@ -3,12 +3,11 @@ package com.github.andrewlalis.running_every_day.data;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.sql.Connection; import java.sql.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.SQLException; import java.sql.SQLException;
public class DataSource implements AutoCloseable { public class DataSource {
private final Connection conn; private final Connection conn;
public DataSource(String url) throws SQLException { public DataSource(String url) throws SQLException {
@ -16,17 +15,32 @@ public class DataSource implements AutoCloseable {
this.initSchemaIfNeeded(); this.initSchemaIfNeeded();
} }
public RunRecordRepository runRecords() {
return new RunRecordRepository(this.conn);
}
public void close() throws SQLException { public void close() throws SQLException {
this.conn.close(); this.conn.close();
} }
/**
* Checks for any missing tables and initializes the database schema with
* them if so.
* @throws SQLException If an SQL error occurs.
*/
private void initSchemaIfNeeded() throws SQLException { private void initSchemaIfNeeded() throws SQLException {
boolean shouldInitSchema; boolean shouldInitSchema = false;
try ( final String[] tableNames = {"run"};
var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='run'"); try (var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name=?")) {
var rs = stmt.executeQuery() for (var name : tableNames) {
) { stmt.setString(1, name);
shouldInitSchema = !rs.next(); try (var rs = stmt.executeQuery()) {
if (!rs.next()) {
shouldInitSchema = true;
break;
}
}
}
} }
if (shouldInitSchema) { if (shouldInitSchema) {
try (var stmt = this.conn.createStatement()) { try (var stmt = this.conn.createStatement()) {

View File

@ -0,0 +1,19 @@
package com.github.andrewlalis.running_every_day.data;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public record Page<T>(
List<T> items,
Pagination pagination
) {
public static <T> Page<T> fromResultSet(ResultSet rs, ResultSetMapper<T> mapper, Pagination pagination) throws SQLException {
List<T> items = new ArrayList<>(pagination.size());
while (rs.next() && items.size() < pagination.size()) {
items.add(mapper.map(rs));
}
return new Page<>(items, pagination);
}
}

View File

@ -0,0 +1,51 @@
package com.github.andrewlalis.running_every_day.data;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
public record Pagination(int page, int size, List<Sort> sorts) {
public record Sort(String property, SortDir direction, NullsDir nullsDirection) {
public Sort(String property, SortDir direction) {
this(property, direction, NullsDir.FIRST);
}
public Sort(String property) {
this(property, SortDir.ASC);
}
}
public enum SortDir {ASC, DESC}
public enum NullsDir {FIRST, LAST}
public Pagination(int page, int size) {
this(page, size, Collections.emptyList());
}
public Pagination nextPage() {
return new Pagination(this.page + 1, this.size, this.sorts);
}
public Pagination previousPage() {
return new Pagination(this.page - 1, this.size, this.sorts);
}
public String toQuerySyntax() {
StringBuilder sb = new StringBuilder(256);
List<String> orderingTerms = this.sorts.stream()
.map(s -> s.property + ' ' + s.direction.name())
.toList();
if (!orderingTerms.isEmpty()) {
sb.append(String.join(",", orderingTerms)).append(' ');
}
sb.append("LIMIT ").append(this.size).append(" OFFSET ").append(this.page * this.size);
return sb.toString();
}
public <T> Page<T> execute(Connection conn, String baseQuery, ResultSetMapper<T> mapper) throws SQLException {
String query = baseQuery + ' ' + this.toQuerySyntax();
try (var stmt = conn.prepareStatement(query)) {
return Page.fromResultSet(stmt.executeQuery(), mapper, this);
}
}
}

View File

@ -0,0 +1,9 @@
package com.github.andrewlalis.running_every_day.data;
import java.sql.ResultSet;
import java.sql.SQLException;
@FunctionalInterface
public interface ResultSetMapper<T> {
T map(ResultSet rs) throws SQLException;
}

View File

@ -1,6 +1,9 @@
package com.github.andrewlalis.running_every_day.data; package com.github.andrewlalis.running_every_day.data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
@ -23,4 +26,38 @@ public record RunRecord(
Duration duration, Duration duration,
BigDecimal weightKg, BigDecimal weightKg,
String comment String comment
) {} ) {
public BigDecimal distanceMeters() {
return distanceKm.multiply(BigDecimal.valueOf(1000));
}
public int durationSeconds () {
return (int) duration.getSeconds();
}
public BigDecimal weightGrams() {
return weightKg.multiply(BigDecimal.valueOf(1000));
}
public static class Mapper implements ResultSetMapper<RunRecord> {
@Override
public RunRecord map(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
String dateStr = rs.getString("date");
String startTimeStr = rs.getString("start_time");
int distanceMeters = rs.getInt("distance");
int durationSeconds = rs.getInt("duration");
int weightGrams = rs.getInt("weight");
String comment = rs.getString("comment");
return new RunRecord(
id,
LocalDate.parse(dateStr),
LocalTime.parse(startTimeStr),
BigDecimal.valueOf(distanceMeters, 3).divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY),
Duration.ofSeconds(durationSeconds),
BigDecimal.valueOf(weightGrams, 3).divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY),
comment
);
}
}
}

View File

@ -0,0 +1,83 @@
package com.github.andrewlalis.running_every_day.data;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public record RunRecordRepository(Connection conn) {
public Page<RunRecord> findAll(Pagination pagination) throws SQLException {
String query = "SELECT id, date, start_time, distance, duration, weight, comment FROM run";
return pagination.execute(conn, query, new RunRecord.Mapper());
}
public List<RunRecord> findAllByQuery(String query) throws SQLException {
List<RunRecord> items = new ArrayList<>();
var mapper = new RunRecord.Mapper();
try (
var stmt = conn.prepareStatement(query);
var rs = stmt.executeQuery()
) {
while (rs.next()) {
items.add(mapper.map(rs));
}
}
return items;
}
public void delete(long id) throws SQLException {
try (var stmt = conn.prepareStatement("DELETE FROM run WHERE id = ?")) {
stmt.setLong(1, id);
stmt.executeUpdate();
}
}
public RunRecord save(RunRecord runRecord) throws SQLException {
String query = "INSERT INTO run (date, start_time, distance, duration, weight, comment) VALUES (?, ?, ?, ?, ?, ?)";
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, runRecord.date().toString());
stmt.setString(2, runRecord.startTime().toString());
stmt.setInt(3, runRecord.distanceMeters().intValue());
stmt.setInt(4, runRecord.durationSeconds());
stmt.setInt(5, runRecord.weightGrams().intValue());
stmt.setString(6, runRecord.comment());
stmt.executeUpdate();
try (var rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
long id = rs.getLong(1);
return new RunRecord(
id,
runRecord.date(),
runRecord.startTime(),
runRecord.distanceKm(),
runRecord.duration(),
runRecord.weightKg(),
runRecord.comment()
);
} else {
throw new SQLException("Missing generated primary key for record.");
}
}
}
}
public long countAll() throws SQLException {
String query = "SELECT COUNT(id) FROM run";
try (
var stmt = conn.prepareStatement(query);
var rs = stmt.executeQuery()
) {
if (rs.next()) {
return rs.getLong(1);
} else {
throw new SQLException("Missing count.");
}
}
}
public long pageCount(int size) throws SQLException {
long recordCount = countAll();
return (recordCount / size) + (recordCount % size != 0 ? 1 : 0);
}
}

View File

@ -9,9 +9,25 @@ public class RecorderAppWindow extends JFrame {
public RecorderAppWindow(DataSource dataSource) { public RecorderAppWindow(DataSource dataSource) {
super("Run-Recorder"); super("Run-Recorder");
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE); this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
// TODO: Build UI this.setContentPane(buildGui(dataSource));
this.setPreferredSize(new Dimension(800, 600)); this.setPreferredSize(new Dimension(1000, 600));
this.pack(); this.pack();
this.setLocationRelativeTo(null); this.setLocationRelativeTo(null);
} }
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));
return tabbedPane;
}
private Container buildAggregateStatisticsPanel(DataSource dataSource) {
return new JPanel();
}
private Container buildChartsPanel(DataSource dataSource) {
return new JPanel();
}
} }

View File

@ -0,0 +1,63 @@
package com.github.andrewlalis.running_every_day.view;
import com.github.andrewlalis.running_every_day.data.Page;
import com.github.andrewlalis.running_every_day.data.RunRecord;
import javax.swing.table.AbstractTableModel;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
public class RunRecordTableModel extends AbstractTableModel {
private List<RunRecord> records;
public RunRecordTableModel() {
this.records = new ArrayList<>(0);
}
public void setPage(Page<RunRecord> page) {
this.records = page.items();
this.fireTableDataChanged();
}
@Override
public int getRowCount() {
return records.size();
}
@Override
public int getColumnCount() {
return 7;
}
@Override
public Object getValueAt(int rowIndex, int columnIndex) {
if (rowIndex < 0 || rowIndex >= records.size()) return null;
RunRecord r = records.get(rowIndex);
DecimalFormat singleDigitFormat = new DecimalFormat("#,##0.0");
return switch (columnIndex) {
case 0 -> Long.toString(r.id());
case 1 -> r.date().toString();
case 2 -> r.startTime().toString();
case 3 -> r.distanceKm().toPlainString();
case 4 -> r.duration().toString();
case 5 -> r.weightKg().toPlainString();
case 6 -> r.comment();
default -> null;
};
}
@Override
public String getColumnName(int column) {
return switch (column) {
case 0 -> "Id";
case 1 -> "Date";
case 2 -> "Start Time";
case 3 -> "Distance (Km)";
case 4 -> "Duration";
case 5 -> "Weight (Kg)";
case 6 -> "Comment";
default -> "Unknown Value";
};
}
}

View File

@ -0,0 +1,104 @@
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.Pagination;
import javax.swing.*;
import java.awt.*;
import java.sql.SQLException;
/**
* A panel for displaying a table view of run records, and various controls for
* navigating the data.
*/
public class RunRecordsPanel extends JPanel {
private final DataSource dataSource;
private final RunRecordTableModel tableModel = new RunRecordTableModel();
private final JTextField currentPageField;
private final JComboBox<Integer> pageSizeSelector;
private final JButton firstPageButton;
private final JButton previousPageButton;
private final JButton nextPageButton;
private final JButton lastPageButton;
private Pagination currentPage;
public RunRecordsPanel(DataSource dataSource) {
super(new BorderLayout());
this.dataSource = dataSource;
this.currentPage = new Pagination(0, 20);
var table = new JTable(tableModel);
var scrollPane = new JScrollPane(table, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED);
this.add(scrollPane, BorderLayout.CENTER);
JPanel paginationPanel = new JPanel(new GridLayout(1, 6));
firstPageButton = new JButton("First Page");
firstPageButton.addActionListener(e -> {
currentPage = new Pagination(0, currentPage.size(), currentPage.sorts());
loadPage();
});
previousPageButton = new JButton("Previous Page");
previousPageButton.addActionListener(e -> {
currentPage = currentPage.previousPage();
loadPage();
});
nextPageButton = new JButton("Next Page");
nextPageButton.addActionListener(e -> {
currentPage = currentPage.nextPage();
loadPage();
});
lastPageButton = new JButton("Last Page");
lastPageButton.addActionListener(e -> {
try {
long pageCount = dataSource.runRecords().pageCount(currentPage.size());
if (pageCount <= 0) pageCount = 1;
currentPage = new Pagination((int) (pageCount - 1), currentPage.size(), currentPage.sorts());
loadPage();
} catch (SQLException ex) {
ex.printStackTrace();
}
});
JPanel currentPagePanel = new JPanel();
currentPagePanel.add(new JLabel("Current Page: "));
this.currentPageField = new JTextField("1", 3);
// TODO: Add document change listener.
currentPagePanel.add(this.currentPageField);
JPanel pageSizePanel = new JPanel();
pageSizePanel.add(new JLabel("Page Size: "));
pageSizeSelector = new JComboBox<>(new Integer[]{5, 10, 20, 50, 100, 500});
pageSizeSelector.setSelectedItem(this.currentPage.size());
pageSizeSelector.addItemListener(e -> {
currentPage = new Pagination(0, (Integer) e.getItem(), currentPage.sorts());
loadPage();
});
pageSizePanel.add(pageSizeSelector);
paginationPanel.add(firstPageButton);
paginationPanel.add(previousPageButton);
paginationPanel.add(currentPagePanel);
paginationPanel.add(pageSizePanel);
paginationPanel.add(nextPageButton);
paginationPanel.add(lastPageButton);
this.add(paginationPanel, BorderLayout.SOUTH);
SwingUtilities.invokeLater(this::loadPage);
}
private void loadPage() {
try {
this.tableModel.setPage(dataSource.runRecords().findAll(this.currentPage));
long pageCount = dataSource.runRecords().pageCount(this.currentPage.size());
this.firstPageButton.setEnabled(this.currentPage.page() != 0);
this.previousPageButton.setEnabled(this.currentPage.page() > 0);
this.nextPageButton.setEnabled(this.currentPage.page() < pageCount - 1);
this.lastPageButton.setEnabled(this.currentPage.page() != pageCount - 1);
this.currentPageField.setText(String.valueOf(this.currentPage.page() + 1));
} catch (SQLException e) {
e.printStackTrace();
}
}
}

View File

@ -1,4 +1,4 @@
CREATE TABLE run ( CREATE TABLE IF NOT EXISTS run (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, date TEXT NOT NULL,
start_time TEXT, start_time TEXT,