Added validation, cleaned up CSS colors with theme definitions.

This commit is contained in:
Andrew Lalis 2024-01-07 19:05:09 -05:00
parent ebf4880297
commit c02e5d3fc6
14 changed files with 447 additions and 178 deletions

View File

@ -8,9 +8,15 @@ import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
@ -22,50 +28,58 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import java.util.stream.Collectors;
import static com.andrewlalis.perfin.PerfinApp.router;
public class CreateTransactionController implements RouteSelectionListener {
@FXML public TextField timestampField;
@FXML public Label timestampInvalidLabel;
@FXML public Label timestampFutureLabel;
@FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox;
@FXML public TextArea descriptionField;
@FXML public Label descriptionErrorLabel;
@FXML public HBox linkedAccountsContainer;
@FXML public ComboBox<Account> linkDebitAccountComboBox;
@FXML public ComboBox<Account> linkCreditAccountComboBox;
@FXML public Label linkedAccountsErrorLabel;
@FXML public VBox attachmentsVBox;
private FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton;
public CreateTransactionController() {
}
@FXML public void initialize() {
// Setup error field validation.
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
timestampFutureLabel.managedProperty().bind(timestampFutureLabel.visibleProperty());
timestampField.textProperty().addListener((observable, oldValue, newValue) -> {
LocalDateTime parsedTimestamp = parseTimestamp();
timestampInvalidLabel.setVisible(parsedTimestamp == null);
timestampFutureLabel.setVisible(parsedTimestamp != null && parsedTimestamp.isAfter(LocalDateTime.now()));
});
descriptionErrorLabel.managedProperty().bind(descriptionErrorLabel.visibleProperty());
descriptionErrorLabel.visibleProperty().bind(descriptionErrorLabel.textProperty().isNotEmpty());
descriptionField.textProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null && newValue.length() > 255) {
descriptionErrorLabel.setText("Description is too long.");
} else {
descriptionErrorLabel.setText(null);
}
});
linkedAccountsErrorLabel.managedProperty().bind(linkedAccountsErrorLabel.visibleProperty());
linkedAccountsErrorLabel.visibleProperty().bind(linkedAccountsErrorLabel.textProperty().isNotEmpty());
linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
var timestampValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> parseTimestamp() != null, "Invalid timestamp.")
.addPredicate(s -> {
LocalDateTime ts = parseTimestamp();
return ts != null && ts.isBefore(LocalDateTime.now());
}, "Timestamp cannot be in the future.")
).validatedInitially().attachToTextField(timestampField);
var amountValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
.addPredicate(
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
"The credit and debit accounts cannot be the same."
)
).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());
// Update the lists of accounts available for linking based on the selected currency.
var cellFactory = new AccountComboBoxCellFactory();
@ -87,35 +101,23 @@ public class CreateTransactionController implements RouteSelectionListener {
}
@FXML public void save() {
var validationMessages = validateFormData();
if (!validationMessages.isEmpty()) {
Alert alert = new Alert(
Alert.AlertType.WARNING,
"There are some issues with your data:\n\n" +
validationMessages.stream()
.map(s -> "- " + s)
.collect(Collectors.joining("\n\n"))
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
List<Path> attachments = attachmentsSelectionArea.getSelectedFiles();
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.insert(
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
attachments
);
alert.show();
} else {
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
List<Path> attachments = attachmentsSelectionArea.getSelectedFiles();
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.insert(
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
attachments
);
});
router.navigateBackAndClear();
}
});
router.navigateBackAndClear();
}
@FXML public void cancel() {
@ -129,7 +131,7 @@ public class CreateTransactionController implements RouteSelectionListener {
private void resetForm() {
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
amountField.setText("0");
amountField.setText(null);
descriptionField.setText(null);
attachmentsSelectionArea.clear();
Thread.ofVirtual().start(() -> {
@ -191,41 +193,4 @@ public class CreateTransactionController implements RouteSelectionListener {
});
});
}
private void onLinkedAccountsUpdated() {
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) {
linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect.");
} else if (debitAccount != null && debitAccount.equals(creditAccount)) {
linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit.");
} else {
linkedAccountsErrorLabel.setText(null);
}
}
private List<String> validateFormData() {
List<String> errorMessages = new ArrayList<>();
if (parseTimestamp() == null) errorMessages.add("Invalid or missing timestamp.");
if (descriptionField.getText() != null && descriptionField.getText().strip().length() > 255) {
errorMessages.add("Description is too long.");
}
try {
BigDecimal value = new BigDecimal(amountField.getText());
if (value.compareTo(BigDecimal.ZERO) <= 0) {
errorMessages.add("Amount should be a positive number.");
}
} catch (NumberFormatException e) {
errorMessages.add("Invalid or missing amount.");
}
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) {
errorMessages.add("At least one account must be linked to this transaction.");
}
if (debitAccount != null && debitAccount.equals(creditAccount)) {
errorMessages.add("Credit and debit accounts cannot be the same.");
}
return errorMessages;
}
}

