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