]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java
smooth items replacement when old transaction is loaded and upon reset
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / new_transaction / NewTransactionModel.java
1 /*
2  * Copyright © 2021 Damyan Ivanov.
3  * This file is part of MoLe.
4  * MoLe is free software: you can distribute it and/or modify it
5  * under the term of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your opinion), any later version.
8  *
9  * MoLe is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License terms for details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
16  */
17
18 package net.ktnx.mobileledger.ui.new_transaction;
19
20 import android.annotation.SuppressLint;
21 import android.os.Handler;
22 import android.os.Looper;
23 import android.text.TextUtils;
24
25 import androidx.annotation.NonNull;
26 import androidx.annotation.Nullable;
27 import androidx.lifecycle.LifecycleOwner;
28 import androidx.lifecycle.LiveData;
29 import androidx.lifecycle.MutableLiveData;
30 import androidx.lifecycle.Observer;
31 import androidx.lifecycle.ViewModel;
32
33 import net.ktnx.mobileledger.BuildConfig;
34 import net.ktnx.mobileledger.db.DB;
35 import net.ktnx.mobileledger.db.TemplateAccount;
36 import net.ktnx.mobileledger.db.TemplateHeader;
37 import net.ktnx.mobileledger.model.Data;
38 import net.ktnx.mobileledger.model.InertMutableLiveData;
39 import net.ktnx.mobileledger.model.LedgerTransaction;
40 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
41 import net.ktnx.mobileledger.model.MatchedTemplate;
42 import net.ktnx.mobileledger.model.MobileLedgerProfile;
43 import net.ktnx.mobileledger.utils.Globals;
44 import net.ktnx.mobileledger.utils.Logger;
45 import net.ktnx.mobileledger.utils.Misc;
46 import net.ktnx.mobileledger.utils.SimpleDate;
47
48 import org.jetbrains.annotations.NotNull;
49
50 import java.text.ParseException;
51 import java.util.ArrayList;
52 import java.util.Calendar;
53 import java.util.GregorianCalendar;
54 import java.util.HashMap;
55 import java.util.List;
56 import java.util.Locale;
57 import java.util.Objects;
58 import java.util.Set;
59 import java.util.concurrent.atomic.AtomicInteger;
60 import java.util.regex.MatchResult;
61
62 enum ItemType {generalData, transactionRow}
63
64 enum FocusedElement {Account, Comment, Amount, Description, TransactionComment}
65
66
67 public class NewTransactionModel extends ViewModel {
68     private static final int MIN_ITEMS = 3;
69     private final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
70     private final MutableLiveData<Boolean> isSubmittable = new InertMutableLiveData<>(false);
71     private final MutableLiveData<Boolean> showComments = new MutableLiveData<>(true);
72     private final MutableLiveData<List<Item>> items = new MutableLiveData<>();
73     private final MutableLiveData<Boolean> simulateSave = new InertMutableLiveData<>(false);
74     private final AtomicInteger busyCounter = new AtomicInteger(0);
75     private final MutableLiveData<Boolean> busyFlag = new InertMutableLiveData<>(false);
76     private final Observer<MobileLedgerProfile> profileObserver = profile -> {
77         showCurrency.postValue(profile.getShowCommodityByDefault());
78         showComments.postValue(profile.getShowCommentsByDefault());
79     };
80     private final MutableLiveData<FocusInfo> focusInfo = new MutableLiveData<>();
81     private boolean observingDataProfile;
82     public NewTransactionModel() {
83         reset();
84     }
85     public LiveData<Boolean> getShowCurrency() {
86         return showCurrency;
87     }
88     public LiveData<List<Item>> getItems() {
89         return items;
90     }
91     private void setItems(@NonNull List<Item> newList) {
92         checkTransactionSubmittable(newList);
93         setItemsWithoutSubmittableChecks(newList);
94     }
95     private void replaceItems(@NonNull List<Item> newList) {
96         renumberItems();
97
98         setItems(newList);
99     }
100     /**
101      * make old items replaceable in-place. makes the new values visually blend in
102      */
103     private void renumberItems() {
104         final List<Item> list = items.getValue();
105         if (list == null) {
106             return;
107         }
108
109         int id = 0;
110         for (Item item : list)
111             item.id = id++;
112     }
113     private void setItemsWithoutSubmittableChecks(@NonNull List<Item> list) {
114         final int cnt = list.size();
115         for (int i = 1; i < cnt - 1; i++) {
116             final TransactionAccount item = list.get(i)
117                                                 .toTransactionAccount();
118             if (item.isLast) {
119                 TransactionAccount replacement = new TransactionAccount(item);
120                 replacement.isLast = false;
121                 list.set(i, replacement);
122             }
123         }
124         final TransactionAccount last = list.get(cnt - 1)
125                                             .toTransactionAccount();
126         if (!last.isLast) {
127             TransactionAccount replacement = new TransactionAccount(last);
128             replacement.isLast = true;
129             list.set(cnt - 1, replacement);
130         }
131
132         if (BuildConfig.DEBUG)
133             dumpItemList("Before setValue()", list);
134         items.setValue(list);
135     }
136     private List<Item> copyList() {
137         List<Item> copy = new ArrayList<>();
138         List<Item> oldList = items.getValue();
139
140         if (oldList != null)
141             for (Item item : oldList) {
142                 copy.add(Item.from(item));
143             }
144
145         return copy;
146     }
147     private List<Item> copyListWithoutItem(int position) {
148         List<Item> copy = new ArrayList<>();
149         List<Item> oldList = items.getValue();
150
151         if (oldList != null) {
152             int i = 0;
153             for (Item item : oldList) {
154                 if (i++ == position)
155                     continue;
156                 copy.add(Item.from(item));
157             }
158         }
159
160         return copy;
161     }
162     private List<Item> shallowCopyList() {
163         return new ArrayList<>(items.getValue());
164     }
165     LiveData<Boolean> getShowComments() {
166         return showComments;
167     }
168     void observeDataProfile(LifecycleOwner activity) {
169         if (!observingDataProfile)
170             Data.observeProfile(activity, profileObserver);
171         observingDataProfile = true;
172     }
173     boolean getSimulateSaveFlag() {
174         Boolean value = simulateSave.getValue();
175         if (value == null)
176             return false;
177         return value;
178     }
179     LiveData<Boolean> getSimulateSave() {
180         return simulateSave;
181     }
182     void toggleSimulateSave() {
183         simulateSave.setValue(!getSimulateSaveFlag());
184     }
185     LiveData<Boolean> isSubmittable() {
186         return this.isSubmittable;
187     }
188     void reset() {
189         Logger.debug("new-trans", "Resetting model");
190         List<Item> list = new ArrayList<>();
191         Item.resetIdDispenser();
192         list.add(new TransactionHead(""));
193         list.add(new TransactionAccount(""));
194         list.add(new TransactionAccount(""));
195         noteFocusChanged(0, FocusedElement.Description);
196         renumberItems();
197         isSubmittable.setValue(false);
198         setItemsWithoutSubmittableChecks(list);
199     }
200     boolean accountsInInitialState() {
201         final List<Item> list = items.getValue();
202
203         if (list == null)
204             return true;
205
206         for (Item item : list) {
207             if (!(item instanceof TransactionAccount))
208                 continue;
209
210             TransactionAccount accRow = (TransactionAccount) item;
211             if (!accRow.isEmpty())
212                 return false;
213         }
214
215         return true;
216     }
217     void applyTemplate(MatchedTemplate matchedTemplate, String text) {
218         SimpleDate transactionDate = null;
219         final MatchResult matchResult = matchedTemplate.matchResult;
220         final TemplateHeader templateHead = matchedTemplate.templateHead;
221         {
222             int day = extractIntFromMatches(matchResult, templateHead.getDateDayMatchGroup(),
223                     templateHead.getDateDay());
224             int month = extractIntFromMatches(matchResult, templateHead.getDateMonthMatchGroup(),
225                     templateHead.getDateMonth());
226             int year = extractIntFromMatches(matchResult, templateHead.getDateYearMatchGroup(),
227                     templateHead.getDateYear());
228
229             if (year > 0 || month > 0 || day > 0) {
230                 SimpleDate today = SimpleDate.today();
231                 if (year <= 0)
232                     year = today.year;
233                 if (month <= 0)
234                     month = today.month;
235                 if (day <= 0)
236                     day = today.day;
237
238                 transactionDate = new SimpleDate(year, month, day);
239
240                 Logger.debug("pattern", "setting transaction date to " + transactionDate);
241             }
242         }
243
244         List<Item> present = copyList();
245
246         TransactionHead head = new TransactionHead(present.get(0)
247                                                           .toTransactionHead());
248         if (transactionDate != null)
249             head.setDate(transactionDate);
250
251         final String transactionDescription = extractStringFromMatches(matchResult,
252                 templateHead.getTransactionDescriptionMatchGroup(),
253                 templateHead.getTransactionDescription());
254         if (Misc.emptyIsNull(transactionDescription) != null)
255             head.setDescription(transactionDescription);
256
257         final String transactionComment = extractStringFromMatches(matchResult,
258                 templateHead.getTransactionCommentMatchGroup(),
259                 templateHead.getTransactionComment());
260         if (Misc.emptyIsNull(transactionComment) != null)
261             head.setComment(transactionComment);
262
263         List<Item> newItems = new ArrayList<>();
264
265         newItems.add(head);
266
267         for (int i = 1; i < present.size(); i++) {
268             final TransactionAccount row = present.get(i)
269                                                   .toTransactionAccount();
270             if (!row.isEmpty())
271                 newItems.add(new TransactionAccount(row));
272         }
273
274         DB.get()
275           .getTemplateDAO()
276           .getTemplateWithAccountsAsync(templateHead.getId(), entry -> {
277               int rowIndex = 0;
278               final boolean accountsInInitialState = accountsInInitialState();
279               for (TemplateAccount acc : entry.accounts) {
280                   rowIndex++;
281
282                   String accountName =
283                           extractStringFromMatches(matchResult, acc.getAccountNameMatchGroup(),
284                                   acc.getAccountName());
285                   String accountComment =
286                           extractStringFromMatches(matchResult, acc.getAccountCommentMatchGroup(),
287                                   acc.getAccountComment());
288                   Float amount = extractFloatFromMatches(matchResult, acc.getAmountMatchGroup(),
289                           acc.getAmount());
290                   if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount())
291                       amount = -amount;
292
293                   // TODO currency
294                   TransactionAccount accRow = new TransactionAccount(accountName);
295                   accRow.setComment(accountComment);
296                   if (amount != null)
297                       accRow.setAmount(amount);
298
299                   newItems.add(accRow);
300               }
301
302               new Handler(Looper.getMainLooper()).post(() -> setItems(newItems));
303           });
304     }
305     private int extractIntFromMatches(MatchResult m, Integer group, Integer literal) {
306         if (literal != null)
307             return literal;
308
309         if (group != null) {
310             int grp = group;
311             if (grp > 0 & grp <= m.groupCount())
312                 try {
313                     return Integer.parseInt(m.group(grp));
314                 }
315                 catch (NumberFormatException e) {
316                     Logger.debug("new-trans", "Error extracting matched number", e);
317                 }
318         }
319
320         return 0;
321     }
322     private String extractStringFromMatches(MatchResult m, Integer group, String literal) {
323         if (literal != null)
324             return literal;
325
326         if (group != null) {
327             int grp = group;
328             if (grp > 0 & grp <= m.groupCount())
329                 return m.group(grp);
330         }
331
332         return null;
333     }
334     private Float extractFloatFromMatches(MatchResult m, Integer group, Float literal) {
335         if (literal != null)
336             return literal;
337
338         if (group != null) {
339             int grp = group;
340             if (grp > 0 & grp <= m.groupCount())
341                 try {
342                     return Float.valueOf(m.group(grp));
343                 }
344                 catch (NumberFormatException e) {
345                     Logger.debug("new-trans", "Error extracting matched number", e);
346                 }
347         }
348
349         return null;
350     }
351     void removeItem(int pos) {
352         Logger.debug("new-trans", String.format(Locale.US, "Removing item at position %d", pos));
353         List<Item> newList = copyListWithoutItem(pos);
354         final FocusInfo fi = focusInfo.getValue();
355         if ((fi != null) && (pos < fi.position))
356             noteFocusChanged(fi.position - 1, fi.element);
357         setItems(newList);
358     }
359     void noteFocusChanged(int position, FocusedElement element) {
360         FocusInfo present = focusInfo.getValue();
361         if (present == null || present.position != position || present.element != element)
362             focusInfo.setValue(new FocusInfo(position, element));
363     }
364     public LiveData<FocusInfo> getFocusInfo() {
365         return focusInfo;
366     }
367     void moveItem(int fromIndex, int toIndex) {
368         List<Item> newList = shallowCopyList();
369         Item item = newList.remove(fromIndex);
370         newList.add(toIndex, item);
371
372         FocusInfo fi = focusInfo.getValue();
373         if (fi != null && fi.position == fromIndex)
374             noteFocusChanged(toIndex, fi.element);
375
376         items.setValue(newList); // same count, same submittable state
377     }
378     void moveItemLast(List<Item> list, int index) {
379         /*   0
380              1   <-- index
381              2
382              3   <-- desired position
383                  (no bottom filler)
384          */
385         int itemCount = list.size();
386
387         if (index < itemCount - 1)
388             list.add(list.remove(index));
389     }
390     void toggleCurrencyVisible() {
391         final boolean newValue = !Objects.requireNonNull(showCurrency.getValue());
392
393         // remove currency from all items, or reset currency to the default
394         // no need to clone the list, because the removal of the currency won't lead to
395         // visual changes -- the currency fields will be hidden or reset to default anyway
396         // still, there may be changes in the submittable state
397         final List<Item> list = Objects.requireNonNull(this.items.getValue());
398         for (int i = 1; i < list.size(); i++) {
399             ((TransactionAccount) list.get(i)).setCurrency(newValue ? Data.getProfile()
400                                                                           .getDefaultCommodity()
401                                                                     : null);
402         }
403         checkTransactionSubmittable(null);
404         showCurrency.setValue(newValue);
405     }
406     void stopObservingBusyFlag(Observer<Boolean> observer) {
407         busyFlag.removeObserver(observer);
408     }
409     void incrementBusyCounter() {
410         int newValue = busyCounter.incrementAndGet();
411         if (newValue == 1)
412             busyFlag.postValue(true);
413     }
414     void decrementBusyCounter() {
415         int newValue = busyCounter.decrementAndGet();
416         if (newValue == 0)
417             busyFlag.postValue(false);
418     }
419     public LiveData<Boolean> getBusyFlag() {
420         return busyFlag;
421     }
422     public void toggleShowComments() {
423         showComments.setValue(!Objects.requireNonNull(showComments.getValue()));
424     }
425     public LedgerTransaction constructLedgerTransaction() {
426         List<Item> list = Objects.requireNonNull(items.getValue());
427         TransactionHead head = list.get(0)
428                                    .toTransactionHead();
429         SimpleDate date = head.getDate();
430         LedgerTransaction tr = head.asLedgerTransaction();
431
432         tr.setComment(head.getComment());
433         LedgerTransactionAccount emptyAmountAccount = null;
434         float emptyAmountAccountBalance = 0;
435         for (int i = 1; i < list.size(); i++) {
436             TransactionAccount item = list.get(i)
437                                           .toTransactionAccount();
438             LedgerTransactionAccount acc = new LedgerTransactionAccount(item.getAccountName()
439                                                                             .trim(),
440                     item.getCurrency());
441             if (acc.getAccountName()
442                    .isEmpty())
443                 continue;
444
445             acc.setComment(item.getComment());
446
447             if (item.isAmountSet()) {
448                 acc.setAmount(item.getAmount());
449                 emptyAmountAccountBalance += item.getAmount();
450             }
451             else {
452                 emptyAmountAccount = acc;
453             }
454
455             tr.addAccount(acc);
456         }
457
458         if (emptyAmountAccount != null)
459             emptyAmountAccount.setAmount(-emptyAmountAccountBalance);
460
461         return tr;
462     }
463     void loadTransactionIntoModel(String profileUUID, int transactionId) {
464         List<Item> newList = new ArrayList<>();
465         Item.resetIdDispenser();
466         LedgerTransaction tr;
467         MobileLedgerProfile profile = Data.getProfile(profileUUID);
468         if (profile == null)
469             throw new RuntimeException(String.format(
470                     "Unable to find profile %s, which is supposed to contain transaction %d",
471                     profileUUID, transactionId));
472
473         tr = profile.loadTransaction(transactionId);
474         TransactionHead head = new TransactionHead(tr.getDescription());
475         head.setComment(tr.getComment());
476
477         newList.add(head);
478
479         List<LedgerTransactionAccount> accounts = tr.getAccounts();
480
481         TransactionAccount firstNegative = null;
482         TransactionAccount firstPositive = null;
483         int singleNegativeIndex = -1;
484         int singlePositiveIndex = -1;
485         int negativeCount = 0;
486         for (int i = 0; i < accounts.size(); i++) {
487             LedgerTransactionAccount acc = accounts.get(i);
488             TransactionAccount item =
489                     new TransactionAccount(acc.getAccountName(), acc.getCurrency());
490             newList.add(item);
491
492             item.setAccountName(acc.getAccountName());
493             item.setComment(acc.getComment());
494             if (acc.isAmountSet()) {
495                 item.setAmount(acc.getAmount());
496                 if (acc.getAmount() < 0) {
497                     if (firstNegative == null) {
498                         firstNegative = item;
499                         singleNegativeIndex = i + 1;
500                     }
501                     else
502                         singleNegativeIndex = -1;
503                 }
504                 else {
505                     if (firstPositive == null) {
506                         firstPositive = item;
507                         singlePositiveIndex = i + 1;
508                     }
509                     else
510                         singlePositiveIndex = -1;
511                 }
512             }
513             else
514                 item.resetAmount();
515         }
516         if (BuildConfig.DEBUG)
517             dumpItemList("Loaded previous transaction", newList);
518
519         if (singleNegativeIndex != -1) {
520             firstNegative.resetAmount();
521             moveItemLast(newList, singleNegativeIndex);
522         }
523         else if (singlePositiveIndex != -1) {
524             firstPositive.resetAmount();
525             moveItemLast(newList, singlePositiveIndex);
526         }
527
528         setItems(newList);
529
530         noteFocusChanged(1, FocusedElement.Amount);
531     }
532     /**
533      * A transaction is submittable if:
534      * 0) has description
535      * 1) has at least two account names
536      * 2) each row with amount has account name
537      * 3) for each commodity:
538      * 3a) amounts must balance to 0, or
539      * 3b) there must be exactly one empty amount (with account)
540      * 4) empty accounts with empty amounts are ignored
541      * Side effects:
542      * 5) a row with an empty account name or empty amount is guaranteed to exist for each
543      * commodity
544      * 6) at least two rows need to be present in the ledger
545      *
546      * @param list - the item list to check. Can be the displayed list or a list that will be
547      *             displayed soon
548      */
549     @SuppressLint("DefaultLocale")
550     void checkTransactionSubmittable(@Nullable List<Item> list) {
551         boolean workingWithLiveList = false;
552         if (list == null) {
553             list = copyList();
554             workingWithLiveList = true;
555         }
556
557         if (BuildConfig.DEBUG)
558             dumpItemList(String.format("Before submittable checks (%s)",
559                     workingWithLiveList ? "LIVE LIST" : "custom list"), list);
560
561         int accounts = 0;
562         final BalanceForCurrency balance = new BalanceForCurrency();
563         final String descriptionText = list.get(0)
564                                            .toTransactionHead()
565                                            .getDescription();
566         boolean submittable = true;
567         boolean listChanged = false;
568         final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
569         final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency();
570         final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency();
571         final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency();
572         final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency();
573         final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency();
574         final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency();
575         final List<Item> emptyRows = new ArrayList<>();
576
577         try {
578             if ((descriptionText == null) || descriptionText.trim()
579                                                             .isEmpty())
580             {
581                 Logger.debug("submittable", "Transaction not submittable: missing description");
582                 submittable = false;
583             }
584
585             for (int i = 1; i < list.size(); i++) {
586                 TransactionAccount item = list.get(i)
587                                               .toTransactionAccount();
588
589                 String accName = item.getAccountName()
590                                      .trim();
591                 String currName = item.getCurrency();
592
593                 itemsForCurrency.add(currName, item);
594
595                 if (accName.isEmpty()) {
596                     itemsWithEmptyAccountForCurrency.add(currName, item);
597
598                     if (item.isAmountSet()) {
599                         // 2) each amount has account name
600                         Logger.debug("submittable", String.format(
601                                 "Transaction not submittable: row %d has no account name, but" +
602                                 " has" + " amount %1.2f", i + 1, item.getAmount()));
603                         submittable = false;
604                     }
605                     else {
606                         emptyRowsForCurrency.add(currName, item);
607                     }
608                 }
609                 else {
610                     accounts++;
611                     itemsWithAccountForCurrency.add(currName, item);
612                 }
613
614                 if (!item.isAmountValid()) {
615                     Logger.debug("submittable",
616                             String.format("Not submittable: row %d has an invalid amount", i + 1));
617                     submittable = false;
618                 }
619                 else if (item.isAmountSet()) {
620                     itemsWithAmountForCurrency.add(currName, item);
621                     balance.add(currName, item.getAmount());
622                 }
623                 else {
624                     itemsWithEmptyAmountForCurrency.add(currName, item);
625
626                     if (!accName.isEmpty())
627                         itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
628                 }
629             }
630
631             // 1) has at least two account names
632             if (accounts < 2) {
633                 if (accounts == 0)
634                     Logger.debug("submittable", "Transaction not submittable: no account names");
635                 else if (accounts == 1)
636                     Logger.debug("submittable",
637                             "Transaction not submittable: only one account name");
638                 else
639                     Logger.debug("submittable",
640                             String.format("Transaction not submittable: only %d account names",
641                                     accounts));
642                 submittable = false;
643             }
644
645             // 3) for each commodity:
646             // 3a) amount must balance to 0, or
647             // 3b) there must be exactly one empty amount (with account)
648             for (String balCurrency : itemsForCurrency.currencies()) {
649                 float currencyBalance = balance.get(balCurrency);
650                 if (Misc.isZero(currencyBalance)) {
651                     // remove hints from all amount inputs in that currency
652                     for (int i = 1; i < list.size(); i++) {
653                         TransactionAccount acc = list.get(i)
654                                                      .toTransactionAccount();
655                         if (Misc.equalStrings(acc.getCurrency(), balCurrency)) {
656                             if (BuildConfig.DEBUG)
657                                 Logger.debug("submittable",
658                                         String.format(Locale.US, "Resetting hint of %d:'%s' [%s]",
659                                                 i, Misc.nullIsEmpty(acc.getAccountName()),
660                                                 balCurrency));
661                             // skip if the amount is set, in which case the hint is not
662                             // important/visible
663                             if (!acc.isAmountSet() && acc.amountHintIsSet &&
664                                 !TextUtils.isEmpty(acc.getAmountHint()))
665                             {
666                                 acc.setAmountHint(null);
667                                 listChanged = true;
668                             }
669                         }
670                     }
671                 }
672                 else {
673                     List<Item> tmpList =
674                             itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
675                     int balanceReceiversCount = tmpList.size();
676                     if (balanceReceiversCount != 1) {
677                         if (BuildConfig.DEBUG) {
678                             if (balanceReceiversCount == 0)
679                                 Logger.debug("submittable", String.format(
680                                         "Transaction not submittable [%s]: non-zero balance " +
681                                         "with no empty amounts with accounts", balCurrency));
682                             else
683                                 Logger.debug("submittable", String.format(
684                                         "Transaction not submittable [%s]: non-zero balance " +
685                                         "with multiple empty amounts with accounts", balCurrency));
686                         }
687                         submittable = false;
688                     }
689
690                     List<Item> emptyAmountList =
691                             itemsWithEmptyAmountForCurrency.getList(balCurrency);
692
693                     // suggest off-balance amount to a row and remove hints on other rows
694                     Item receiver = null;
695                     if (!tmpList.isEmpty())
696                         receiver = tmpList.get(0);
697                     else if (!emptyAmountList.isEmpty())
698                         receiver = emptyAmountList.get(0);
699
700                     for (int i = 0; i < list.size(); i++) {
701                         Item item = list.get(i);
702                         if (!(item instanceof TransactionAccount))
703                             continue;
704
705                         TransactionAccount acc = item.toTransactionAccount();
706                         if (!Misc.equalStrings(acc.getCurrency(), balCurrency))
707                             continue;
708
709                         if (item == receiver) {
710                             final String hint = String.format("%1.2f", -currencyBalance);
711                             if (!acc.isAmountHintSet() ||
712                                 !Misc.equalStrings(acc.getAmountHint(), hint))
713                             {
714                                 Logger.debug("submittable",
715                                         String.format("Setting amount hint of {%s} to %s [%s]",
716                                                 acc.toString(), hint, balCurrency));
717                                 acc.setAmountHint(hint);
718                                 listChanged = true;
719                             }
720                         }
721                         else {
722                             if (BuildConfig.DEBUG)
723                                 Logger.debug("submittable",
724                                         String.format("Resetting hint of '%s' [%s]",
725                                                 Misc.nullIsEmpty(acc.getAccountName()),
726                                                 balCurrency));
727                             if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) {
728                                 acc.setAmountHint(null);
729                                 listChanged = true;
730                             }
731                         }
732                     }
733                 }
734             }
735
736             // 5) a row with an empty account name or empty amount is guaranteed to exist for
737             // each commodity
738             for (String balCurrency : balance.currencies()) {
739                 int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
740                 int currRows = itemsForCurrency.size(balCurrency);
741                 int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
742                 int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
743                 if ((currEmptyRows == 0) &&
744                     ((currRows == currAccounts) || (currRows == currAmounts)))
745                 {
746                     // perhaps there already is an unused empty row for another currency that
747                     // is not used?
748 //                        boolean foundIt = false;
749 //                        for (Item item : emptyRows) {
750 //                            Currency itemCurrency = item.getCurrency();
751 //                            String itemCurrencyName =
752 //                                    (itemCurrency == null) ? "" : itemCurrency.getName();
753 //                            if (Misc.isZero(balance.get(itemCurrencyName))) {
754 //                                item.setCurrency(Currency.loadByName(balCurrency));
755 //                                item.setAmountHint(
756 //                                        String.format("%1.2f", -balance.get(balCurrency)));
757 //                                foundIt = true;
758 //                                break;
759 //                            }
760 //                        }
761 //
762 //                        if (!foundIt)
763                     final TransactionAccount newAcc = new TransactionAccount("", balCurrency);
764                     final float bal = balance.get(balCurrency);
765                     if (!Misc.isZero(bal) && currAmounts == currRows)
766                         newAcc.setAmountHint(String.format("%4.2f", -bal));
767                     Logger.debug("submittable",
768                             String.format("Adding new item with %s for currency %s",
769                                     newAcc.getAmountHint(), balCurrency));
770                     list.add(newAcc);
771                     listChanged = true;
772                 }
773             }
774
775             // drop extra empty rows, not needed
776             for (String currName : emptyRowsForCurrency.currencies()) {
777                 List<Item> emptyItems = emptyRowsForCurrency.getList(currName);
778                 while ((list.size() > MIN_ITEMS) && (emptyItems.size() > 1)) {
779                     // the list is a copy, so the empty item is no longer present
780                     Item itemToRemove = emptyItems.remove(1);
781                     removeItemById(list, itemToRemove.id);
782                     listChanged = true;
783                 }
784
785                 // unused currency, remove last item (which is also an empty one)
786                 if ((list.size() > MIN_ITEMS) && (emptyItems.size() == 1)) {
787                     List<Item> currItems = itemsForCurrency.getList(currName);
788
789                     if (currItems.size() == 1) {
790                         // the list is a copy, so the empty item is no longer present
791                         removeItemById(list, emptyItems.get(0).id);
792                         listChanged = true;
793                     }
794                 }
795             }
796
797             // 6) at least two rows need to be present in the ledger
798             //    (the list also contains header and trailer)
799             while (list.size() < MIN_ITEMS) {
800                 list.add(new TransactionAccount(""));
801                 listChanged = true;
802             }
803
804             Logger.debug("submittable", submittable ? "YES" : "NO");
805             isSubmittable.setValue(submittable);
806
807             if (BuildConfig.DEBUG)
808                 dumpItemList("After submittable checks", list);
809         }
810         catch (NumberFormatException e) {
811             Logger.debug("submittable", "NO (because of NumberFormatException)");
812             isSubmittable.setValue(false);
813         }
814         catch (Exception e) {
815             e.printStackTrace();
816             Logger.debug("submittable", "NO (because of an Exception)");
817             isSubmittable.setValue(false);
818         }
819
820         if (listChanged && workingWithLiveList) {
821             setItemsWithoutSubmittableChecks(list);
822         }
823     }
824     private void removeItemById(@NotNull List<Item> list, int id) {
825         if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
826             list.removeIf(item -> item.id == id);
827         }
828         else {
829             for (Item item : list) {
830                 if (item.id == id) {
831                     list.remove(item);
832                     break;
833                 }
834             }
835         }
836     }
837     @SuppressLint("DefaultLocale")
838     private void dumpItemList(@NotNull String msg, @NotNull List<Item> list) {
839         Logger.debug("submittable", "== Dump of all items " + msg);
840         for (int i = 1; i < list.size(); i++) {
841             TransactionAccount item = list.get(i)
842                                           .toTransactionAccount();
843             Logger.debug("submittable", String.format("%d:%s", i, item.toString()));
844         }
845     }
846     public void setItemCurrency(int position, String newCurrency) {
847         TransactionAccount item = Objects.requireNonNull(items.getValue())
848                                          .get(position)
849                                          .toTransactionAccount();
850         final String oldCurrency = item.getCurrency();
851
852         if (Misc.equalStrings(oldCurrency, newCurrency))
853             return;
854
855         List<Item> newList = copyList();
856         newList.get(position)
857                .toTransactionAccount()
858                .setCurrency(newCurrency);
859
860         setItems(newList);
861     }
862     public boolean accountListIsEmpty() {
863         List<Item> items = Objects.requireNonNull(this.items.getValue());
864
865         for (Item item : items) {
866             if (!(item instanceof TransactionAccount))
867                 continue;
868
869             if (!((TransactionAccount) item).isEmpty())
870                 return false;
871         }
872
873         return true;
874     }
875
876     public static class FocusInfo {
877         int position;
878         FocusedElement element;
879         public FocusInfo(int position, FocusedElement element) {
880             this.position = position;
881             this.element = element;
882         }
883     }
884
885     static abstract class Item {
886         private static int idDispenser = 0;
887         protected int id;
888         private Item() {
889             if (this instanceof TransactionHead)
890                 id = 0;
891             else
892                 synchronized (Item.class) {
893                     id = ++idDispenser;
894                 }
895         }
896         public Item(int id) {
897             this.id = id;
898         }
899         public static Item from(Item origin) {
900             if (origin instanceof TransactionHead)
901                 return new TransactionHead((TransactionHead) origin);
902             if (origin instanceof TransactionAccount)
903                 return new TransactionAccount((TransactionAccount) origin);
904             throw new RuntimeException("Don't know how to handle " + origin);
905         }
906         private static void resetIdDispenser() {
907             idDispenser = 0;
908         }
909         public int getId() {
910             return id;
911         }
912         public abstract ItemType getType();
913         public TransactionHead toTransactionHead() {
914             if (this instanceof TransactionHead)
915                 return (TransactionHead) this;
916
917             throw new IllegalStateException("Wrong item type " + this);
918         }
919         public TransactionAccount toTransactionAccount() {
920             if (this instanceof TransactionAccount)
921                 return (TransactionAccount) this;
922
923             throw new IllegalStateException("Wrong item type " + this);
924         }
925         public boolean equalContents(@Nullable Object item) {
926             if (item == null)
927                 return false;
928
929             if (!getClass().equals(item.getClass()))
930                 return false;
931
932             // shortcut - comparing same instance
933             if (item == this)
934                 return true;
935
936             if (this instanceof TransactionHead)
937                 return ((TransactionHead) item).equalContents((TransactionHead) this);
938             if (this instanceof TransactionAccount)
939                 return ((TransactionAccount) item).equalContents((TransactionAccount) this);
940
941             throw new RuntimeException("Don't know how to handle " + this);
942         }
943     }
944
945
946 //==========================================================================================
947
948     public static class TransactionHead extends Item {
949         private SimpleDate date;
950         private String description;
951         private String comment;
952         TransactionHead(String description) {
953             super();
954             this.description = description;
955         }
956         public TransactionHead(TransactionHead origin) {
957             super(origin.id);
958             date = origin.date;
959             description = origin.description;
960             comment = origin.comment;
961         }
962         public SimpleDate getDate() {
963             return date;
964         }
965         public void setDate(SimpleDate date) {
966             this.date = date;
967         }
968         public void setDate(String text) throws ParseException {
969             if (Misc.emptyIsNull(text) == null) {
970                 date = null;
971                 return;
972             }
973
974             date = Globals.parseLedgerDate(text);
975         }
976         /**
977          * getFormattedDate()
978          *
979          * @return nicely formatted, shortest available date representation
980          */
981         String getFormattedDate() {
982             if (date == null)
983                 return null;
984
985             Calendar today = GregorianCalendar.getInstance();
986
987             if (today.get(Calendar.YEAR) != date.year) {
988                 return String.format(Locale.US, "%d/%02d/%02d", date.year, date.month, date.day);
989             }
990
991             if (today.get(Calendar.MONTH) + 1 != date.month) {
992                 return String.format(Locale.US, "%d/%02d", date.month, date.day);
993             }
994
995             return String.valueOf(date.day);
996         }
997         @NonNull
998         @Override
999         public String toString() {
1000             @SuppressLint("DefaultLocale") StringBuilder b = new StringBuilder(
1001                     String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
1002
1003             if (TextUtils.isEmpty(description))
1004                 b.append(" «no description»");
1005             else
1006                 b.append(String.format(" '%s'", description));
1007
1008             if (date != null)
1009                 b.append(String.format("@%s", date.toString()));
1010
1011             if (!TextUtils.isEmpty(comment))
1012                 b.append(String.format(" /%s/", comment));
1013
1014             return b.toString();
1015         }
1016         public String getDescription() {
1017             return description;
1018         }
1019         public void setDescription(String description) {
1020             this.description = description;
1021         }
1022         public String getComment() {
1023             return comment;
1024         }
1025         public void setComment(String comment) {
1026             this.comment = comment;
1027         }
1028         @Override
1029         public ItemType getType() {
1030             return ItemType.generalData;
1031         }
1032         public LedgerTransaction asLedgerTransaction() {
1033             return new LedgerTransaction(null, date, description, Data.getProfile());
1034         }
1035         public boolean equalContents(TransactionHead other) {
1036             if (other == null)
1037                 return false;
1038
1039             return Objects.equals(date, other.date) &&
1040                    Misc.equalStrings(description, other.description) &&
1041                    Misc.equalStrings(comment, other.comment);
1042         }
1043     }
1044
1045     public static class TransactionAccount extends Item {
1046         private String accountName;
1047         private String amountHint;
1048         private String comment;
1049         private String currency;
1050         private float amount;
1051         private boolean amountSet;
1052         private boolean amountValid = true;
1053         private FocusedElement focusedElement = FocusedElement.Account;
1054         private boolean amountHintIsSet = false;
1055         private boolean isLast = false;
1056         private int accountNameCursorPosition;
1057         public TransactionAccount(TransactionAccount origin) {
1058             super(origin.id);
1059             accountName = origin.accountName;
1060             amount = origin.amount;
1061             amountSet = origin.amountSet;
1062             amountHint = origin.amountHint;
1063             amountHintIsSet = origin.amountHintIsSet;
1064             comment = origin.comment;
1065             currency = origin.currency;
1066             amountValid = origin.amountValid;
1067             focusedElement = origin.focusedElement;
1068             isLast = origin.isLast;
1069             accountNameCursorPosition = origin.accountNameCursorPosition;
1070         }
1071         public TransactionAccount(LedgerTransactionAccount account) {
1072             super();
1073             currency = account.getCurrency();
1074             amount = account.getAmount();
1075         }
1076         public TransactionAccount(String accountName) {
1077             super();
1078             this.accountName = accountName;
1079         }
1080         public TransactionAccount(String accountName, String currency) {
1081             super();
1082             this.accountName = accountName;
1083             this.currency = currency;
1084         }
1085         public boolean isLast() {
1086             return isLast;
1087         }
1088         public boolean isAmountSet() {
1089             return amountSet;
1090         }
1091         public String getAccountName() {
1092             return accountName;
1093         }
1094         public void setAccountName(String accountName) {
1095             this.accountName = accountName;
1096         }
1097         public float getAmount() {
1098             if (!amountSet)
1099                 throw new IllegalStateException("Amount is not set");
1100             return amount;
1101         }
1102         public void setAmount(float amount) {
1103             this.amount = amount;
1104             amountSet = true;
1105         }
1106         public void resetAmount() {
1107             amountSet = false;
1108         }
1109         @Override
1110         public ItemType getType() {
1111             return ItemType.transactionRow;
1112         }
1113         public String getAmountHint() {
1114             return amountHint;
1115         }
1116         public void setAmountHint(String amountHint) {
1117             this.amountHint = amountHint;
1118             amountHintIsSet = !TextUtils.isEmpty(amountHint);
1119         }
1120         public String getComment() {
1121             return comment;
1122         }
1123         public void setComment(String comment) {
1124             this.comment = comment;
1125         }
1126         public String getCurrency() {
1127             return currency;
1128         }
1129         public void setCurrency(String currency) {
1130             this.currency = currency;
1131         }
1132         public boolean isAmountValid() {
1133             return amountValid;
1134         }
1135         public void setAmountValid(boolean amountValid) {
1136             this.amountValid = amountValid;
1137         }
1138         public FocusedElement getFocusedElement() {
1139             return focusedElement;
1140         }
1141         public void setFocusedElement(FocusedElement focusedElement) {
1142             this.focusedElement = focusedElement;
1143         }
1144         public boolean isAmountHintSet() {
1145             return amountHintIsSet;
1146         }
1147         public void setAmountHintIsSet(boolean amountHintIsSet) {
1148             this.amountHintIsSet = amountHintIsSet;
1149         }
1150         public boolean isEmpty() {
1151             return !amountSet && Misc.emptyIsNull(accountName) == null &&
1152                    Misc.emptyIsNull(comment) == null;
1153         }
1154         @SuppressLint("DefaultLocale")
1155         @Override
1156         public String toString() {
1157             StringBuilder b = new StringBuilder();
1158             b.append(String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
1159             if (!TextUtils.isEmpty(accountName))
1160                 b.append(String.format(" acc'%s'", accountName));
1161
1162             if (amountSet)
1163                 b.append(String.format(" %4.2f", amount));
1164             else if (amountHintIsSet)
1165                 b.append(String.format(" (%s)", amountHint));
1166
1167             if (!TextUtils.isEmpty(currency))
1168                 b.append(" ")
1169                  .append(currency);
1170
1171             if (!TextUtils.isEmpty(comment))
1172                 b.append(String.format(" /%s/", comment));
1173
1174             if (isLast)
1175                 b.append(" last");
1176
1177             return b.toString();
1178         }
1179         public boolean equalContents(TransactionAccount other) {
1180             if (other == null)
1181                 return false;
1182
1183             boolean equal = Misc.equalStrings(accountName, other.accountName);
1184             equal = equal && Misc.equalStrings(comment, other.comment) &&
1185                     (amountSet ? other.amountSet && amount == other.amount : !other.amountSet);
1186
1187             // compare amount hint only if there is no amount
1188             if (!amountSet)
1189                 equal = equal && (amountHintIsSet ? other.amountHintIsSet &&
1190                                                     Misc.equalStrings(amountHint, other.amountHint)
1191                                                   : !other.amountHintIsSet);
1192             equal = equal && Misc.equalStrings(currency, other.currency) && isLast == other.isLast;
1193
1194             Logger.debug("new-trans",
1195                     String.format("Comparing {%s} and {%s}: %s", this.toString(), other.toString(),
1196                             equal));
1197             return equal;
1198         }
1199         public int getAccountNameCursorPosition() {
1200             return accountNameCursorPosition;
1201         }
1202         public void setAccountNameCursorPosition(int position) {
1203             this.accountNameCursorPosition = position;
1204         }
1205     }
1206
1207     private static class BalanceForCurrency {
1208         private final HashMap<String, Float> hashMap = new HashMap<>();
1209         float get(String currencyName) {
1210             Float f = hashMap.get(currencyName);
1211             if (f == null) {
1212                 f = 0f;
1213                 hashMap.put(currencyName, f);
1214             }
1215             return f;
1216         }
1217         void add(String currencyName, float amount) {
1218             hashMap.put(currencyName, get(currencyName) + amount);
1219         }
1220         Set<String> currencies() {
1221             return hashMap.keySet();
1222         }
1223         boolean containsCurrency(String currencyName) {
1224             return hashMap.containsKey(currencyName);
1225         }
1226     }
1227
1228     private static class ItemsForCurrency {
1229         private final HashMap<String, List<Item>> hashMap = new HashMap<>();
1230         @NonNull
1231         List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
1232             List<NewTransactionModel.Item> list = hashMap.get(currencyName);
1233             if (list == null) {
1234                 list = new ArrayList<>();
1235                 hashMap.put(currencyName, list);
1236             }
1237             return list;
1238         }
1239         void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
1240             getList(currencyName).add(item);
1241         }
1242         int size(@Nullable String currencyName) {
1243             return this.getList(currencyName)
1244                        .size();
1245         }
1246         Set<String> currencies() {
1247             return hashMap.keySet();
1248         }
1249     }
1250 }