X-Git-Url: https://git.ktnx.net/?p=mobile-ledger.git;a=blobdiff_plain;f=app%2Fsrc%2Fmain%2Fjava%2Fnet%2Fktnx%2Fmobileledger%2Fui%2Fnew_transaction%2FNewTransactionModel.java;h=36b457ff4bf76bc1c0973a1db085196c2f3ee4e3;hp=b96b917a01a622e641cefa71182f4222099f1ae4;hb=f973784f579d42988174acf0b24593aa23180fa6;hpb=9fad5003ac30c3e4f9d073e04f4569aeb31779b2 diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java index b96b917a..36b457ff 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java @@ -17,18 +17,32 @@ package net.ktnx.mobileledger.ui.new_transaction; +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModel; -import net.ktnx.mobileledger.model.Currency; +import net.ktnx.mobileledger.BuildConfig; +import net.ktnx.mobileledger.db.DB; +import net.ktnx.mobileledger.db.TemplateAccount; +import net.ktnx.mobileledger.db.TemplateHeader; import net.ktnx.mobileledger.model.Data; +import net.ktnx.mobileledger.model.InertMutableLiveData; +import net.ktnx.mobileledger.model.LedgerTransaction; import net.ktnx.mobileledger.model.LedgerTransactionAccount; +import net.ktnx.mobileledger.model.MatchedTemplate; import net.ktnx.mobileledger.model.MobileLedgerProfile; import net.ktnx.mobileledger.utils.Globals; +import net.ktnx.mobileledger.utils.Logger; +import net.ktnx.mobileledger.utils.Misc; import net.ktnx.mobileledger.utils.SimpleDate; import org.jetbrains.annotations.NotNull; @@ -36,171 +50,339 @@ import org.jetbrains.annotations.NotNull; import java.text.ParseException; import java.util.ArrayList; import java.util.Calendar; -import java.util.Collections; import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.MatchResult; + +enum ItemType {generalData, transactionRow} + +enum FocusedElement {Account, Comment, Amount, Description, TransactionComment} + public class NewTransactionModel extends ViewModel { - final MutableLiveData showCurrency = new MutableLiveData<>(false); - final ArrayList items = new ArrayList<>(); - final MutableLiveData isSubmittable = new MutableLiveData<>(false); - final MutableLiveData showComments = new MutableLiveData<>(true); - private final Item header = new Item(this, ""); - private final Item trailer = new Item(this); - private final MutableLiveData focusedItem = new MutableLiveData<>(0); - private final MutableLiveData accountCount = new MutableLiveData<>(0); - private final MutableLiveData simulateSave = new MutableLiveData<>(false); + private static final int MIN_ITEMS = 3; + private final MutableLiveData showCurrency = new MutableLiveData<>(false); + private final MutableLiveData isSubmittable = new InertMutableLiveData<>(false); + private final MutableLiveData showComments = new MutableLiveData<>(true); + private final MutableLiveData> items = new MutableLiveData<>(); + private final MutableLiveData simulateSave = new InertMutableLiveData<>(false); private final AtomicInteger busyCounter = new AtomicInteger(0); - private final MutableLiveData busyFlag = new MutableLiveData<>(false); + private final MutableLiveData busyFlag = new InertMutableLiveData<>(false); private final Observer profileObserver = profile -> { showCurrency.postValue(profile.getShowCommodityByDefault()); showComments.postValue(profile.getShowCommentsByDefault()); }; + private final MutableLiveData focusInfo = new MutableLiveData<>(); private boolean observingDataProfile; - void observeShowComments(LifecycleOwner owner, Observer observer) { - showComments.observe(owner, observer); + public NewTransactionModel() { + reset(); } - void observeBusyFlag(@NonNull LifecycleOwner owner, Observer observer) { - busyFlag.observe(owner, observer); + public LiveData getShowCurrency() { + return showCurrency; } - void observeDataProfile(LifecycleOwner activity) { - if (!observingDataProfile) - Data.observeProfile(activity, profileObserver); - observingDataProfile = true; + public LiveData> getItems() { + return items; } - boolean getSimulateSave() { - return simulateSave.getValue(); + private void setItems(@NonNull List newList) { + checkTransactionSubmittable(newList); + setItemsWithoutSubmittableChecks(newList); } - public void setSimulateSave(boolean simulateSave) { - this.simulateSave.setValue(simulateSave); + private void setItemsWithoutSubmittableChecks(@NonNull List list) { + final int cnt = list.size(); + for (int i = 1; i < cnt - 1; i++) { + final TransactionAccount item = list.get(i) + .toTransactionAccount(); + if (item.isLast) { + TransactionAccount replacement = new TransactionAccount(item); + replacement.isLast = false; + list.set(i, replacement); + } + } + final TransactionAccount last = list.get(cnt - 1) + .toTransactionAccount(); + if (!last.isLast) { + TransactionAccount replacement = new TransactionAccount(last); + replacement.isLast = true; + list.set(cnt - 1, replacement); + } + + if (BuildConfig.DEBUG) + dumpItemList("Before setValue()", list); + items.setValue(list); } - void toggleSimulateSave() { - simulateSave.setValue(!simulateSave.getValue()); + private List copyList() { + List copy = new ArrayList<>(); + List oldList = items.getValue(); + + if (oldList != null) + for (Item item : oldList) { + copy.add(Item.from(item)); + } + + return copy; } - void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.simulateSave.observe(owner, observer); + private List copyListWithoutItem(int position) { + List copy = new ArrayList<>(); + List oldList = items.getValue(); + + if (oldList != null) { + int i = 0; + for (Item item : oldList) { + if (i++ == position) + continue; + copy.add(Item.from(item)); + } + } + + return copy; + } + private List shallowCopyList() { + return new ArrayList<>(items.getValue()); } - int getAccountCount() { - return items.size(); + LiveData getShowComments() { + return showComments; } - public SimpleDate getDate() { - return header.date.getValue(); + void observeDataProfile(LifecycleOwner activity) { + if (!observingDataProfile) + Data.observeProfile(activity, profileObserver); + observingDataProfile = true; } - public void setDate(SimpleDate date) { - header.date.setValue(date); + boolean getSimulateSaveFlag() { + Boolean value = simulateSave.getValue(); + if (value == null) + return false; + return value; } - public String getDescription() { - return header.description.getValue(); + LiveData getSimulateSave() { + return simulateSave; } - public String getComment() { - return header.comment.getValue(); + void toggleSimulateSave() { + simulateSave.setValue(!getSimulateSaveFlag()); } LiveData isSubmittable() { return this.isSubmittable; } void reset() { - header.date.setValue(null); - header.description.setValue(null); - header.comment.setValue(null); - items.clear(); - items.add(new Item(this, new LedgerTransactionAccount(""))); - items.add(new Item(this, new LedgerTransactionAccount(""))); - focusedItem.setValue(0); - } - void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.observe(owner, observer); - } - void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.removeObserver(observer); - } - void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.accountCount.observe(owner, observer); - } - void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer observer) { - this.accountCount.removeObserver(observer); - } - int getFocusedItem() { return focusedItem.getValue(); } - void setFocusedItem(int position) { - focusedItem.setValue(position); - } - int addAccount(LedgerTransactionAccount acc) { - items.add(new Item(this, acc)); - accountCount.setValue(getAccountCount()); - return items.size(); + Logger.debug("new-trans", "Resetting model"); + List list = new ArrayList<>(); + Item.resetIdDispenser(); + list.add(new TransactionHead("")); + list.add(new TransactionAccount("")); + list.add(new TransactionAccount("")); + noteFocusChanged(0, FocusedElement.Description); + isSubmittable.setValue(false); + setItemsWithoutSubmittableChecks(list); } boolean accountsInInitialState() { - for (Item item : items) { - LedgerTransactionAccount acc = item.getAccount(); - if (acc.isAmountSet()) - return false; - if (!acc.getAccountName() - .trim() - .isEmpty()) + final List list = items.getValue(); + + if (list == null) + return true; + + for (Item item : list) { + if (!(item instanceof TransactionAccount)) + continue; + + TransactionAccount accRow = (TransactionAccount) item; + if (!accRow.isEmpty()) return false; } return true; } - LedgerTransactionAccount getAccount(int index) { - return items.get(index) - .getAccount(); - } - Item getItem(int index) { - if (index == 0) { - return header; + void applyTemplate(MatchedTemplate matchedTemplate, String text) { + SimpleDate transactionDate = null; + final MatchResult matchResult = matchedTemplate.matchResult; + final TemplateHeader templateHead = matchedTemplate.templateHead; + { + int day = extractIntFromMatches(matchResult, templateHead.getDateDayMatchGroup(), + templateHead.getDateDay()); + int month = extractIntFromMatches(matchResult, templateHead.getDateMonthMatchGroup(), + templateHead.getDateMonth()); + int year = extractIntFromMatches(matchResult, templateHead.getDateYearMatchGroup(), + templateHead.getDateYear()); + + if (year > 0 || month > 0 || day > 0) { + SimpleDate today = SimpleDate.today(); + if (year <= 0) + year = today.year; + if (month <= 0) + month = today.month; + if (day <= 0) + day = today.day; + + transactionDate = new SimpleDate(year, month, day); + + Logger.debug("pattern", "setting transaction date to " + transactionDate); + } } - if (index <= items.size()) - return items.get(index - 1); + List present = copyList(); - return trailer; - } - void removeRow(Item item, NewTransactionItemsAdapter adapter) { - int pos = items.indexOf(item); - items.remove(pos); - if (adapter != null) { - adapter.notifyItemRemoved(pos + 1); - sendCountNotifications(); + TransactionHead head = new TransactionHead(present.get(0) + .toTransactionHead()); + if (transactionDate != null) + head.setDate(transactionDate); + + final String transactionDescription = extractStringFromMatches(matchResult, + templateHead.getTransactionDescriptionMatchGroup(), + templateHead.getTransactionDescription()); + if (Misc.emptyIsNull(transactionDescription) != null) + head.setDescription(transactionDescription); + + final String transactionComment = extractStringFromMatches(matchResult, + templateHead.getTransactionCommentMatchGroup(), + templateHead.getTransactionComment()); + if (Misc.emptyIsNull(transactionComment) != null) + head.setComment(transactionComment); + + List newItems = new ArrayList<>(); + + newItems.add(head); + + for (int i = 1; i < present.size(); i++) { + final TransactionAccount row = present.get(i) + .toTransactionAccount(); + if (!row.isEmpty()) + newItems.add(new TransactionAccount(row)); } + + DB.get() + .getTemplateDAO() + .getTemplateWithAccountsAsync(templateHead.getId(), entry -> { + int rowIndex = 0; + final boolean accountsInInitialState = accountsInInitialState(); + for (TemplateAccount acc : entry.accounts) { + rowIndex++; + + String accountName = + extractStringFromMatches(matchResult, acc.getAccountNameMatchGroup(), + acc.getAccountName()); + String accountComment = + extractStringFromMatches(matchResult, acc.getAccountCommentMatchGroup(), + acc.getAccountComment()); + Float amount = extractFloatFromMatches(matchResult, acc.getAmountMatchGroup(), + acc.getAmount()); + if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount()) + amount = -amount; + + // TODO currency + TransactionAccount accRow = new TransactionAccount(accountName); + accRow.setComment(accountComment); + if (amount != null) + accRow.setAmount(amount); + + newItems.add(accRow); + } + + new Handler(Looper.getMainLooper()).post(() -> setItems(newItems)); + }); } - void removeItem(int pos) { - items.remove(pos); - accountCount.setValue(getAccountCount()); + private int extractIntFromMatches(MatchResult m, Integer group, Integer literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + try { + return Integer.parseInt(m.group(grp)); + } + catch (NumberFormatException e) { + Logger.debug("new-trans", "Error extracting matched number", e); + } + } + + return 0; } - void sendCountNotifications() { - accountCount.setValue(getAccountCount()); + private String extractStringFromMatches(MatchResult m, Integer group, String literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + return m.group(grp); + } + + return null; } - public void sendFocusedNotification() { - focusedItem.setValue(focusedItem.getValue()); + private Float extractFloatFromMatches(MatchResult m, Integer group, Float literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + try { + return Float.valueOf(m.group(grp)); + } + catch (NumberFormatException e) { + Logger.debug("new-trans", "Error extracting matched number", e); + } + } + + return null; } - void updateFocusedItem(int position) { - focusedItem.setValue(position); + void removeItem(int pos) { + Logger.debug("new-trans", String.format(Locale.US, "Removing item at position %d", pos)); + List newList = copyListWithoutItem(pos); + final FocusInfo fi = focusInfo.getValue(); + if ((fi != null) && (pos < fi.position)) + noteFocusChanged(fi.position - 1, fi.element); + setItems(newList); } void noteFocusChanged(int position, FocusedElement element) { - getItem(position).setFocusedElement(element); + FocusInfo present = focusInfo.getValue(); + if (present == null || present.position != position || present.element != element) + focusInfo.setValue(new FocusInfo(position, element)); } - void swapItems(int one, int two) { - Collections.swap(items, one - 1, two - 1); + public LiveData getFocusInfo() { + return focusInfo; } - void moveItemLast(int index) { + void moveItem(int fromIndex, int toIndex) { + List newList = shallowCopyList(); + Item item = newList.remove(fromIndex); + newList.add(toIndex, item); + + FocusInfo fi = focusInfo.getValue(); + if (fi != null && fi.position == fromIndex) + noteFocusChanged(toIndex, fi.element); + + items.setValue(newList); // same count, same submittable state + } + void moveItemLast(List list, int index) { /* 0 1 <-- index 2 3 <-- desired position + (no bottom filler) */ - int itemCount = items.size(); + int itemCount = list.size(); - if (index < itemCount - 1) { - Item acc = items.remove(index); - items.add(itemCount - 1, acc); - } + if (index < itemCount - 1) + list.add(list.remove(index)); } void toggleCurrencyVisible() { - showCurrency.setValue(!showCurrency.getValue()); + final boolean newValue = !Objects.requireNonNull(showCurrency.getValue()); + + // remove currency from all items, or reset currency to the default + // no need to clone the list, because the removal of the currency won't lead to + // visual changes -- the currency fields will be hidden or reset to default anyway + // still, there may be changes in the submittable state + final List list = Objects.requireNonNull(this.items.getValue()); + for (int i = 1; i < list.size(); i++) { + ((TransactionAccount) list.get(i)).setCurrency(newValue ? Data.getProfile() + .getDefaultCommodity() + : null); + } + checkTransactionSubmittable(null); + showCurrency.setValue(newValue); } void stopObservingBusyFlag(Observer observer) { busyFlag.removeObserver(observer); @@ -215,178 +397,562 @@ public class NewTransactionModel extends ViewModel { if (newValue == 0) busyFlag.postValue(false); } - public boolean getBusyFlag() { - return busyFlag.getValue(); + public LiveData getBusyFlag() { + return busyFlag; } public void toggleShowComments() { - showComments.setValue(!showComments.getValue()); + showComments.setValue(!Objects.requireNonNull(showComments.getValue())); } - enum ItemType {generalData, transactionRow, bottomFiller} + public LedgerTransaction constructLedgerTransaction() { + List list = Objects.requireNonNull(items.getValue()); + TransactionHead head = list.get(0) + .toTransactionHead(); + SimpleDate date = head.getDate(); + LedgerTransaction tr = head.asLedgerTransaction(); - enum FocusedElement {Account, Comment, Amount, Description, TransactionComment} + tr.setComment(head.getComment()); + LedgerTransactionAccount emptyAmountAccount = null; + float emptyAmountAccountBalance = 0; + for (int i = 1; i < list.size(); i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + LedgerTransactionAccount acc = new LedgerTransactionAccount(item.getAccountName() + .trim(), + item.getCurrency()); + if (acc.getAccountName() + .isEmpty()) + continue; + acc.setComment(item.getComment()); - //========================================================================================== + if (item.isAmountSet()) { + acc.setAmount(item.getAmount()); + emptyAmountAccountBalance += item.getAmount(); + } + else { + emptyAmountAccount = acc; + } + tr.addAccount(acc); + } - static class Item { - private final ItemType type; - private final MutableLiveData date = new MutableLiveData<>(); - private final MutableLiveData description = new MutableLiveData<>(); - private final MutableLiveData amountHint = new MutableLiveData<>(null); - private final NewTransactionModel model; - private final MutableLiveData editable = new MutableLiveData<>(true); - private final MutableLiveData comment = new MutableLiveData<>(null); - private final MutableLiveData currency = new MutableLiveData<>(null); - private final MutableLiveData amountValid = new MutableLiveData<>(true); - private LedgerTransactionAccount account; - private FocusedElement focusedElement = FocusedElement.Account; - private boolean amountHintIsSet = false; - Item(NewTransactionModel model) { - this.model = model; - type = ItemType.bottomFiller; - editable.setValue(false); - } - Item(NewTransactionModel model, String description) { - this.model = model; - this.type = ItemType.generalData; - this.description.setValue(description); - this.editable.setValue(true); - } - Item(NewTransactionModel model, LedgerTransactionAccount account) { - this.model = model; - this.type = ItemType.transactionRow; - this.account = account; - String currName = account.getCurrency(); - Currency curr = null; - if ((currName != null) && !currName.isEmpty()) - curr = Currency.loadByName(currName); - this.currency.setValue(curr); - this.editable.setValue(true); - } - FocusedElement getFocusedElement() { - return focusedElement; + if (emptyAmountAccount != null) + emptyAmountAccount.setAmount(-emptyAmountAccountBalance); + + return tr; + } + void loadTransactionIntoModel(String profileUUID, int transactionId) { + List newList = new ArrayList<>(); + Item.resetIdDispenser(); + LedgerTransaction tr; + MobileLedgerProfile profile = Data.getProfile(profileUUID); + if (profile == null) + throw new RuntimeException(String.format( + "Unable to find profile %s, which is supposed to contain transaction %d", + profileUUID, transactionId)); + + tr = profile.loadTransaction(transactionId); + TransactionHead head = new TransactionHead(tr.getDescription()); + head.setComment(tr.getComment()); + + newList.add(head); + + List accounts = tr.getAccounts(); + + TransactionAccount firstNegative = null; + TransactionAccount firstPositive = null; + int singleNegativeIndex = -1; + int singlePositiveIndex = -1; + int negativeCount = 0; + for (int i = 0; i < accounts.size(); i++) { + LedgerTransactionAccount acc = accounts.get(i); + TransactionAccount item = + new TransactionAccount(acc.getAccountName(), acc.getCurrency()); + newList.add(item); + + item.setAccountName(acc.getAccountName()); + item.setComment(acc.getComment()); + if (acc.isAmountSet()) { + item.setAmount(acc.getAmount()); + if (acc.getAmount() < 0) { + if (firstNegative == null) { + firstNegative = item; + singleNegativeIndex = i + 1; + } + else + singleNegativeIndex = -1; + } + else { + if (firstPositive == null) { + firstPositive = item; + singlePositiveIndex = i + 1; + } + else + singlePositiveIndex = -1; + } + } + else + item.resetAmount(); } - void setFocusedElement(FocusedElement focusedElement) { - this.focusedElement = focusedElement; + if (BuildConfig.DEBUG) + dumpItemList("Loaded previous transaction", newList); + + if (singleNegativeIndex != -1) { + firstNegative.resetAmount(); + moveItemLast(newList, singleNegativeIndex); } - public NewTransactionModel getModel() { - return model; + else if (singlePositiveIndex != -1) { + firstPositive.resetAmount(); + moveItemLast(newList, singlePositiveIndex); } - void setEditable(boolean editable) { - ensureTypeIsGeneralDataOrTransactionRow(); - this.editable.setValue(editable); + + setItems(newList); + + noteFocusChanged(1, FocusedElement.Amount); + } + /** + * 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 + * Side effects: + * 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 + * + * @param list - the item list to check. Can be the displayed list or a list that will be + * displayed soon + */ + @SuppressLint("DefaultLocale") + void checkTransactionSubmittable(@Nullable List list) { + boolean workingWithLiveList = false; + if (list == null) { + list = copyList(); + workingWithLiveList = true; } - private void ensureTypeIsGeneralDataOrTransactionRow() { - if ((type != ItemType.generalData) && (type != ItemType.transactionRow)) { - throw new RuntimeException( - String.format("Actual type (%s) differs from wanted (%s or %s)", type, - ItemType.generalData, ItemType.transactionRow)); + + if (BuildConfig.DEBUG) + dumpItemList(String.format("Before submittable checks (%s)", + workingWithLiveList ? "LIVE LIST" : "custom list"), list); + + int accounts = 0; + final BalanceForCurrency balance = new BalanceForCurrency(); + final String descriptionText = list.get(0) + .toTransactionHead() + .getDescription(); + boolean submittable = true; + boolean listChanged = false; + final ItemsForCurrency itemsForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency(); + final List emptyRows = new ArrayList<>(); + + try { + if ((descriptionText == null) || descriptionText.trim() + .isEmpty()) + { + Logger.debug("submittable", "Transaction not submittable: missing description"); + submittable = false; } - } - String getAmountHint() { - ensureType(ItemType.transactionRow); - return amountHint.getValue(); - } - void setAmountHint(String amountHint) { - ensureType(ItemType.transactionRow); - // avoid unnecessary triggers - if (amountHint == null) { - if (this.amountHint.getValue() == null) - return; - amountHintIsSet = false; + for (int i = 1; i < list.size(); i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + + String accName = item.getAccountName() + .trim(); + String currName = item.getCurrency(); + + itemsForCurrency.add(currName, item); + + if (accName.isEmpty()) { + itemsWithEmptyAccountForCurrency.add(currName, item); + + if (item.isAmountSet()) { + // 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, item.getAmount())); + submittable = false; + } + else { + emptyRowsForCurrency.add(currName, item); + } + } + else { + accounts++; + itemsWithAccountForCurrency.add(currName, item); + } + + if (!item.isAmountValid()) { + Logger.debug("submittable", + String.format("Not submittable: row %d has an invalid amount", i + 1)); + submittable = false; + } + else if (item.isAmountSet()) { + itemsWithAmountForCurrency.add(currName, item); + balance.add(currName, item.getAmount()); + } + else { + itemsWithEmptyAmountForCurrency.add(currName, item); + + if (!accName.isEmpty()) + itemsWithAccountAndEmptyAmountForCurrency.add(currName, item); + } } - else { - if (amountHint.equals(this.amountHint.getValue())) - return; - amountHintIsSet = true; + + // 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; + } + + // 3) for each commodity: + // 3a) amount must balance to 0, or + // 3b) there must be exactly one empty amount (with account) + for (String balCurrency : itemsForCurrency.currencies()) { + float currencyBalance = balance.get(balCurrency); + if (Misc.isZero(currencyBalance)) { + // remove hints from all amount inputs in that currency + for (int i = 1; i < list.size(); i++) { + TransactionAccount acc = list.get(i) + .toTransactionAccount(); + if (Misc.equalStrings(acc.getCurrency(), balCurrency)) { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format(Locale.US, "Resetting hint of %d:'%s' [%s]", + i, Misc.nullIsEmpty(acc.getAccountName()), + balCurrency)); + // skip if the amount is set, in which case the hint is not + // important/visible + if (!acc.isAmountSet() && acc.amountHintIsSet && + !TextUtils.isEmpty(acc.getAmountHint())) + { + acc.setAmountHint(null); + listChanged = true; + } + } + } + } + else { + List tmpList = + itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency); + int balanceReceiversCount = tmpList.size(); + if (balanceReceiversCount != 1) { + if (BuildConfig.DEBUG) { + if (balanceReceiversCount == 0) + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with no empty amounts with accounts", balCurrency)); + else + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with multiple empty amounts with accounts", balCurrency)); + } + submittable = false; + } + + List emptyAmountList = + itemsWithEmptyAmountForCurrency.getList(balCurrency); + + // suggest off-balance amount to a row and remove hints on other rows + Item receiver = null; + if (!tmpList.isEmpty()) + receiver = tmpList.get(0); + else if (!emptyAmountList.isEmpty()) + receiver = emptyAmountList.get(0); + + for (int i = 0; i < list.size(); i++) { + Item item = list.get(i); + if (!(item instanceof TransactionAccount)) + continue; + + TransactionAccount acc = item.toTransactionAccount(); + if (!Misc.equalStrings(acc.getCurrency(), balCurrency)) + continue; + + if (item == receiver) { + final String hint = String.format("%1.2f", -currencyBalance); + if (!acc.isAmountHintSet() || + !Misc.equalStrings(acc.getAmountHint(), hint)) + { + Logger.debug("submittable", + String.format("Setting amount hint of {%s} to %s [%s]", + acc.toString(), hint, balCurrency)); + acc.setAmountHint(hint); + listChanged = true; + } + } + else { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format("Resetting hint of '%s' [%s]", + Misc.nullIsEmpty(acc.getAccountName()), + balCurrency)); + if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) { + acc.setAmountHint(null); + listChanged = true; + } + } + } + } + } + + // 5) a row with an empty account name or empty amount is guaranteed to exist for + // each commodity + for (String balCurrency : balance.currencies()) { + int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency); + int currRows = itemsForCurrency.size(balCurrency); + int currAccounts = itemsWithAccountForCurrency.size(balCurrency); + int currAmounts = itemsWithAmountForCurrency.size(balCurrency); + if ((currEmptyRows == 0) && + ((currRows == currAccounts) || (currRows == currAmounts))) + { + // perhaps there already is an unused empty row for another currency that + // is not used? +// boolean foundIt = false; +// for (Item item : emptyRows) { +// Currency itemCurrency = item.getCurrency(); +// String itemCurrencyName = +// (itemCurrency == null) ? "" : itemCurrency.getName(); +// if (Misc.isZero(balance.get(itemCurrencyName))) { +// item.setCurrency(Currency.loadByName(balCurrency)); +// item.setAmountHint( +// String.format("%1.2f", -balance.get(balCurrency))); +// foundIt = true; +// break; +// } +// } +// +// if (!foundIt) + final TransactionAccount newAcc = new TransactionAccount("", balCurrency); + final float bal = balance.get(balCurrency); + if (!Misc.isZero(bal) && currAmounts == currRows) + newAcc.setAmountHint(String.format("%4.2f", -bal)); + Logger.debug("submittable", + String.format("Adding new item with %s for currency %s", + newAcc.getAmountHint(), balCurrency)); + list.add(newAcc); + listChanged = true; + } + } + + // drop extra empty rows, not needed + for (String currName : emptyRowsForCurrency.currencies()) { + List emptyItems = emptyRowsForCurrency.getList(currName); + while ((list.size() > MIN_ITEMS) && (emptyItems.size() > 1)) { + // the list is a copy, so the empty item is no longer present + Item itemToRemove = emptyItems.remove(1); + removeItemById(list, itemToRemove.id); + listChanged = true; + } + + // unused currency, remove last item (which is also an empty one) + if ((list.size() > MIN_ITEMS) && (emptyItems.size() == 1)) { + List currItems = itemsForCurrency.getList(currName); + + if (currItems.size() == 1) { + // the list is a copy, so the empty item is no longer present + removeItemById(list, emptyItems.get(0).id); + listChanged = true; + } + } + } + + // 6) at least two rows need to be present in the ledger + // (the list also contains header and trailer) + while (list.size() < MIN_ITEMS) { + list.add(new TransactionAccount("")); + listChanged = true; } - this.amountHint.setValue(amountHint); + Logger.debug("submittable", submittable ? "YES" : "NO"); + isSubmittable.setValue(submittable); + + if (BuildConfig.DEBUG) + dumpItemList("After submittable checks", list); } - void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.amountHint.observe(owner, observer); + catch (NumberFormatException e) { + Logger.debug("submittable", "NO (because of NumberFormatException)"); + isSubmittable.setValue(false); } - void stopObservingAmountHint( - @NonNull androidx.lifecycle.Observer observer) { - this.amountHint.removeObserver(observer); + catch (Exception e) { + e.printStackTrace(); + Logger.debug("submittable", "NO (because of an Exception)"); + isSubmittable.setValue(false); } - ItemType getType() { - return type; + + if (listChanged && workingWithLiveList) { + setItemsWithoutSubmittableChecks(list); } - void ensureType(ItemType wantedType) { - if (type != wantedType) { - throw new RuntimeException( - String.format("Actual type (%s) differs from wanted (%s)", type, - wantedType)); + } + private void removeItemById(@NotNull List list, int id) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + list.removeIf(item -> item.id == id); + } + else { + for (Item item : list) { + if (item.id == id) { + list.remove(item); + break; + } } } - public SimpleDate getDate() { - ensureType(ItemType.generalData); - return date.getValue(); + } + @SuppressLint("DefaultLocale") + private void dumpItemList(@NotNull String msg, @NotNull List list) { + Logger.debug("submittable", "== Dump of all items " + msg); + for (int i = 1; i < list.size(); i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + Logger.debug("submittable", String.format("%d:%s", i, item.toString())); } - public void setDate(SimpleDate date) { - ensureType(ItemType.generalData); - this.date.setValue(date); + } + public void setItemCurrency(int position, String newCurrency) { + TransactionAccount item = Objects.requireNonNull(items.getValue()) + .get(position) + .toTransactionAccount(); + final String oldCurrency = item.getCurrency(); + + if (Misc.equalStrings(oldCurrency, newCurrency)) + return; + + List newList = copyList(); + newList.get(position) + .toTransactionAccount() + .setCurrency(newCurrency); + + setItems(newList); + } + public boolean accountListIsEmpty() { + List items = Objects.requireNonNull(this.items.getValue()); + + for (Item item : items) { + if (!(item instanceof TransactionAccount)) + continue; + + if (!((TransactionAccount) item).isEmpty()) + return false; } - public void setDate(String text) throws ParseException { - if ((text == null) || text.trim() - .isEmpty()) - { - setDate((SimpleDate) null); - return; - } - SimpleDate date = Globals.parseLedgerDate(text); - this.setDate(date); + return true; + } + + public static class FocusInfo { + int position; + FocusedElement element; + public FocusInfo(int position, FocusedElement element) { + this.position = position; + this.element = element; } - void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.date.observe(owner, observer); + } + + static abstract class Item { + private static int idDispenser = 0; + protected final int id; + private Item() { + if (this instanceof TransactionHead) + id = 0; + else + synchronized (Item.class) { + id = ++idDispenser; + } } - void stopObservingDate(@NonNull androidx.lifecycle.Observer observer) { - this.date.removeObserver(observer); + public Item(int id) { + this.id = id; } - public String getDescription() { - ensureType(ItemType.generalData); - return description.getValue(); + public static Item from(Item origin) { + if (origin instanceof TransactionHead) + return new TransactionHead((TransactionHead) origin); + if (origin instanceof TransactionAccount) + return new TransactionAccount((TransactionAccount) origin); + throw new RuntimeException("Don't know how to handle " + origin); } - public void setDescription(String description) { - ensureType(ItemType.generalData); - this.description.setValue(description); + private static void resetIdDispenser() { + idDispenser = 0; } - void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.description.observe(owner, observer); + public int getId() { + return id; } - void stopObservingDescription( - @NonNull androidx.lifecycle.Observer observer) { - this.description.removeObserver(observer); + public abstract ItemType getType(); + public TransactionHead toTransactionHead() { + if (this instanceof TransactionHead) + return (TransactionHead) this; + + throw new IllegalStateException("Wrong item type " + this); } - public String getTransactionComment() { - ensureType(ItemType.generalData); - return comment.getValue(); + public TransactionAccount toTransactionAccount() { + if (this instanceof TransactionAccount) + return (TransactionAccount) this; + + throw new IllegalStateException("Wrong item type " + this); + } + public boolean equalContents(@Nullable Object item) { + if (item == null) + return false; + + if (!getClass().equals(item.getClass())) + return false; + + // shortcut - comparing same instance + if (item == this) + return true; + + if (this instanceof TransactionHead) + return ((TransactionHead) item).equalContents((TransactionHead) this); + if (this instanceof TransactionAccount) + return ((TransactionAccount) item).equalContents((TransactionAccount) this); + + throw new RuntimeException("Don't know how to handle " + this); } - public void setTransactionComment(String transactionComment) { - ensureType(ItemType.generalData); - this.comment.setValue(transactionComment); + } + + +//========================================================================================== + + public static class TransactionHead extends Item { + private SimpleDate date; + private String description; + private String comment; + TransactionHead(String description) { + super(); + this.description = description; } - void observeTransactionComment(@NonNull @NotNull LifecycleOwner owner, - @NonNull Observer observer) { - ensureType(ItemType.generalData); - this.comment.observe(owner, observer); + public TransactionHead(TransactionHead origin) { + super(origin.id); + date = origin.date; + description = origin.description; + comment = origin.comment; } - void stopObservingTransactionComment(@NonNull Observer observer) { - this.comment.removeObserver(observer); + public SimpleDate getDate() { + return date; } - public LedgerTransactionAccount getAccount() { - ensureType(ItemType.transactionRow); - return account; + public void setDate(SimpleDate date) { + this.date = date; } - public void setAccountName(String name) { - account.setAccountName(name); + public void setDate(String text) throws ParseException { + if (Misc.emptyIsNull(text) == null) { + date = null; + return; + } + + date = Globals.parseLedgerDate(text); } /** * getFormattedDate() @@ -396,75 +962,270 @@ public class NewTransactionModel extends ViewModel { String getFormattedDate() { if (date == null) return null; - SimpleDate d = date.getValue(); - if (d == null) - return null; Calendar today = GregorianCalendar.getInstance(); - if (today.get(Calendar.YEAR) != d.year) { - return String.format(Locale.US, "%d/%02d/%02d", d.year, d.month, d.day); + if (today.get(Calendar.YEAR) != date.year) { + return String.format(Locale.US, "%d/%02d/%02d", date.year, date.month, date.day); } - if (today.get(Calendar.MONTH) != d.month - 1) { - return String.format(Locale.US, "%d/%02d", d.month, d.day); + if (today.get(Calendar.MONTH) + 1 != date.month) { + return String.format(Locale.US, "%d/%02d", date.month, date.day); } - return String.valueOf(d.day); + return String.valueOf(date.day); + } + @NonNull + @Override + public String toString() { + @SuppressLint("DefaultLocale") StringBuilder b = new StringBuilder( + String.format("id:%d/%s", id, Integer.toHexString(hashCode()))); + + if (TextUtils.isEmpty(description)) + b.append(" «no description»"); + else + b.append(String.format(" '%s'", description)); + + if (date != null) + b.append(String.format("@%s", date.toString())); + + if (!TextUtils.isEmpty(comment)) + b.append(String.format(" /%s/", comment)); + + return b.toString(); } - void observeEditableFlag(NewTransactionActivity activity, Observer observer) { - editable.observe(activity, observer); + public String getDescription() { + return description; + } + public void setDescription(String description) { + this.description = description; + } + public String getComment() { + return comment; + } + public void setComment(String comment) { + this.comment = comment; + } + @Override + public ItemType getType() { + return ItemType.generalData; + } + public LedgerTransaction asLedgerTransaction() { + return new LedgerTransaction(null, date, description, Data.getProfile()); } - void stopObservingEditableFlag(Observer observer) { - editable.removeObserver(observer); + public boolean equalContents(TransactionHead other) { + if (other == null) + return false; + + return Objects.equals(date, other.date) && + Misc.equalStrings(description, other.description) && + Misc.equalStrings(comment, other.comment); + } + } + + public static class TransactionAccount extends Item { + private String accountName; + private String amountHint; + private String comment; + private String currency; + private float amount; + private boolean amountSet; + private boolean amountValid = true; + private FocusedElement focusedElement = FocusedElement.Account; + private boolean amountHintIsSet = false; + private boolean isLast = false; + private int accountNameCursorPosition; + public TransactionAccount(TransactionAccount origin) { + super(origin.id); + accountName = origin.accountName; + amount = origin.amount; + amountSet = origin.amountSet; + amountHint = origin.amountHint; + amountHintIsSet = origin.amountHintIsSet; + comment = origin.comment; + currency = origin.currency; + amountValid = origin.amountValid; + focusedElement = origin.focusedElement; + isLast = origin.isLast; + accountNameCursorPosition = origin.accountNameCursorPosition; } - void observeComment(NewTransactionActivity activity, Observer observer) { - comment.observe(activity, observer); + public TransactionAccount(LedgerTransactionAccount account) { + super(); + currency = account.getCurrency(); + amount = account.getAmount(); } - void stopObservingComment(Observer observer) { - comment.removeObserver(observer); + public TransactionAccount(String accountName) { + super(); + this.accountName = accountName; + } + public TransactionAccount(String accountName, String currency) { + super(); + this.accountName = accountName; + this.currency = currency; + } + public boolean isLast() { + return isLast; + } + public boolean isAmountSet() { + return amountSet; + } + public String getAccountName() { + return accountName; + } + public void setAccountName(String accountName) { + this.accountName = accountName; + } + public float getAmount() { + if (!amountSet) + throw new IllegalStateException("Amount is not set"); + return amount; + } + public void setAmount(float amount) { + this.amount = amount; + amountSet = true; + } + public void resetAmount() { + amountSet = false; + } + @Override + public ItemType getType() { + return ItemType.transactionRow; + } + public String getAmountHint() { + return amountHint; + } + public void setAmountHint(String amountHint) { + this.amountHint = amountHint; + amountHintIsSet = !TextUtils.isEmpty(amountHint); + } + public String getComment() { + return comment; } public void setComment(String comment) { - getAccount().setComment(comment); - this.comment.postValue(comment); + this.comment = comment; } - public Currency getCurrency() { - return this.currency.getValue(); + public String getCurrency() { + return currency; } - public void setCurrency(Currency currency) { - Currency present = this.currency.getValue(); - if ((currency == null) && (present != null) || - (currency != null) && !currency.equals(present)) - { - getAccount().setCurrency((currency != null && !currency.getName() - .isEmpty()) - ? currency.getName() : null); - this.currency.setValue(currency); - } + public void setCurrency(String currency) { + this.currency = currency; } - void observeCurrency(NewTransactionActivity activity, Observer observer) { - currency.observe(activity, observer); + public boolean isAmountValid() { + return amountValid; } - void stopObservingCurrency(Observer observer) { - currency.removeObserver(observer); + public void setAmountValid(boolean amountValid) { + this.amountValid = amountValid; } - boolean isBottomFiller() { - return this.type == ItemType.bottomFiller; + public FocusedElement getFocusedElement() { + return focusedElement; + } + public void setFocusedElement(FocusedElement focusedElement) { + this.focusedElement = focusedElement; } - boolean isAmountHintSet() { + public boolean isAmountHintSet() { return amountHintIsSet; } - void validateAmount() { - amountValid.setValue(true); + public void setAmountHintIsSet(boolean amountHintIsSet) { + this.amountHintIsSet = amountHintIsSet; + } + public boolean isEmpty() { + return !amountSet && Misc.emptyIsNull(accountName) == null && + Misc.emptyIsNull(comment) == null; + } + @SuppressLint("DefaultLocale") + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append(String.format("id:%d/%s", id, Integer.toHexString(hashCode()))); + if (!TextUtils.isEmpty(accountName)) + b.append(String.format(" acc'%s'", accountName)); + + if (amountSet) + b.append(String.format(" %4.2f", amount)); + else if (amountHintIsSet) + b.append(String.format(" (%s)", amountHint)); + + if (!TextUtils.isEmpty(currency)) + b.append(" ") + .append(currency); + + if (!TextUtils.isEmpty(comment)) + b.append(String.format(" /%s/", comment)); + + if (isLast) + b.append(" last"); + + return b.toString(); + } + public boolean equalContents(TransactionAccount other) { + if (other == null) + return false; + + boolean equal = Misc.equalStrings(accountName, other.accountName); + equal = equal && Misc.equalStrings(comment, other.comment) && + (amountSet ? other.amountSet && amount == other.amount : !other.amountSet); + + // compare amount hint only if there is no amount + if (!amountSet) + equal = equal && (amountHintIsSet ? other.amountHintIsSet && + Misc.equalStrings(amountHint, other.amountHint) + : !other.amountHintIsSet); + equal = equal && Misc.equalStrings(currency, other.currency) && isLast == other.isLast; + + Logger.debug("new-trans", + String.format("Comparing {%s} and {%s}: %s", this.toString(), other.toString(), + equal)); + return equal; + } + public int getAccountNameCursorPosition() { + return accountNameCursorPosition; + } + public void setAccountNameCursorPosition(int position) { + this.accountNameCursorPosition = position; + } + } + + private static class BalanceForCurrency { + private final HashMap hashMap = new HashMap<>(); + float get(String currencyName) { + Float f = hashMap.get(currencyName); + if (f == null) { + f = 0f; + hashMap.put(currencyName, f); + } + return f; + } + void add(String currencyName, float amount) { + hashMap.put(currencyName, get(currencyName) + amount); + } + Set currencies() { + return hashMap.keySet(); + } + boolean containsCurrency(String currencyName) { + return hashMap.containsKey(currencyName); + } + } + + private static class ItemsForCurrency { + private final HashMap> hashMap = new HashMap<>(); + @NonNull + List getList(@Nullable String currencyName) { + List list = hashMap.get(currencyName); + if (list == null) { + list = new ArrayList<>(); + hashMap.put(currencyName, list); + } + return list; } - void invalidateAmount() { - amountValid.setValue(false); + void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) { + getList(currencyName).add(item); } - void observeAmountValidity(NewTransactionActivity activity, Observer observer) { - amountValid.observe(activity, observer); + int size(@Nullable String currencyName) { + return this.getList(currencyName) + .size(); } - void stopObservingAmountValidity(Observer observer) { - amountValid.removeObserver(observer); + Set currencies() { + return hashMap.keySet(); } } }