diff --git a/README.md b/README.md index aa58898..7cf8a10 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,6 @@ interface and interoperable file formats for maximum compatibility. Perfin is a desktop app built with Java 21 and JavaFX, using the SQLite3 database for most data storage. It's intended to be used by individuals to track their finances across multiple accounts (savings, checking, credit, etc.). + +Because the app lives and works entirely on your local computer, you can rest +assured that your data remains completely private. diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 8dfb68d..dcbb876 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -23,12 +23,13 @@ public class PerfinApp extends Application { initMainScreen(stage); splashStage.stateProperty().addListener((v, oldState, state) -> { if (state == SplashScreenStage.State.DONE) stage.show(); + if (state == SplashScreenStage.State.ERROR) System.out.println("ERROR!"); }); } private void initMainScreen(Stage stage) { stage.hide(); - stage.setScene(SceneUtil.load("/main.fxml")); + stage.setScene(SceneUtil.load("/accounts-view.fxml")); stage.setTitle("Perfin"); } } \ No newline at end of file diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java b/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java index 9a40049..4f7cead 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java @@ -33,7 +33,7 @@ public class AccountTileController { this.account = account; Platform.runLater(() -> { accountNumberLabel.setText(account.getAccountNumber()); - accountBalanceLabel.setText(account.getCurrency().getSymbol() + " " + account.getCurrentBalance().toPlainString()); + accountBalanceLabel.setText(account.getCurrency().getSymbol()); accountNameLabel.setText(account.getName()); container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { System.out.println("Clicked on " + account.getAccountNumber()); diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java new file mode 100644 index 0000000..9fe8edb --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java @@ -0,0 +1,44 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.perfin.SceneUtil; +import com.andrewlalis.perfin.model.Account; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.BindingUtil; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; + +import java.util.List; +import java.util.function.Consumer; + +public class AccountsViewController { + @FXML + public BorderPane mainContainer; + @FXML + public FlowPane accountsPane; + + private final ObservableList accountsList = FXCollections.observableArrayList(); + + @FXML + public void initialize() { + accountsPane.minWidthProperty().bind(mainContainer.widthProperty()); + accountsPane.prefWidthProperty().bind(mainContainer.widthProperty()); + accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty()); + accountsPane.maxWidthProperty().bind(mainContainer.widthProperty()); + + BindingUtil.mapContent(accountsPane.getChildren(), accountsList, account -> SceneUtil.loadNode( + "/account-tile.fxml", + (Consumer) c -> c.setAccount(account) + )); + Profile.whenLoaded(profile -> { + populateAccounts(profile.getDataSource().getAccountRepository().findAll()); + }); + } + + private void populateAccounts(List accounts) { + this.accountsList.clear(); + this.accountsList.addAll(accounts); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/MainController.java b/src/main/java/com/andrewlalis/perfin/control/MainController.java deleted file mode 100644 index 125cb74..0000000 --- a/src/main/java/com/andrewlalis/perfin/control/MainController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.andrewlalis.perfin.control; - -import com.andrewlalis.perfin.SceneUtil; -import com.andrewlalis.perfin.model.Account; -import com.andrewlalis.perfin.model.AccountType; -import javafx.fxml.FXML; -import javafx.scene.Parent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.FlowPane; - -import java.math.BigDecimal; -import java.util.Currency; -import java.util.List; -import java.util.function.Consumer; - -public class MainController { - @FXML - public BorderPane mainContainer; - @FXML - public FlowPane accountsPane; - - @FXML - public void initialize() { - accountsPane.minWidthProperty().bind(mainContainer.widthProperty()); - accountsPane.prefWidthProperty().bind(mainContainer.widthProperty()); - accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty()); - accountsPane.maxWidthProperty().bind(mainContainer.widthProperty()); - List tempAccounts = List.of( - new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")), - new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")), - new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")), - new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")) - ); - populateAccounts(tempAccounts); - } - - private void populateAccounts(List accounts) { - accountsPane.getChildren().clear(); - for (var account : accounts) { - Parent node = SceneUtil.loadNode( - "/account-tile.fxml", - (Consumer) c -> c.setAccount(account) - ); - accountsPane.getChildren().add(node); - } - } -} diff --git a/src/main/java/com/andrewlalis/perfin/control/StartupSplashScreenController.java b/src/main/java/com/andrewlalis/perfin/control/StartupSplashScreenController.java index 4c908a5..06703c5 100644 --- a/src/main/java/com/andrewlalis/perfin/control/StartupSplashScreenController.java +++ b/src/main/java/com/andrewlalis/perfin/control/StartupSplashScreenController.java @@ -1,5 +1,6 @@ package com.andrewlalis.perfin.control; +import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.SplashScreenStage; import javafx.application.Platform; import javafx.fxml.FXML; @@ -29,10 +30,22 @@ public class StartupSplashScreenController { try { printlnLater("Initializing application files..."); if (!initAppDir()) { + Thread.sleep(3000); Platform.runLater(() -> getSplashStage().setError()); return; } + printlnLater("Loading the last profile..."); + try { + Profile.loadLast(); + } catch (Exception e) { + printlnLater("Failed to load profile: " + e.getMessage()); + Thread.sleep(3000); + Platform.runLater(() -> getSplashStage().setError()); + return; + } + + printlnLater("Perfin initialized. Starting the app now."); Thread.sleep(500); diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java new file mode 100644 index 0000000..4ba0d49 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java @@ -0,0 +1,12 @@ +package com.andrewlalis.perfin.data; + +import com.andrewlalis.perfin.model.Account; + +import java.util.List; +import java.util.Optional; + +public interface AccountRepository { + long insert(Account account); + List findAll(); + Optional findById(long id); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java new file mode 100644 index 0000000..f91e141 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -0,0 +1,5 @@ +package com.andrewlalis.perfin.data; + +public interface DataSource { + AccountRepository getAccountRepository(); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java new file mode 100644 index 0000000..2fe29cf --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java @@ -0,0 +1,93 @@ +package com.andrewlalis.perfin.data; + +import java.sql.*; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public final class DbUtil { + private DbUtil() {} + + public static void setArgs(PreparedStatement stmt, List args) { + for (int i = 0; i < args.size(); i++) { + try { + stmt.setObject(i + 1, args.get(i)); + } catch (SQLException e) { + throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args.get(i), e); + } + } + } + + public static List findAll(Connection conn, String query, List args, ResultSetMapper mapper) { + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + var rs = stmt.executeQuery(); + List results = new ArrayList<>(); + while (rs.next()) { + results.add(mapper.map(rs)); + } + return results; + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + + public static List findAll(Connection conn, String query, ResultSetMapper mapper) { + return findAll(conn, query, Collections.emptyList(), mapper); + } + + public static Optional findOne(Connection conn, String query, List args, ResultSetMapper mapper) { + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + var rs = stmt.executeQuery(); + if (!rs.next()) return Optional.empty(); + return Optional.of(mapper.map(rs)); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + + public static Optional findById(Connection conn, String query, long id, ResultSetMapper mapper) { + return findOne(conn, query, List.of(id), mapper); + } + + public static void updateOne(Connection conn, String query, List args) { + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + int updateCount = stmt.executeUpdate(); + if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1."); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + + public static long insertOne(Connection conn, String query, List args) { + try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { + setArgs(stmt, args); + int result = stmt.executeUpdate(); + if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); + var rs = stmt.getGeneratedKeys(); + rs.next(); + return rs.getLong(1); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + + public static Timestamp timestampFromUtcLDT(LocalDateTime utc) { + return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); + } + + public static Timestamp timestampFromUtcNow() { + return Timestamp.from(Instant.now(Clock.systemUTC())); + } + + public static LocalDateTime utcLDTFromTimestamp(Timestamp ts) { + return ts.toInstant().atOffset(ZoneOffset.UTC).toLocalDateTime(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/ResultSetMapper.java b/src/main/java/com/andrewlalis/perfin/data/ResultSetMapper.java new file mode 100644 index 0000000..390f5cf --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/ResultSetMapper.java @@ -0,0 +1,9 @@ +package com.andrewlalis.perfin.data; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@FunctionalInterface +public interface ResultSetMapper { + T map(ResultSet rs) throws SQLException; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/UncheckedSqlException.java b/src/main/java/com/andrewlalis/perfin/data/UncheckedSqlException.java new file mode 100644 index 0000000..3933c68 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/UncheckedSqlException.java @@ -0,0 +1,17 @@ +package com.andrewlalis.perfin.data; + +import java.sql.SQLException; + +public class UncheckedSqlException extends RuntimeException { + public UncheckedSqlException(SQLException cause) { + super(cause); + } + + public UncheckedSqlException(String message, SQLException cause) { + super(message, cause); + } + + public UncheckedSqlException(String message) { + super(message); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java new file mode 100644 index 0000000..1b0be21 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -0,0 +1,51 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.DbUtil; +import com.andrewlalis.perfin.model.Account; +import com.andrewlalis.perfin.model.AccountType; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Currency; +import java.util.List; +import java.util.Optional; + +public record JdbcAccountRepository(Connection conn) implements AccountRepository { + @Override + public long insert(Account account) { + return DbUtil.insertOne( + conn, + "INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)", + List.of( + DbUtil.timestampFromUtcNow(), + account.getType().name(), + account.getAccountNumber(), + account.getName(), + account.getCurrency().getCurrencyCode() + ) + ); + } + + @Override + public List findAll() { + return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount); + } + + @Override + public Optional findById(long id) { + return DbUtil.findById(conn, "SELECT * FROM account WHERE id = ?", id, JdbcAccountRepository::parseAccount); + } + + private static Account parseAccount(ResultSet rs) throws SQLException { + long id = rs.getLong("id"); + LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at")); + AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase()); + String accountNumber = rs.getString("account_number"); + String name = rs.getString("name"); + Currency currency = Currency.getInstance(rs.getString("currency")); + return new Account(id, createdAt, type, accountNumber, name, currency); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java new file mode 100644 index 0000000..9202fba --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -0,0 +1,34 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.DataSource; +import com.andrewlalis.perfin.data.UncheckedSqlException; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * A basic data source implementation that gets a new SQL connection using a + * pre-defined JDBC connection URL. + */ +public class JdbcDataSource implements DataSource { + private final String jdbcUrl; + + public JdbcDataSource(String jdbcUrl) { + this.jdbcUrl = jdbcUrl; + } + + public Connection getConnection() { + try { + return DriverManager.getConnection(jdbcUrl); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + + @Override + public AccountRepository getAccountRepository() { + return new JdbcAccountRepository(getConnection()); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Account.java b/src/main/java/com/andrewlalis/perfin/model/Account.java index bd3654e..2b5ff05 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Account.java +++ b/src/main/java/com/andrewlalis/perfin/model/Account.java @@ -1,6 +1,5 @@ package com.andrewlalis.perfin.model; -import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Currency; @@ -14,14 +13,21 @@ public class Account { private AccountType type; private String accountNumber; - private BigDecimal currentBalance; private String name; private Currency currency; - public Account(AccountType type, String accountNumber, BigDecimal currentBalance, String name, Currency currency) { + public Account(long id, LocalDateTime createdAt, AccountType type, String accountNumber, String name, Currency currency) { + this.id = id; + this.createdAt = createdAt; + this.type = type; + this.accountNumber = accountNumber; + this.name = name; + this.currency = currency; + } + + public Account(AccountType type, String accountNumber, String name, Currency currency) { this.type = type; this.accountNumber = accountNumber; - this.currentBalance = currentBalance; this.name = name; this.currency = currency; } @@ -34,10 +40,6 @@ public class Account { return accountNumber; } - public BigDecimal getCurrentBalance() { - return currentBalance; - } - public String getName() { return name; } diff --git a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java index 50eba0c..f298ec5 100644 --- a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java +++ b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java @@ -5,7 +5,8 @@ import java.time.LocalDateTime; import java.util.Currency; /** - * A single entry depicting a credit or debit to an account. + * A single entry depicting a credit or debit to an account, according to the + * rules of single-entry accounting. *

