X-Git-Url: https://git.ktnx.net/?a=blobdiff_plain;f=app%2Fsrc%2Fmain%2Fjava%2Fnet%2Fktnx%2Fmobileledger%2Fui%2Factivity%2FNewTransactionModel.java;h=8a271fa7c2a2514783c63813314c33dec5a8eb5f;hb=ee38e21aa7318a51f9f3e62788d920e13b7be620;hp=bbfedcbc1aae2a870a10934f91023d67bd7c60ec;hpb=90ca7634c9f8577bc58763e93c166a4db1888ac6;p=mobile-ledger.git diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java index bbfedcbc..8a271fa7 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java @@ -17,39 +17,62 @@ package net.ktnx.mobileledger.ui.activity; +import android.annotation.SuppressLint; + import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModel; +import net.ktnx.mobileledger.BuildConfig; +import net.ktnx.mobileledger.model.Currency; import net.ktnx.mobileledger.model.LedgerTransactionAccount; +import net.ktnx.mobileledger.utils.Logger; import net.ktnx.mobileledger.utils.Misc; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import static net.ktnx.mobileledger.utils.Logger.debug; -import static net.ktnx.mobileledger.utils.Misc.isZero; public class NewTransactionModel extends ViewModel { static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$"); static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$"); static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$"); - private static final String ZERO_AMOUNT_HINT = "0.00"; + final MutableLiveData showCurrency = new MutableLiveData<>(false); private final Item header = new Item(this, null, ""); private final Item trailer = new Item(this); private final ArrayList items = new ArrayList<>(); private final MutableLiveData isSubmittable = new MutableLiveData<>(false); - private final MutableLiveData focusedItem = new MutableLiveData<>(null); + private final MutableLiveData focusedItem = new MutableLiveData<>(0); private final MutableLiveData accountCount = new MutableLiveData<>(0); + private final MutableLiveData simulateSave = new MutableLiveData<>(false); + public boolean getSimulateSave() { + return simulateSave.getValue(); + } + public void setSimulateSave(boolean simulateSave) { + this.simulateSave.setValue(simulateSave); + } + public void toggleSimulateSave() { + simulateSave.setValue(!simulateSave.getValue()); + } + public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, + @NonNull + androidx.lifecycle.Observer observer) { + this.simulateSave.observe(owner, observer); + } public int getAccountCount() { return items.size(); } @@ -87,6 +110,7 @@ public class NewTransactionModel extends ViewModel { @NonNull androidx.lifecycle.Observer observer) { this.accountCount.removeObserver(observer); } + public int getFocusedItem() { return focusedItem.getValue(); } public void setFocusedItem(int position) { focusedItem.setValue(position); } @@ -122,110 +146,182 @@ public class NewTransactionModel extends ViewModel { return trailer; } - // rules: - // 1) at least two account names - // 2) each amount must have account name - // 3) amounts must balance to 0, or - // 3a) there must be exactly one empty amount - // 4) empty accounts with empty amounts are ignored - // 5) a row with an empty account name or empty amount is guaranteed to exist + /* + A transaction is submittable if: + 0) has description + 1) has at least two account names + 2) each row with amount has account name + 3) for each commodity: + 3a) amounts must balance to 0, or + 3b) there must be exactly one empty amount (with account) + 4) empty accounts with empty amounts are ignored + 5) a row with an empty account name or empty amount is guaranteed to exist for each commodity + 6) at least two rows need to be present in the ledger + + */ + @SuppressLint("DefaultLocale") public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) { int accounts = 0; - int accounts_with_values = 0; int amounts = 0; - int amounts_with_accounts = 0; int empty_rows = 0; - Item empty_amount = null; - boolean single_empty_amount = false; - boolean single_empty_amount_has_account = false; - float running_total = 0f; + final Map balance = new HashMap<>(); final String descriptionText = getDescription(); - final boolean have_description = ((descriptionText != null) && !descriptionText.isEmpty()); + boolean submittable = true; + final Map> itemsWithEmptyAmountForCurrency = new HashMap<>(); + final Map> itemsWithAccountAndEmptyAmountForCurrency = new HashMap<>(); try { + if ((descriptionText == null) || descriptionText.trim() + .isEmpty()) + { + Logger.debug("submittable", "Transaction not submittable: missing description"); + submittable = false; + } + for (int i = 0; i < this.items.size(); i++) { Item item = this.items.get(i); LedgerTransactionAccount acc = item.getAccount(); String acc_name = acc.getAccountName() .trim(); + String currName = acc.getCurrency(); + if (acc_name.isEmpty()) { empty_rows++; - } - else { - accounts++; if (acc.isAmountSet()) { - accounts_with_values++; + // 2) each amount has account name + Logger.debug("submittable", String.format( + "Transaction not submittable: row %d has no account name, but has" + + " amount %1.2f", i + 1, acc.getAmount())); + submittable = false; } } + else { + accounts++; + } if (acc.isAmountSet()) { amounts++; - if (!acc_name.isEmpty()) - amounts_with_accounts++; - running_total += acc.getAmount(); + Float bal = balance.get(currName); + if (bal == null) + bal = 0f; + bal += acc.getAmount(); + balance.put(currName, bal); } else { - if (empty_amount == null) { - empty_amount = item; - single_empty_amount = true; - single_empty_amount_has_account = !acc_name.isEmpty(); + { + List list = itemsWithEmptyAmountForCurrency.get(currName); + if (list == null) + list = new ArrayList<>(); + itemsWithEmptyAmountForCurrency.put(currName, list); + list.add(item); + } + + if (!acc_name.isEmpty()) { + List list = + itemsWithAccountAndEmptyAmountForCurrency.get(acc.getCurrency()); + if (list == null) { + list = new ArrayList<>(); + itemsWithAccountAndEmptyAmountForCurrency.put(acc.getCurrency(), list); + } + list.add(item); } - else if (!acc_name.isEmpty()) - single_empty_amount = false; } } - if ((empty_rows == 0) && - ((this.items.size() == accounts) || (this.items.size() == amounts))) - { - adapter.addRow(); + // 1) has at least two account names + if (accounts < 2) { + if (accounts == 0) + Logger.debug("submittable", "Transaction not submittable: no account names"); + else if (accounts == 1) + Logger.debug("submittable", + "Transaction not submittable: only one account name"); + else + Logger.debug("submittable", + String.format("Transaction not submittable: only %d account names", + accounts)); + submittable = false; } - for (NewTransactionModel.Item item : items) { - - final LedgerTransactionAccount acc = item.getAccount(); - if (acc.isAmountSet()) - continue; - - if (single_empty_amount) { - if (item.equals(empty_amount)) { - empty_amount.setAmountHint(String.format(Locale.US, "%1.2f", - Misc.isZero(running_total) ? 0f : -running_total)); - continue; + // 3) for each commodity: + // 3a) amount must balance to 0, or + // 3b) there must be exactly one empty amount (with account) + for (String balCurrency : balance.keySet()) { + Float bal = balance.get(balCurrency); + float currencyBalance = (bal == null) ? 0f : bal; + if (Misc.isZero(currencyBalance)) { + // remove hints from all amount inputs in that currency + for (Item item : items) { + final String itemCurrency = item.getCurrency() + .getName(); + if (((balCurrency == null) && (null == itemCurrency)) || + ((balCurrency != null) && balCurrency.equals(itemCurrency))) + item.setAmountHint(null); } } else { - // no single empty account and this account's amount is not set - // => hint should be '0.00' - String hint = item.getAmountHint(); - if ((hint == null) || !hint.equals(ZERO_AMOUNT_HINT)) { - item.setAmountHint(ZERO_AMOUNT_HINT); + List list = itemsWithAccountAndEmptyAmountForCurrency.get(balCurrency); + int balanceReceiversCount = (list == null) ? 0 : list.size(); + if (balanceReceiversCount != 1) { + Logger.debug("submittable", (balanceReceiversCount == 0) ? String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with no empty amounts with accounts", balCurrency) : String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with multiple empty amounts with accounts", balCurrency)); + submittable = false; } - } - } + List emptyAmountList = itemsWithEmptyAmountForCurrency.get(balCurrency); - debug("submittable", String.format(Locale.US, - "%s, accounts=%d, accounts_with_values=%s, " + - "amounts_with_accounts=%d, amounts=%d, running_total=%1.2f, " + - "single_empty_with_acc=%s", have_description ? "description" : "NO description", - accounts, accounts_with_values, amounts_with_accounts, amounts, running_total, - (single_empty_amount && single_empty_amount_has_account) ? "true" : "false")); + // suggest off-balance amount to a row and remove hints on other rows + Item receiver = null; + if ((list != null) && !list.isEmpty()) + receiver = list.get(0); + else if ((emptyAmountList != null) && !emptyAmountList.isEmpty()) + receiver = emptyAmountList.get(0); - if (have_description && (accounts >= 2) && (accounts_with_values >= (accounts - 1)) && - (amounts_with_accounts == amounts) && - (single_empty_amount && single_empty_amount_has_account || isZero(running_total))) - { - debug("submittable", "YES"); - isSubmittable.setValue(true); + for (Item item : items) { + if (item.equals(receiver)) { + Logger.debug("submittable", + String.format("Setting amount hint to %1.2f", + -currencyBalance)); + item.setAmountHint(String.format("%1.2f", -currencyBalance)); + } + else + item.setAmountHint(null); + } + } } - else { - debug("submittable", "NO"); - isSubmittable.setValue(false); + + // 5) a row with an empty account name or empty amount is guaranteed to exist for + // each commodity + for (String balCurrency : balance.keySet()) { + if ((empty_rows == 0) && + ((this.items.size() == accounts) || (this.items.size() == amounts))) + { + adapter.addRow(balCurrency); + } } + // 6) at least two rows need to be present in the ledger + while (this.items.size() < 2) + adapter.addRow(); + + + debug("submittable", submittable ? "YES" : "NO"); + isSubmittable.setValue(submittable); + + if (BuildConfig.DEBUG) { + debug("submittable", "== Dump of all items"); + for (int i = 0; i < items.size(); i++) { + Item item = items.get(i); + LedgerTransactionAccount acc = item.getAccount(); + debug("submittable", String.format("Item %2d: [%4.2f %s] %s // %s", i, + acc.isAmountSet() ? acc.getAmount() : 0, acc.getCurrency(), + acc.getAccountName(), acc.getComment())); + } + } } catch (NumberFormatException e) { debug("submittable", "NO (because of NumberFormatException)"); @@ -244,16 +340,56 @@ public class NewTransactionModel extends ViewModel { public void sendCountNotifications() { accountCount.setValue(getAccountCount()); } + public void sendFocusedNotification() { + focusedItem.setValue(focusedItem.getValue()); + } + public void updateFocusedItem(int position) { + focusedItem.setValue(position); + } + public void noteFocusChanged(int position, FocusedElement element) { + getItem(position).setFocusedElement(element); + } + public void swapItems(int one, int two) { + Collections.swap(items, one - 1, two - 1); + } + public void toggleComment(int position) { + final MutableLiveData commentVisible = getItem(position).commentVisible; + commentVisible.postValue(!commentVisible.getValue()); + } + public void moveItemLast(int index) { + /* 0 + 1 <-- index + 2 + 3 <-- desired position + */ + int itemCount = items.size(); + + if (index < itemCount - 1) { + Item acc = items.remove(index); + items.add(itemCount - 1, acc); + } + } + public void toggleCurrencyVisible() { + showCurrency.setValue(!showCurrency.getValue()); + } enum ItemType {generalData, transactionRow, bottomFiller} - class Item extends Object { + //========================================================================================== + + enum FocusedElement {Account, Comment, Amount} + + class Item { private ItemType type; private MutableLiveData date = new MutableLiveData<>(); private MutableLiveData description = new MutableLiveData<>(); private LedgerTransactionAccount account; - private MutableLiveData amountHint = new MutableLiveData<>(); + private MutableLiveData amountHint = new MutableLiveData<>(null); private NewTransactionModel model; private MutableLiveData editable = new MutableLiveData<>(true); + private FocusedElement focusedElement = FocusedElement.Account; + private MutableLiveData comment = new MutableLiveData<>(null); + private MutableLiveData commentVisible = new MutableLiveData<>(false); + private MutableLiveData currency = new MutableLiveData<>(null); public Item(NewTransactionModel model) { this.model = model; type = ItemType.bottomFiller; @@ -272,23 +408,43 @@ public class NewTransactionModel extends ViewModel { this.account = account; this.editable.setValue(true); } + public FocusedElement getFocusedElement() { + return focusedElement; + } + public void setFocusedElement(FocusedElement focusedElement) { + this.focusedElement = focusedElement; + } public NewTransactionModel getModel() { return model; } - public boolean isEditable() { - ensureType(ItemType.transactionRow); - return this.editable.getValue(); - } public void setEditable(boolean editable) { - ensureType(ItemType.transactionRow); + ensureType(ItemType.generalData, ItemType.transactionRow); this.editable.setValue(editable); } + private void ensureType(ItemType type1, ItemType type2) { + if ((type != type1) && (type != type2)) { + throw new RuntimeException( + String.format("Actual type (%s) differs from wanted (%s or %s)", type, + type1, type2)); + } + } public String getAmountHint() { ensureType(ItemType.transactionRow); return amountHint.getValue(); } public void setAmountHint(String amountHint) { ensureType(ItemType.transactionRow); + + // avoid unnecessary triggers + if (amountHint == null) { + if (this.amountHint.getValue() == null) + return; + } + else { + if (amountHint.equals(this.amountHint.getValue())) + return; + } + this.amountHint.setValue(amountHint); } public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, @@ -319,6 +475,13 @@ public class NewTransactionModel extends ViewModel { this.date.setValue(date); } public void setDate(String text) { + if ((text == null) || text.trim() + .isEmpty()) + { + setDate((Date) null); + return; + } + int year, month, day; final Calendar c = GregorianCalendar.getInstance(); Matcher m = reYMD.matcher(text); @@ -402,11 +565,11 @@ public class NewTransactionModel extends ViewModel { final int myDay = c.get(Calendar.DAY_OF_MONTH); if (today.get(Calendar.YEAR) != myYear) { - return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth, myDay); + return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay); } if (today.get(Calendar.MONTH) != myMonth) { - return String.format(Locale.US, "%d/%02d", myMonth, myDay); + return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay); } return String.valueOf(myDay); @@ -418,5 +581,37 @@ public class NewTransactionModel extends ViewModel { public void stopObservingEditableFlag(Observer observer) { editable.removeObserver(observer); } + public void observeCommentVisible(NewTransactionActivity activity, + Observer observer) { + commentVisible.observe(activity, observer); + } + public void stopObservingCommentVisible(Observer observer) { + commentVisible.removeObserver(observer); + } + public void observeComment(NewTransactionActivity activity, Observer observer) { + comment.observe(activity, observer); + } + public void stopObservingComment(Observer observer) { + comment.removeObserver(observer); + } + public void setComment(String comment) { + getAccount().setComment(comment); + this.comment.postValue(comment); + } + public Currency getCurrency() { + return this.currency.getValue(); + } + public void setCurrency(Currency currency) { + getAccount().setCurrency((currency != null && !currency.getName() + .isEmpty()) ? currency.getName() + : null); + this.currency.setValue(currency); + } + public void observeCurrency(NewTransactionActivity activity, Observer observer) { + currency.observe(activity, observer); + } + public void stopObservingCurrency(Observer observer) { + currency.removeObserver(observer); + } } }