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