* The following rules apply in determining the type of an entry: *

@@ -13,6 +14,21 @@ import java.util.Currency; *
  • A debit indicates an increase in assets or decrease in liability.
  • *
  • A credit indicates a decrease in assets or increase in liability.
  • * + *

    + * For example, for a checking account, a debit entry is added when money + * is transferred to the account, and credit when money is taken away. + *

    + *

    + * Each entry corresponds to exactly one transaction. For pretty much + * everything but personal transfers, one transaction maps to one entry, + * but for transferring from one account to another, you'll have one + * transaction and an entry for each account. + *

    + *

    + * We don't use double-entry accounting since we're just tracking personal + * accounts, so we don't need the granularity of business accounting, and + * all those extra accounts would be a burden to casual users. + *

    */ public class AccountEntry { public enum Type { @@ -23,21 +39,16 @@ public class AccountEntry { private long id; private LocalDateTime timestamp; private long accountId; + private long transactionId; private BigDecimal amount; private Type type; private Currency currency; - public AccountEntry(long id, LocalDateTime timestamp, long accountId, BigDecimal amount, Type type, Currency currency) { + public AccountEntry(long id, LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) { this.id = id; this.timestamp = timestamp; this.accountId = accountId; - this.amount = amount; - this.type = type; - this.currency = currency; - } - - public AccountEntry(long accountId, BigDecimal amount, Type type, Currency currency) { - this.accountId = accountId; + this.transactionId = transactionId; this.amount = amount; this.type = type; this.currency = currency; diff --git a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java new file mode 100644 index 0000000..9f0f561 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java @@ -0,0 +1,27 @@ +package com.andrewlalis.perfin.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +/** + * A recording of an account's real reported balance at a given point in time, + * used as a sanity check for ensuring that an account's entries add up to the + * correct balance. + */ +public class BalanceRecord { + private long id; + private LocalDateTime timestamp; + + private long accountId; + private BigDecimal balance; + private Currency currency; + + public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) { + this.id = id; + this.timestamp = timestamp; + this.accountId = accountId; + this.balance = balance; + this.currency = currency; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 1a39e47..743d4a4 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -1,5 +1,20 @@ package com.andrewlalis.perfin.model; +import com.andrewlalis.perfin.PerfinApp; +import com.andrewlalis.perfin.data.DataSource; +import com.andrewlalis.perfin.data.impl.JdbcDataSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; +import java.util.function.Consumer; + /** * A profile is essentially a complete set of data that the application can * operate on, sort of like a save file or user account. The profile contains @@ -7,10 +22,165 @@ package com.andrewlalis.perfin.model; * and more. A profile can be imported or exported easily from the application, * and can be encrypted for additional security. Each profile also has its own * settings. - * Practically, each profile is stored as its own isolated database file, with - * a name corresponding to the profile's name. + *

    + * Practically, each profile is a directory containing a database file, + * settings, files, and other information. + *

    + *

    + * Because only one profile may be loaded in the app at once, the Profile + * class maintains a static current profile that can be loaded and + * unloaded. + *

    */ public class Profile { - private String name; + private static Profile current; + private static final List> profileLoadListeners = new ArrayList<>(); + private final String name; + private final Properties settings; + private final DataSource dataSource; + + private Profile(String name, Properties settings, DataSource dataSource) { + this.name = name; + this.settings = settings; + this.dataSource = dataSource; + } + + public String getName() { + return name; + } + + public Properties getSettings() { + return settings; + } + + public DataSource getDataSource() { + return dataSource; + } + + public static Path getDir(String name) { + return PerfinApp.APP_DIR.resolve(name); + } + + public static Path getContentDir(String name) { + return getDir(name).resolve("content"); + } + + public static Path getSettingsFile(String name) { + return getDir(name).resolve("settings.properties"); + } + + public static Path getDatabaseFile(String name) { + return getDir(name).resolve("database.mv.db"); + } + + public static Profile getCurrent() { + return current; + } + + public static void whenLoaded(Consumer consumer) { + if (current != null) { + consumer.accept(current); + } else { + profileLoadListeners.add(consumer); + } + } + + public static String getLastProfile() { + Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt"); + if (Files.exists(lastProfileFile)) { + try { + String s = Files.readString(lastProfileFile).strip().toLowerCase(); + if (!s.isBlank()) return s; + } catch (IOException e) { + System.err.println("Failed to read " + lastProfileFile); + e.printStackTrace(System.err); + } + } + return "default"; + } + + public static void saveLastProfile(String name) { + Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt"); + try { + Files.writeString(lastProfileFile, name); + } catch (IOException e) { + System.err.println("Failed to write " + lastProfileFile); + e.printStackTrace(System.err); + } + } + + public static void loadLast() throws Exception { + load(getLastProfile()); + } + + public static void load(String name) throws IOException { + if (Files.notExists(getDir(name))) { + initProfileDir(name); + } + Properties settings = new Properties(); + try (var in = Files.newInputStream(getSettingsFile(name))) { + settings.load(in); + } + current = new Profile(name, settings, initJdbcDataSource(name)); + saveLastProfile(current.getName()); + for (var c : profileLoadListeners) { + c.accept(current); + } + profileLoadListeners.clear(); + } + + private static void initProfileDir(String name) throws IOException { + Files.createDirectory(getDir(name)); + copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt")); + copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name)); + Files.createDirectory(getContentDir(name)); + copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt")); + } + + private static DataSource initJdbcDataSource(String name) throws IOException { + String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString(); + String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6); + boolean exists = Files.exists(getDatabaseFile(name)); + JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl); + if (!exists) {// Initialize the datasource using schema.sql. + try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) { + if (in == null) throw new IOException("Could not load /sql/schema.sql"); + String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); + List statements = Arrays.stream(schemaStr.split(";")) + .map(String::strip).filter(s -> !s.isBlank()).toList(); + for (var statementStr : statements) { + try (var stmt = conn.createStatement()) { + stmt.executeUpdate(statementStr); + System.out.println("Executed update:\n" + statementStr + "\n-----"); + } + } + } catch (SQLException e) { + Files.deleteIfExists(getDatabaseFile(name)); + throw new IOException("Failed to initialize database.", e); + } + } + // Test the datasource before returning it. + try (var conn = dataSource.getConnection(); var s = conn.createStatement()) { + boolean success = s.execute("SELECT 1;"); + if (!success) throw new IOException("Failed to execute DB test statement."); + } catch (SQLException e) { + throw new IOException(e); + } + return dataSource; + } + + private static void copyResourceFile(String resource, Path dest) throws IOException { + try ( + var in = Profile.class.getResourceAsStream(resource); + var out = Files.newOutputStream(dest) + ) { + if (in == null) throw new IOException("Could not load resource " + resource); + in.transferTo(out); + } + } + + public static boolean validateName(String name) { + return name.matches("\\w+"); + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java new file mode 100644 index 0000000..56f5f81 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -0,0 +1,26 @@ +package com.andrewlalis.perfin.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +/** + * A transaction is a permanent record of a transfer of funds between two + * accounts. + */ +public class Transaction { + private long id; + private LocalDateTime timestamp; + + private BigDecimal amount; + private Currency currency; + private String description; + + public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) { + this.id = id; + this.timestamp = timestamp; + this.amount = amount; + this.currency = currency; + this.description = description; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java new file mode 100644 index 0000000..6a233c5 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java @@ -0,0 +1,89 @@ +package com.andrewlalis.perfin.view; + +import javafx.beans.WeakListener; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.function.Function; + +public class BindingUtil { + + public static void mapContent(ObservableList mapped, ObservableList source, + Function mapper) { + map(mapped, source, mapper); + } + + private static Object map(ObservableList mapped, ObservableList source, + Function mapper) { + final ListContentMapping contentMapping = new ListContentMapping<>(mapped, mapper); + mapped.setAll(source.stream().map(mapper).toList()); + source.removeListener(contentMapping); + source.addListener(contentMapping); + return contentMapping; + } + + private static class ListContentMapping implements ListChangeListener, WeakListener { + private final WeakReference> mappedRef; + private final Function mapper; + + public ListContentMapping(List mapped, Function mapper) { + this.mappedRef = new WeakReference<>(mapped); + this.mapper = mapper; + } + + @Override + public void onChanged(Change change) { + final List mapped = mappedRef.get(); + if (mapped == null) { + change.getList().removeListener(this); + } else { + while (change.next()) { + if (change.wasPermutated()) { + mapped.subList(change.getFrom(), change.getTo()).clear(); + mapped.addAll(change.getFrom(), change.getList().subList(change.getFrom(), change.getTo()) + .stream().map(mapper).toList()); + } else { + if (change.wasRemoved()) { + mapped.subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear(); + } + if (change.wasAdded()) { + mapped.addAll(change.getFrom(), change.getAddedSubList() + .stream().map(mapper).toList()); + } + } + } + } + } + + @Override + public boolean wasGarbageCollected() { + return mappedRef.get() == null; + } + + @Override + public int hashCode() { + final List list = mappedRef.get(); + return (list == null) ? 0 : list.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + final List mapped1 = mappedRef.get(); + if (mapped1 == null) { + return false; + } + + if (obj instanceof ListContentMapping other) { + final List mapped2 = other.mappedRef.get(); + return mapped1 == mapped2; + } + return false; + } + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index ea1442c..68f3184 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,6 +6,8 @@ module com.andrewlalis.perfin { requires com.fasterxml.jackson.databind; + requires java.sql; + exports com.andrewlalis.perfin to javafx.graphics; opens com.andrewlalis.perfin.control to javafx.fxml; } \ No newline at end of file diff --git a/src/main/resources/account-tile.fxml b/src/main/resources/account-tile.fxml index 8988e68..1c10a04 100644 --- a/src/main/resources/account-tile.fxml +++ b/src/main/resources/account-tile.fxml @@ -6,15 +6,13 @@ -