View File

@ -4,14 +4,15 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -41,12 +42,33 @@ public class EditAccountController implements RouteSelectionListener {
@FXML
public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML
public VBox initialBalanceContent;
public PropertiesPane initialBalanceContent;
@FXML
public TextField initialBalanceField;
@FXML public Button saveButton;
@FXML
public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name should not be empty.")
.addPredicate(s -> s.length() <= 63, "Name is too long.")
).attachToTextField(accountNameField);
var numberValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Account number should not be empty.")
.addPredicate(s -> s.length() <= 255, "Account number is too long.")
.addPredicate(s -> s.matches("\\d+"), "Account number should contain only numeric digits.")
).attachToTextField(accountNumberField);
var balanceValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), false, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
// Combine validity of all fields for an expression that determines if the whole form is valid.
BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid);
saveButton.disableProperty().bind(formValid.not());
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
.map(Currency::getInstance)
.toList();

View File

@ -0,0 +1,58 @@
package com.andrewlalis.perfin.view.component.validation;
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.Property;
import javafx.scene.Node;
import javafx.scene.control.TextField;
/**
* Fluent interface for applying a validator to one or more controls.
* @param <T> The value type.
*/
public class ValidationApplier<T> {
private final ValidationFunction<T> validator;
private ValidationDecorator decorator = new FieldSubtextDecorator();
private boolean validateInitially = false;
public ValidationApplier(ValidationFunction<T> validator) {
this.validator = validator;
}
public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
this.decorator = decorator;
return this;
}
public ValidationApplier<T> validatedInitially() {
this.validateInitially = true;
return this;
}
public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
BooleanExpression validProperty = BooleanExpression.booleanExpression(
valueProperty.map(value -> validator.validate(value).isValid())
);
valueProperty.addListener((observable, oldValue, newValue) -> {
ValidationResult result = validator.validate(newValue);
decorator.decorate(node, result);
});
for (Property<?> influencingProperty : triggerProperties) {
influencingProperty.addListener((observable, oldValue, newValue) -> {
ValidationResult result = validator.validate(valueProperty.getValue());
decorator.decorate(node, result);
});
}
if (validateInitially) {
// Call the decorator once to perform validation right away.
decorator.decorate(node, validator.validate(valueProperty.getValue()));
}
return validProperty;
}
@SuppressWarnings("unchecked")
public BooleanExpression attachToTextField(TextField field, Property<?>... triggerProperties) {
return attach(field, (Property<T>) field.textProperty(), triggerProperties);
}
}

View File

@ -0,0 +1,12 @@
package com.andrewlalis.perfin.view.component.validation;
import javafx.scene.Node;
/**
* A controller style component that, when validation of a field updates,
* will represent the result in some way in the UI, usually by modifying the
* given control and/or its parents in the scene graph.
*/
public interface ValidationDecorator {
void decorate(Node node, ValidationResult result);
}

View File

@ -0,0 +1,5 @@
package com.andrewlalis.perfin.view.component.validation;
public interface ValidationFunction<T> {
ValidationResult validate(T input);
}

View File

@ -0,0 +1,23 @@
package com.andrewlalis.perfin.view.component.validation;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public record ValidationResult(List<String> messages) {
public boolean isValid() {
return messages.isEmpty();
}
public String asLines() {
return messages.stream().map(String::strip).collect(Collectors.joining("\n"));
}
public static ValidationResult valid() {
return new ValidationResult(Collections.emptyList());
}
public static ValidationResult of(String... messages) {
return new ValidationResult(List.of(messages));
}
}

