diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 411a57a..e89b049 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void archiveAccount() { boolean confirmResult = Popups.confirm( + titleLabel, "Are you sure you want to archive this account? It will no " + "longer show up in the app normally, and you won't be " + "able to add new transactions to it. You'll still be " + @@ -103,6 +104,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void unarchiveAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to restore this account from its archived " + "status?" ); @@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void deleteAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to permanently delete this account and " + "all data directly associated with it? This cannot be " + "undone; deleted accounts are not recoverable at all. " + diff --git a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java index 32334ca..9e2c4d4 100644 --- a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java @@ -48,7 +48,10 @@ public class BalanceRecordViewController implements RouteSelectionListener { } @FXML public void delete() { - boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."); + boolean confirm = Popups.confirm( + titleLabel, + "Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin." + ); if (confirm) { Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id)); router.navigateBackAndClear(); diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index 68d1657..a9233ff 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -89,7 +89,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); - boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( + boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( account.getShortName(), CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) @@ -122,7 +122,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency())) ); - return Popups.confirm(msg); + return Popups.confirm(timestampField, msg); } return true; } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 3e3142e..2d97e97 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener { BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); List attachments = Collections.emptyList(); - boolean success = Popups.confirm("Are you sure you want to create this account?"); + boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?"); if (success) { long id = accountRepo.insert(type, number, name, currency); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); @@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener { } } catch (Exception e) { log.error("Failed to save (or update) account " + account.id, e); - Popups.error("Failed to save the account: " + e.getMessage()); + Popups.error(accountNameField, "Failed to save the account: " + e.getMessage()); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 6bebcd8..5db7345 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -203,7 +203,7 @@ public class EditTransactionController implements RouteSelectionListener { }); } catch (Exception e) { log.error("Failed to get repositories.", e); - Popups.error("Failed to fetch account-specific data: " + e.getMessage()); + Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()); } }); } diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java index eb2c5d4..ff2af1f 100644 --- a/src/main/java/com/andrewlalis/perfin/control/Popups.java +++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java @@ -1,30 +1,65 @@ package com.andrewlalis.perfin.control; +import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.stage.Modality; +import javafx.stage.Window; /** * Helper class for standardized popups and confirmation dialogs for the app. */ public class Popups { - public static boolean confirm(String text) { + public static boolean confirm(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); var result = alert.showAndWait(); return result.isPresent() && result.get() == ButtonType.OK; } - public static void message(String text) { + public static boolean confirm(Node node, String text) { + return confirm(getWindowFromNode(node), text); + } + + public static void message(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.NONE, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); alert.getButtonTypes().setAll(ButtonType.OK); alert.showAndWait(); } - public static void error(String text) { + public static void message(Node node, String text) { + message(getWindowFromNode(node), text); + } + + public static void error(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.WARNING, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); alert.showAndWait(); } + + public static void error(Node node, String text) { + error(getWindowFromNode(node), text); + } + + public static void error(Window owner, Exception e) { + error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage()); + } + + public static void error(Node node, Exception e) { + error(getWindowFromNode(node), e); + } + + private static Window getWindowFromNode(Node n) { + Window owner = null; + Scene scene = n.getScene(); + if (scene != null) { + owner = scene.getWindow(); + } + return owner; + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index d9bb663..c26665a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -46,10 +46,10 @@ public class ProfilesViewController { String name = newProfileNameField.getText(); boolean valid = Profile.validateName(name); if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) { - boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?"); + boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?"); if (confirm) { if (openProfile(name, false)) { - Popups.message("Created new profile \"" + name + "\" and loaded it."); + Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it."); } newProfileNameField.clear(); } @@ -108,18 +108,18 @@ public class ProfilesViewController { PerfinApp.profileLoader.load(name); ProfilesStage.closeView(); router.replace("accounts"); - if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded."); + if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); return true; } catch (ProfileLoadException e) { - Popups.error("Failed to load the profile: " + e.getMessage()); + Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage()); return false; } } private void deleteProfile(String name) { - boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); + boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); if (confirmA) { - boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); + boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); if (confirmB) { try { FileUtil.deleteDirRecursive(Profile.getDir(name)); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 7315ec0..8884c8d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -72,6 +72,7 @@ public class TransactionViewController { @FXML public void deleteTransaction() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to delete this transaction? This will " + "permanently remove the transaction and its effects on any linked " + "accounts, as well as remove any attachments from storage within " + diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 6db70a3..d9a6d73 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -177,7 +177,7 @@ public class TransactionsViewController implements RouteSelectionListener { )); } } catch (Exception e) { - Popups.error("An error occurred: " + e.getMessage()); + Popups.error(transactionsListBorderPane, e); } } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 992dddc..de4647b 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -28,6 +28,10 @@ import java.util.function.Consumer; * class maintains a static current profile that can be loaded and * unloaded. *

+ * + * @param name The name of the profile. + * @param settings The profile's settings. + * @param dataSource The profile's data source. */ public record Profile(String name, Properties settings, DataSource dataSource) { private static final Logger log = LoggerFactory.getLogger(Profile.class); @@ -65,6 +69,7 @@ public record Profile(String name, Properties settings, DataSource dataSource) { } } currentProfileListeners.removeIf(ref -> ref.get() == null); + log.debug("Current profile set to {}.", current.name()); } public static void whenLoaded(Consumer consumer) { diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java index 046ea58..4b58b2d 100644 --- a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -1,6 +1,7 @@ package com.andrewlalis.perfin.model; import com.andrewlalis.perfin.PerfinApp; +import com.andrewlalis.perfin.control.Popups; import com.andrewlalis.perfin.data.DataSourceFactory; import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.util.FileUtil; @@ -43,6 +44,21 @@ public class ProfileLoader { } catch (IOException e) { throw new ProfileLoadException("Failed to load profile settings.", e); } + try { + DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name); + if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) { + boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?"); + if (!confirm) { + throw new ProfileLoadException("User rejected migration."); + } + } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) { + Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app."); + throw new ProfileLoadException("Incompatible schema version."); + } + } catch (IOException e) { + throw new ProfileLoadException("Failed to get profile's schema status.", e); + } + Popups.message(window, "Test!"); return new Profile(name, settings, dataSourceFactory.getDataSource(name)); } diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java index 526951d..c0d9994 100644 --- a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java +++ b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java @@ -9,6 +9,7 @@ import javafx.stage.Stage; import javafx.stage.StageStyle; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; /** @@ -60,6 +61,10 @@ public class StartupSplashScreen extends Stage implements Consumer { return scene; } + /** + * Runs all tasks sequentially, invoking each one on the JavaFX main thread, + * and quitting if there's any exception thrown. + */ private void runTasks() { Thread.ofVirtual().start(() -> { try { @@ -69,7 +74,16 @@ public class StartupSplashScreen extends Stage implements Consumer { } for (var task : tasks) { try { - task.accept(this); + CompletableFuture future = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + task.accept(this); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + future.join(); Thread.sleep(500); } catch (Exception e) { accept("Startup failed: " + e.getMessage());