Added basic data for run records, and table view with pagination.
This commit is contained in:
parent
8269804f9b
commit
c94bac792c
|
@ -10,14 +10,10 @@ import java.sql.SQLException;
|
|||
* The main application entrypoint.
|
||||
*/
|
||||
public class RecorderApp {
|
||||
public static void main(String[] args) {
|
||||
try (var dataSource = new DataSource("jdbc:sqlite:runs.db")) {
|
||||
FlatLightLaf.setup();
|
||||
var window = new RecorderAppWindow(dataSource);
|
||||
window.setVisible(true);
|
||||
} catch (SQLException e) {
|
||||
System.err.println("An SQL error occurred: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
public static void main(String[] args) throws SQLException {
|
||||
DataSource dataSource = new DataSource("jdbc:sqlite:runs.db");
|
||||
FlatLightLaf.setup();
|
||||
var window = new RecorderAppWindow(dataSource);
|
||||
window.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,11 @@ package com.github.andrewlalis.running_every_day.data;
|
|||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class DataSource implements AutoCloseable {
|
||||
public class DataSource {
|
||||
private final Connection conn;
|
||||
|
||||
public DataSource(String url) throws SQLException {
|
||||
|
@ -16,17 +15,32 @@ public class DataSource implements AutoCloseable {
|
|||
this.initSchemaIfNeeded();
|
||||
}
|
||||
|
||||
public RunRecordRepository runRecords() {
|
||||
return new RunRecordRepository(this.conn);
|
||||
}
|
||||
|
||||
public void close() throws SQLException {
|
||||
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 {
|
||||
boolean shouldInitSchema;
|
||||
try (
|
||||
var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='run'");
|
||||
var rs = stmt.executeQuery()
|
||||
) {
|
||||
shouldInitSchema = !rs.next();
|
||||
boolean shouldInitSchema = false;
|
||||
final String[] tableNames = {"run"};
|
||||
try (var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name=?")) {
|
||||
for (var name : tableNames) {
|
||||
stmt.setString(1, name);
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
shouldInitSchema = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldInitSchema) {
|
||||
try (var stmt = this.conn.createStatement()) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
package com.github.andrewlalis.running_every_day.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
@ -23,4 +26,38 @@ public record RunRecord(
|
|||
Duration duration,
|
||||
BigDecimal weightKg,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -9,9 +9,25 @@ public class RecorderAppWindow extends JFrame {
|
|||
public RecorderAppWindow(DataSource dataSource) {
|
||||
super("Run-Recorder");
|
||||
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
// TODO: Build UI
|
||||
this.setPreferredSize(new Dimension(800, 600));
|
||||
this.setContentPane(buildGui(dataSource));
|
||||
this.setPreferredSize(new Dimension(1000, 600));
|
||||
this.pack();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
CREATE TABLE run (
|
||||
CREATE TABLE IF NOT EXISTS run (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT,
|
||||
|
|
Loading…
Reference in New Issue