View File

@ -0,0 +1,70 @@
package com.andrewlalis.perfin.view.component.validation.decorators;
import com.andrewlalis.perfin.view.component.validation.ValidationDecorator;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A validation decorator that can be applied to most controls, such that when
* the field's value is invalid, messages are displayed in a label below the
* field. This is accomplished by wrapping the control in a VBox only while
* the value is invalid.
*/
public class FieldSubtextDecorator implements ValidationDecorator {
private static final Logger log = LoggerFactory.getLogger(FieldSubtextDecorator.class);
private static final String WRAP_KEY = FieldSubtextDecorator.class.getName();
@Override
public void decorate(Node node, ValidationResult result) {
if (!result.isValid()) {
if (!node.getStyleClass().contains("validation-field-invalid")) {
node.getStyleClass().add("validation-field-invalid");
}
// Wrap the control in a VBox and put error messages under it.
Label errorLabel;
if (isNodeWrapped(node)) {
VBox validationContainer = (VBox) node.getParent();
errorLabel = (Label) validationContainer.getChildren().get(1);
} else {
errorLabel = wrapNode(node);
}
errorLabel.setText(result.asLines());
} else {
node.getStyleClass().remove("validation-field-invalid");
// Unwrap the control, if it's wrapped.
if (isNodeWrapped(node)) {
unwrapNode(node);
}
}
}
private boolean isNodeWrapped(Node node) {
return WRAP_KEY.equals(node.getParent().getUserData());
}
private Label wrapNode(Node node) {
Pane trueParent = (Pane) node.getParent();
int idx = trueParent.getChildren().indexOf(node);
Label errorLabel = new Label();
errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
errorLabel.setWrapText(true);
VBox validationContainer = new VBox(node, errorLabel);
validationContainer.setUserData(WRAP_KEY);
trueParent.getChildren().add(idx, validationContainer);
return errorLabel;
}
private void unwrapNode(Node node) {
VBox validationContainer = (VBox) node.getParent();
Pane trueParent = (Pane) node.getParent().getParent();
int idx = trueParent.getChildren().indexOf(validationContainer);
trueParent.getChildren().remove(idx);
trueParent.getChildren().add(idx, node);
}
}

View File

@ -0,0 +1,48 @@
package com.andrewlalis.perfin.view.component.validation.validators;
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Currency;
import java.util.List;
import java.util.function.Supplier;
public class CurrencyAmountValidator implements ValidationFunction<String> {
private final Supplier<Currency> currencySupplier;
private final boolean allowNegative;
private final boolean allowEmpty;
public CurrencyAmountValidator(Supplier<Currency> currencySupplier, boolean allowNegative, boolean allowEmpty) {
this.currencySupplier = currencySupplier;
this.allowNegative = allowNegative;
this.allowEmpty = allowEmpty;
}
@Override
public ValidationResult validate(String input) {
if (input == null || input.isBlank()) {
if (!allowEmpty) {
return ValidationResult.of("Amount should not be empty.");
}
return ValidationResult.valid();
}
try {
BigDecimal amount = new BigDecimal(input);
int scale = amount.scale();
List<String> messages = new ArrayList<>();
Currency currency = currencySupplier.get();
if (currency != null && scale > currency.getDefaultFractionDigits()) {
messages.add("The selected currency doesn't support that many digits.");
}
if (!allowNegative && amount.compareTo(BigDecimal.ZERO) < 0) {
messages.add("Negative amounts are not allowed.");
}
return new ValidationResult(messages);
} catch (NumberFormatException e) {
return ValidationResult.of("Invalid amount. Should be a decimal value.");
}
}
}

View File

@ -0,0 +1,43 @@
package com.andrewlalis.perfin.view.component.validation.validators;
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* A common validator pattern, where a series of checks are done on a value to
* determine if it's valid. If invalid, a message is added.
* @param <T> The value type.
*/
public class PredicateValidator<T> implements ValidationFunction<T> {
private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {}
private final List<ValidationStep<T>> steps = new ArrayList<>();
public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
steps.add(new ValidationStep<>(predicate, errorMessage, false));
return this;
}
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
steps.add(new ValidationStep<>(predicate, errorMessage, true));
return this;
}
@Override
public ValidationResult validate(T input) {
List<String> messages = new ArrayList<>();
for (var step : steps) {
if (!step.predicate().apply(input)) {
messages.add(step.message());
if (step.terminal()) {
return new ValidationResult(messages);
}
}
}
return new ValidationResult(messages);
}
}

