Add Transaction Properties #15

Merged
andrewlalis merged 18 commits from transaction-properties into main 2024-02-04 04:31:04 +00:00
12 changed files with 94 additions and 17 deletions
Showing only changes of commit da589807ef - Show all commits

View File

@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener {
@FXML @FXML
public void archiveAccount() { public void archiveAccount() {
boolean confirmResult = Popups.confirm( boolean confirmResult = Popups.confirm(
titleLabel,
"Are you sure you want to archive this account? It will no " + "Are you sure you want to archive this account? It will no " +
"longer show up in the app normally, and you won't be " + "longer show up in the app normally, and you won't be " +
"able to add new transactions to it. You'll still 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() { @FXML public void unarchiveAccount() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to restore this account from its archived " + "Are you sure you want to restore this account from its archived " +
"status?" "status?"
); );
@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener {
@FXML @FXML
public void deleteAccount() { public void deleteAccount() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to permanently delete this account and " + "Are you sure you want to permanently delete this account and " +
"all data directly associated with it? This cannot be " + "all data directly associated with it? This cannot be " +
"undone; deleted accounts are not recoverable at all. " + "undone; deleted accounts are not recoverable at all. " +

View File

@ -48,7 +48,10 @@ public class BalanceRecordViewController implements RouteSelectionListener {
} }
@FXML public void delete() { @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) { if (confirm) {
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id)); Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
router.navigateBackAndClear(); router.navigateBackAndClear();

View File

@ -89,7 +89,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); 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(), account.getShortName(),
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) 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(reportedBalance, account.getCurrency())),
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency())) CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
); );
return Popups.confirm(msg); return Popups.confirm(timestampField, msg);
} }
return true; return true;
} }

View File

@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener {
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
List<Path> attachments = Collections.emptyList(); List<Path> 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) { if (success) {
long id = accountRepo.insert(type, number, name, currency); long id = accountRepo.insert(type, number, name, currency);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to save (or update) account " + account.id, 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());
} }
} }

View File

@ -203,7 +203,7 @@ public class EditTransactionController implements RouteSelectionListener {
}); });
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to get repositories.", 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());
} }
}); });
} }

View File

@ -1,30 +1,65 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.stage.Modality; import javafx.stage.Modality;
import javafx.stage.Window;
/** /**
* Helper class for standardized popups and confirmation dialogs for the app. * Helper class for standardized popups and confirmation dialogs for the app.
*/ */
public class Popups { 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 alert = new Alert(Alert.AlertType.CONFIRMATION, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
var result = alert.showAndWait(); var result = alert.showAndWait();
return result.isPresent() && result.get() == ButtonType.OK; 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 alert = new Alert(Alert.AlertType.NONE, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
alert.getButtonTypes().setAll(ButtonType.OK); alert.getButtonTypes().setAll(ButtonType.OK);
alert.showAndWait(); 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 alert = new Alert(Alert.AlertType.WARNING, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
alert.showAndWait(); 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;
}
} }

View File

@ -46,10 +46,10 @@ public class ProfilesViewController {
String name = newProfileNameField.getText(); String name = newProfileNameField.getText();
boolean valid = Profile.validateName(name); boolean valid = Profile.validateName(name);
if (valid && !ProfileLoader.getAvailableProfiles().contains(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 (confirm) {
if (openProfile(name, false)) { 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(); newProfileNameField.clear();
} }
@ -108,18 +108,18 @@ public class ProfilesViewController {
PerfinApp.profileLoader.load(name); PerfinApp.profileLoader.load(name);
ProfilesStage.closeView(); ProfilesStage.closeView();
router.replace("accounts"); 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; return true;
} catch (ProfileLoadException e) { } catch (ProfileLoadException e) {
Popups.error("Failed to load the profile: " + e.getMessage()); Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
return false; return false;
} }
} }
private void deleteProfile(String name) { 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) { 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) { if (confirmB) {
try { try {
FileUtil.deleteDirRecursive(Profile.getDir(name)); FileUtil.deleteDirRecursive(Profile.getDir(name));

View File

@ -72,6 +72,7 @@ public class TransactionViewController {
@FXML public void deleteTransaction() { @FXML public void deleteTransaction() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to delete this transaction? This will " + "Are you sure you want to delete this transaction? This will " +
"permanently remove the transaction and its effects on any linked " + "permanently remove the transaction and its effects on any linked " +
"accounts, as well as remove any attachments from storage within " + "accounts, as well as remove any attachments from storage within " +

View File

@ -177,7 +177,7 @@ public class TransactionsViewController implements RouteSelectionListener {
)); ));
} }
} catch (Exception e) { } catch (Exception e) {
Popups.error("An error occurred: " + e.getMessage()); Popups.error(transactionsListBorderPane, e);
} }
} }
} }

View File

@ -28,6 +28,10 @@ import java.util.function.Consumer;
* class maintains a static <em>current</em> profile that can be loaded and * class maintains a static <em>current</em> profile that can be loaded and
* unloaded. * unloaded.
* </p> * </p>
*
* @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) { public record Profile(String name, Properties settings, DataSource dataSource) {
private static final Logger log = LoggerFactory.getLogger(Profile.class); 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); currentProfileListeners.removeIf(ref -> ref.get() == null);
log.debug("Current profile set to {}.", current.name());
} }
public static void whenLoaded(Consumer<Profile> consumer) { public static void whenLoaded(Consumer<Profile> consumer) {

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.model; package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp; import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.DataSourceFactory; import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.data.util.FileUtil;
@ -43,6 +44,21 @@ public class ProfileLoader {
} catch (IOException e) { } catch (IOException e) {
throw new ProfileLoadException("Failed to load profile settings.", 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)); return new Profile(name, settings, dataSourceFactory.getDataSource(name));
} }

View File

@ -9,6 +9,7 @@ import javafx.stage.Stage;
import javafx.stage.StageStyle; import javafx.stage.StageStyle;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@ -60,6 +61,10 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
return scene; 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() { private void runTasks() {
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try { try {
@ -68,8 +73,17 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
for (var task : tasks) { for (var task : tasks) {
try {
CompletableFuture<Void> future = new CompletableFuture<>();
Platform.runLater(() -> {
try { try {
task.accept(this); task.accept(this);
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.join();
Thread.sleep(500); Thread.sleep(500);
} catch (Exception e) { } catch (Exception e) {
accept("Startup failed: " + e.getMessage()); accept("Startup failed: " + e.getMessage());