View File

@ -18,4 +18,5 @@ module com.andrewlalis.perfin {
opens com.andrewlalis.perfin.control to javafx.fxml;
opens com.andrewlalis.perfin.view to javafx.fxml;
opens com.andrewlalis.perfin.view.component to javafx.fxml;
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
}

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx"
@ -9,64 +10,61 @@
>
<center>
<ScrollPane fitToWidth="true" fitToHeight="true">
<VBox styleClass="std-spacing,std-padding" style="-fx-max-width: 500px;">
<VBox style="-fx-max-width: 400px;">
<!-- Basic properties -->
<VBox>
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
<TextField fx:id="timestampField" styleClass="mono-font"/>
<Label fx:id="timestampInvalidLabel" text="Invalid timestamp." styleClass="error-text"/>
<Label fx:id="timestampFutureLabel" text="Timestamp cannot be in the future." styleClass="error-text"/>
</VBox>
<VBox>
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
<TextField fx:id="amountField" styleClass="mono-font"/>
</VBox>
<VBox>
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
<ChoiceBox fx:id="currencyChoiceBox"/>
</VBox>
<VBox>
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
<Label text="Maximum of 255 characters." styleClass="small-font"/>
<TextArea
fx:id="descriptionField"
styleClass="mono-font"
wrapText="true"
style="-fx-pref-height: 100px;-fx-min-height: 100px;"
/>
<Label fx:id="descriptionErrorLabel" styleClass="error-text" wrapText="true"/>
</VBox>
</PropertiesPane>
<!-- Container for linked accounts -->
<VBox>
<HBox styleClass="std-spacing">
<VBox>
<Label text="Debited Account" labelFor="${linkDebitAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkDebitAccountComboBox">
<tooltip><Tooltip text="The account whose assets will increase as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
<VBox>
<Label text="Credited Account" labelFor="${linkCreditAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkCreditAccountComboBox">
<tooltip><Tooltip text="The account whose assets will decrease as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
</HBox>
<Label fx:id="linkedAccountsErrorLabel" styleClass="error-text" wrapText="true"/>
</VBox>
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
<VBox>
<Label text="Debited Account" labelFor="${linkDebitAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkDebitAccountComboBox">
<tooltip><Tooltip text="The account whose assets will increase as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
<VBox>
<Label text="Credited Account" labelFor="${linkCreditAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkCreditAccountComboBox">
<tooltip><Tooltip text="The account whose assets will decrease as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
</HBox>
<!-- Container for attachments -->
<VBox fx:id="attachmentsVBox">
<VBox fx:id="attachmentsVBox" styleClass="std-padding">
<Label text="Attachments" styleClass="bold-text"/>
<Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-font" wrapText="true"/>
<!-- FileSelectionArea inserted here! -->
</VBox>
<!-- Buttons -->
<Separator/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
<Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</VBox>
</ScrollPane>
</center>
<bottom>
<HBox styleClass="std-padding,std-spacing">
<Button text="Save" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</bottom>
</BorderPane>

View File

@ -1,12 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane
xmlns="http://javafx.com/javafx/17.0.2-ea"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.andrewlalis.perfin.control.EditAccountController"
stylesheets="@style/edit-account.css,@style/base.css"
stylesheets="@style/base.css"
>
<top>
<HBox styleClass="std-padding,std-spacing">
@ -14,34 +15,42 @@
</HBox>
</top>
<center>
<GridPane BorderPane.alignment="TOP_LEFT" styleClass="fields-grid,std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" minWidth="10.0" />
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" />
</columnConstraints>
<Label GridPane.columnIndex="0" GridPane.rowIndex="0" text="Name"/>
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
<VBox style="-fx-max-width: 400px;" BorderPane.alignment="TOP_LEFT">
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<Label GridPane.columnIndex="0" GridPane.rowIndex="1" text="Account Number"/>
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
<Label text="Name" styleClass="bold-text"/>
<TextField fx:id="accountNameField"/>
<Label GridPane.columnIndex="0" GridPane.rowIndex="2" text="Currency"/>
<ComboBox fx:id="accountCurrencyComboBox" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
<Label text="Account Number" styleClass="bold-text"/>
<TextField fx:id="accountNumberField" styleClass="mono-font"/>
<Label GridPane.columnIndex="0" GridPane.rowIndex="3" text="Account Type"/>
<ChoiceBox fx:id="accountTypeChoiceBox" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
<Label text="Currency" styleClass="bold-text"/>
<ComboBox fx:id="accountCurrencyComboBox"/>
<Label text="Account Type" styleClass="bold-text"/>
<ChoiceBox fx:id="accountTypeChoiceBox"/>
</PropertiesPane>
<!-- Initial balance content that's only visible when creating a new account. -->
<PropertiesPane fx:id="initialBalanceContent" hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<VBox fx:id="initialBalanceContent" GridPane.columnIndex="0" GridPane.rowIndex="4" GridPane.columnSpan="2">
<Separator/>
<Label text="Initial Balance" styleClass="bold-text"/>
<TextField fx:id="initialBalanceField"/>
</VBox>
</GridPane>
</PropertiesPane>
<Separator/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
<Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</VBox>
</center>
<bottom>
<FlowPane BorderPane.alignment="CENTER_RIGHT">
<Button text="Save" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</FlowPane>
</bottom>
</BorderPane>

View File

@ -4,10 +4,21 @@ classes to use for common style variants. Prefer to use the styles defined here
rather than with your own CSS.
*/
* {
-fx-theme-text: rgb(26, 26, 26);
-fx-theme-text-secondary: rgb(99, 99, 99);
-fx-theme-background: rgb(237, 237, 237);
-fx-theme-background-2: rgb(232, 232, 232);
-fx-theme-background-3: rgb(220, 220, 220);
-fx-theme-negative: rgb(247, 37, 69);
-fx-theme-positive: rgb(43, 196, 77);
}
.root {
-fx-font-family: "Roboto", sans-serif;
-fx-font-size: 14px;
-fx-text-fill: rgb(26, 26, 26);
-fx-text-fill: -fx-theme-text;
-fx-background-color: -fx-theme-background;
}
.mono-font {
@ -24,7 +35,7 @@ rather than with your own CSS.
.error-text {
-fx-font-size: small;
-fx-text-fill: red;
-fx-text-fill: -fx-theme-negative;
}
.bold-text {
@ -65,38 +76,47 @@ rather than with your own CSS.
/* Standard "tile" styling. */
.tile {
-fx-border-color: lightgray;
-fx-border-width: 2px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
-fx-background-color: -fx-theme-background-2;
-fx-padding: 10px;
}
.tile:hover {
-fx-background-color: derive(-fx-theme-background-2, -5%);
}
.tile-border-selected {
-fx-border-color: darkgray;
-fx-background-color: -fx-theme-background-3;
}
/* Validation styling. */
.validation-field-invalid {
-fx-border-color: -fx-theme-negative;
-fx-border-width: 2px;
-fx-border-style: solid;
-fx-border-radius: 2px;
}
/* Standard colors */
.normal-color-text-fill {
-fx-text-fill: rgb(26, 26, 26);
-fx-text-fill: -fx-theme-text;
}
.secondary-color-fill {
-fx-fill: rgb(99, 99, 99);
-fx-fill: -fx-theme-text-secondary;
}
.negative-color-text-fill {
-fx-text-fill: rgb(247, 37, 69);
-fx-text-fill: -fx-theme-negative;
}
.negative-color-fill {
-fx-fill: rgb(247, 37, 69);
-fx-fill: -fx-theme-negative;
}
.positive-color-text-fill {
-fx-text-fill: rgb(43, 196, 77);
-fx-text-fill: -fx-theme-positive;
}
.positive-color-fill {
-fx-fill: rgb(43, 196, 77);
-fx-fill: -fx-theme-positive;
}
/* DEBUG BORDERS */

View File

@ -1,5 +0,0 @@
.fields-grid {
-fx-hgap: 3px;
-fx-vgap: 3px;
-fx-max-width: 500px;
}