]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java
flag initial amount hint as present (still null)
[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                             // skip if the amount is set, in which case the hint is not
616                             // important/visible
617                             if (!acc.isAmountSet() && acc.amountHintIsSet &&
618                                 !TextUtils.isEmpty(acc.getAmountHint()))
619                             {
620                                 if (workingWithLiveList && !liveListCopied) {
621                                     list = copyList(list);
622                                     liveListCopied = true;
623                                 }
624                                 final TransactionAccount newAcc = new TransactionAccount(acc);
625                                 newAcc.setAmountHint(null);
626                                 if (!liveListCopied) {
627                                     list = copyList(list);
628                                     liveListCopied = true;
629                                 }
630                                 list.set(i, newAcc);
631                                 listChanged = true;
632                             }
633                         }
634                     }
635                 }
636                 else {
637                     List<Item> tmpList =
638                             itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
639                     int balanceReceiversCount = tmpList.size();
640                     if (balanceReceiversCount != 1) {
641                         if (BuildConfig.DEBUG) {
642                             if (balanceReceiversCount == 0)
643                                 Logger.debug("submittable", String.format(
644                                         "Transaction not submittable [%s]: non-zero balance " +
645                                         "with no empty amounts with accounts", balCurrency));
646                             else
647                                 Logger.debug("submittable", String.format(
648                                         "Transaction not submittable [%s]: non-zero balance " +
649                                         "with multiple empty amounts with accounts", balCurrency));
650                         }
651                         submittable = false;
652                     }
653
654                     List<Item> emptyAmountList =
655                             itemsWithEmptyAmountForCurrency.getList(balCurrency);
656
657                     // suggest off-balance amount to a row and remove hints on other rows
658                     Item receiver = null;
659                     if (!tmpList.isEmpty())
660                         receiver = tmpList.get(0);
661                     else if (!emptyAmountList.isEmpty())
662                         receiver = emptyAmountList.get(0);
663
664                     for (int i = 0; i < list.size(); i++) {
665                         Item item = list.get(i);
666                         if (!(item instanceof TransactionAccount))
667                             continue;
668
669                         TransactionAccount acc = item.toTransactionAccount();
670                         if (!Misc.equalStrings(acc.getCurrency(), balCurrency))
671                             continue;
672
673                         if (item == receiver) {
674                             final String hint = String.format("%1.2f", -currencyBalance);
675                             if (!acc.isAmountHintSet() ||
676                                 !TextUtils.equals(acc.getAmountHint(), hint))
677                             {
678                                 Logger.debug("submittable",
679                                         String.format("Setting amount hint of {%s} to %s [%s]",
680                                                 acc.toString(), hint, balCurrency));
681                                 if (workingWithLiveList & !liveListCopied) {
682                                     list = copyList(list);
683                                     liveListCopied = true;
684                                 }
685                                 final TransactionAccount newAcc = new TransactionAccount(acc);
686                                 newAcc.setAmountHint(hint);
687                                 list.set(i, newAcc);
688                                 listChanged = true;
689                             }
690                         }
691                         else {
692                             if (BuildConfig.DEBUG)
693                                 Logger.debug("submittable",
694                                         String.format("Resetting hint of '%s' [%s]",
695                                                 Misc.nullIsEmpty(acc.getAccountName()),
696                                                 balCurrency));
697                             if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) {
698                                 if (workingWithLiveList && !liveListCopied) {
699                                     list = copyList(list);
700                                     liveListCopied = true;
701                                 }
702                                 final TransactionAccount newAcc = new TransactionAccount(acc);
703                                 newAcc.setAmountHint(null);
704                                 list.set(i, newAcc);
705                                 listChanged = true;
706                             }
707                         }
708                     }
709                 }
710             }
711
712             // 5) a row with an empty account name or empty amount is guaranteed to exist for
713             // each commodity
714             for (String balCurrency : balance.currencies()) {
715                 int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
716                 int currRows = itemsForCurrency.size(balCurrency);
717                 int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
718                 int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
719                 if ((currEmptyRows == 0) &&
720                     ((currRows == currAccounts) || (currRows == currAmounts)))
721                 {
722                     // perhaps there already is an unused empty row for another currency that
723                     // is not used?
724 //                        boolean foundIt = false;
725 //                        for (Item item : emptyRows) {
726 //                            Currency itemCurrency = item.getCurrency();
727 //                            String itemCurrencyName =
728 //                                    (itemCurrency == null) ? "" : itemCurrency.getName();
729 //                            if (Misc.isZero(balance.get(itemCurrencyName))) {
730 //                                item.setCurrency(Currency.loadByName(balCurrency));
731 //                                item.setAmountHint(
732 //                                        String.format("%1.2f", -balance.get(balCurrency)));
733 //                                foundIt = true;
734 //                                break;
735 //                            }
736 //                        }
737 //
738 //                        if (!foundIt)
739                     if (workingWithLiveList && !liveListCopied) {
740                         list = copyList(list);
741                         liveListCopied = true;
742                     }
743                     final TransactionAccount newAcc = new TransactionAccount("", balCurrency);
744                     final float bal = balance.get(balCurrency);
745                     if (!Misc.isZero(bal) && currAmounts == currRows)
746                         newAcc.setAmountHint(String.format("%4.2f", -bal));
747                     Logger.debug("submittable",
748                             String.format("Adding new item with %s for currency %s",
749                                     newAcc.getAmountHint(), balCurrency));
750                     list.add(newAcc);
751                     listChanged = true;
752                 }
753             }
754
755             // drop extra empty rows, not needed
756             for (String currName : emptyRowsForCurrency.currencies()) {
757                 List<Item> emptyItems = emptyRowsForCurrency.getList(currName);
758                 while ((list.size() > MIN_ITEMS) && (emptyItems.size() > 1)) {
759                     if (workingWithLiveList && !liveListCopied) {
760                         list = copyList(list);
761                         liveListCopied = true;
762                     }
763                     // the list is a copy, so the empty item is no longer present
764                     Item itemToRemove = emptyItems.remove(1);
765                     removeItemById(list, itemToRemove.id);
766                     listChanged = true;
767                 }
768
769                 // unused currency, remove last item (which is also an empty one)
770                 if ((list.size() > MIN_ITEMS) && (emptyItems.size() == 1)) {
771                     List<Item> currItems = itemsForCurrency.getList(currName);
772
773                     if (currItems.size() == 1) {
774                         if (workingWithLiveList && !liveListCopied) {
775                             list = copyList(list);
776                             liveListCopied = true;
777                         }
778                         // the list is a copy, so the empty item is no longer present
779                         removeItemById(list, emptyItems.get(0).id);
780                         listChanged = true;
781                     }
782                 }
783             }
784
785             // 6) at least two rows need to be present in the ledger
786             //    (the list also contains header and trailer)
787             while (list.size() < MIN_ITEMS) {
788                 if (workingWithLiveList && !liveListCopied) {
789                     list = copyList(list);
790                     liveListCopied = true;
791                 }
792                 list.add(new TransactionAccount(""));
793                 listChanged = true;
794             }
795
796
797             Logger.debug("submittable", submittable ? "YES" : "NO");
798             isSubmittable.setValue(submittable);
799
800             if (BuildConfig.DEBUG)
801                 dumpItemList("After submittable checks", list);
802         }
803         catch (NumberFormatException e) {
804             Logger.debug("submittable", "NO (because of NumberFormatException)");
805             isSubmittable.setValue(false);
806         }
807         catch (Exception e) {
808             e.printStackTrace();
809             Logger.debug("submittable", "NO (because of an Exception)");
810             isSubmittable.setValue(false);
811         }
812
813         if (listChanged && workingWithLiveList) {
814             setItemsWithoutSubmittableChecks(list);
815         }
816     }
817     private void removeItemById(@NotNull List<Item> list, int id) {
818         if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
819             list.removeIf(item -> item.id == id);
820         }
821         else {
822             for (Item item : list) {
823                 if (item.id == id) {
824                     list.remove(item);
825                     break;
826                 }
827             }
828         }
829     }
830     @SuppressLint("DefaultLocale")
831     private void dumpItemList(@NotNull String msg, @NotNull List<Item> list) {
832         Logger.debug("submittable", "== Dump of all items " + msg);
833         for (int i = 1; i < list.size(); i++) {
834             TransactionAccount item = list.get(i)
835                                           .toTransactionAccount();
836             Logger.debug("submittable", String.format("%d:%s", i, item.toString()));
837         }
838     }
839     public void setItemCurrency(int position, String newCurrency) {
840         TransactionAccount item = Objects.requireNonNull(items.getValue())
841                                          .get(position)
842                                          .toTransactionAccount();
843         final String oldCurrency = item.getCurrency();
844
845         if (Misc.equalStrings(oldCurrency, newCurrency))
846             return;
847
848         List<Item> newList = copyList();
849         newList.get(position)
850                .toTransactionAccount()
851                .setCurrency(newCurrency);
852
853         setItems(newList);
854     }
855     public boolean accountListIsEmpty() {
856         List<Item> items = Objects.requireNonNull(this.items.getValue());
857
858         for (Item item : items) {
859             if (!(item instanceof TransactionAccount))
860                 continue;
861
862             if (!((TransactionAccount) item).isEmpty())
863                 return false;
864         }
865
866         return true;
867     }
868
869     public static class FocusInfo {
870         int position;
871         FocusedElement element;
872         public FocusInfo(int position, FocusedElement element) {
873             this.position = position;
874             this.element = element;
875         }
876     }
877
878     static abstract class Item {
879         private static int idDispenser = 0;
880         protected int id;
881         private Item() {
882             synchronized (Item.class) {
883                 id = ++idDispenser;
884             }
885         }
886         public static Item from(Item origin) {
887             if (origin instanceof TransactionHead)
888                 return new TransactionHead((TransactionHead) origin);
889             if (origin instanceof TransactionAccount)
890                 return new TransactionAccount((TransactionAccount) origin);
891             throw new RuntimeException("Don't know how to handle " + origin);
892         }
893         public int getId() {
894             return id;
895         }
896         public abstract ItemType getType();
897         public TransactionHead toTransactionHead() {
898             if (this instanceof TransactionHead)
899                 return (TransactionHead) this;
900
901             throw new IllegalStateException("Wrong item type " + this);
902         }
903         public TransactionAccount toTransactionAccount() {
904             if (this instanceof TransactionAccount)
905                 return (TransactionAccount) this;
906
907             throw new IllegalStateException("Wrong item type " + this);
908         }
909         public boolean equalContents(@Nullable Object item) {
910             if (item == null)
911                 return false;
912
913             if (!getClass().equals(item.getClass()))
914                 return false;
915
916             // shortcut - comparing same instance
917             if (item == this)
918                 return true;
919
920             if (this instanceof TransactionHead)
921                 return ((TransactionHead) item).equalContents((TransactionHead) this);
922             if (this instanceof TransactionAccount)
923                 return ((TransactionAccount) item).equalContents((TransactionAccount) this);
924
925             throw new RuntimeException("Don't know how to handle " + this);
926         }
927     }
928
929
930 //==========================================================================================
931
932     public static class TransactionHead extends Item {
933         private SimpleDate date;
934         private String description;
935         private String comment;
936         TransactionHead(String description) {
937             super();
938             this.description = description;
939         }
940         public TransactionHead(TransactionHead origin) {
941             id = origin.id;
942             date = origin.date;
943             description = origin.description;
944             comment = origin.comment;
945         }
946         public SimpleDate getDate() {
947             return date;
948         }
949         public void setDate(SimpleDate date) {
950             this.date = date;
951         }
952         public void setDate(String text) throws ParseException {
953             if (Misc.emptyIsNull(text) == null) {
954                 date = null;
955                 return;
956             }
957
958             date = Globals.parseLedgerDate(text);
959         }
960         /**
961          * getFormattedDate()
962          *
963          * @return nicely formatted, shortest available date representation
964          */
965         String getFormattedDate() {
966             if (date == null)
967                 return null;
968
969             Calendar today = GregorianCalendar.getInstance();
970
971             if (today.get(Calendar.YEAR) != date.year) {
972                 return String.format(Locale.US, "%d/%02d/%02d", date.year, date.month, date.day);
973             }
974
975             if (today.get(Calendar.MONTH) + 1 != date.month) {
976                 return String.format(Locale.US, "%d/%02d", date.month, date.day);
977             }
978
979             return String.valueOf(date.day);
980         }
981         @NonNull
982         @Override
983         public String toString() {
984             @SuppressLint("DefaultLocale") StringBuilder b = new StringBuilder(
985                     String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
986
987             if (TextUtils.isEmpty(description))
988                 b.append(" «no description»");
989             else
990                 b.append(String.format(" descr'%s'", description));
991
992             if (date != null)
993                 b.append(String.format("@%s", date.toString()));
994
995             if (!TextUtils.isEmpty(comment))
996                 b.append(String.format(" /%s/", comment));
997
998             return b.toString();
999         }
1000         public String getDescription() {
1001             return description;
1002         }
1003         public void setDescription(String description) {
1004             this.description = description;
1005         }
1006         public String getComment() {
1007             return comment;
1008         }
1009         public void setComment(String comment) {
1010             this.comment = comment;
1011         }
1012         @Override
1013         public ItemType getType() {
1014             return ItemType.generalData;
1015         }
1016         public LedgerTransaction asLedgerTransaction() {
1017             return new LedgerTransaction(null, date, description, Data.getProfile());
1018         }
1019         public boolean equalContents(TransactionHead other) {
1020             if (other == null)
1021                 return false;
1022
1023             return Objects.equals(date, other.date) &&
1024                    TextUtils.equals(description, other.description) &&
1025                    TextUtils.equals(comment, other.comment);
1026         }
1027     }
1028
1029     public static class TransactionAccount extends Item {
1030         private String accountName;
1031         private String amountHint;
1032         private String comment;
1033         private String currency;
1034         private float amount;
1035         private boolean amountSet;
1036         private boolean amountValid = true;
1037         private FocusedElement focusedElement = FocusedElement.Account;
1038         private boolean amountHintIsSet = true;
1039         private boolean isLast = false;
1040         public TransactionAccount(TransactionAccount origin) {
1041             id = origin.id;
1042             accountName = origin.accountName;
1043             amount = origin.amount;
1044             amountSet = origin.amountSet;
1045             amountHint = origin.amountHint;
1046             amountHintIsSet = origin.amountHintIsSet;
1047             comment = origin.comment;
1048             currency = origin.currency;
1049             amountValid = origin.amountValid;
1050             focusedElement = origin.focusedElement;
1051             isLast = origin.isLast;
1052         }
1053         public TransactionAccount(LedgerTransactionAccount account) {
1054             super();
1055             currency = account.getCurrency();
1056             amount = account.getAmount();
1057         }
1058         public TransactionAccount(String accountName) {
1059             super();
1060             this.accountName = accountName;
1061         }
1062         public TransactionAccount(String accountName, String currency) {
1063             super();
1064             this.accountName = accountName;
1065             this.currency = currency;
1066         }
1067         public boolean isLast() {
1068             return isLast;
1069         }
1070         public boolean isAmountSet() {
1071             return amountSet;
1072         }
1073         public String getAccountName() {
1074             return accountName;
1075         }
1076         public void setAccountName(String accountName) {
1077             this.accountName = accountName;
1078         }
1079         public float getAmount() {
1080             if (!amountSet)
1081                 throw new IllegalStateException("Amount is not set");
1082             return amount;
1083         }
1084         public void setAmount(float amount) {
1085             this.amount = amount;
1086             amountSet = true;
1087         }
1088         public void resetAmount() {
1089             amountSet = false;
1090         }
1091         @Override
1092         public ItemType getType() {
1093             return ItemType.transactionRow;
1094         }
1095         public String getAmountHint() {
1096             return amountHint;
1097         }
1098         public void setAmountHint(String amountHint) {
1099             this.amountHint = amountHint;
1100             amountHintIsSet = !TextUtils.isEmpty(amountHint);
1101         }
1102         public String getComment() {
1103             return comment;
1104         }
1105         public void setComment(String comment) {
1106             this.comment = comment;
1107         }
1108         public String getCurrency() {
1109             return currency;
1110         }
1111         public void setCurrency(String currency) {
1112             this.currency = currency;
1113         }
1114         public boolean isAmountValid() {
1115             return amountValid;
1116         }
1117         public void setAmountValid(boolean amountValid) {
1118             this.amountValid = amountValid;
1119         }
1120         public FocusedElement getFocusedElement() {
1121             return focusedElement;
1122         }
1123         public void setFocusedElement(FocusedElement focusedElement) {
1124             this.focusedElement = focusedElement;
1125         }
1126         public boolean isAmountHintSet() {
1127             return amountHintIsSet;
1128         }
1129         public void setAmountHintIsSet(boolean amountHintIsSet) {
1130             this.amountHintIsSet = amountHintIsSet;
1131         }
1132         public boolean isEmpty() {
1133             return !amountSet && Misc.emptyIsNull(accountName) == null &&
1134                    Misc.emptyIsNull(comment) == null;
1135         }
1136         @SuppressLint("DefaultLocale")
1137         @Override
1138         public String toString() {
1139             StringBuilder b = new StringBuilder();
1140             b.append(String.format("id:%d/%s", id, Integer.toHexString(hashCode())));
1141             if (!TextUtils.isEmpty(accountName))
1142                 b.append(String.format(" acc'%s'", accountName));
1143
1144             if (amountSet)
1145                 b.append(String.format(" %4.2f", amount));
1146             else if (amountHintIsSet)
1147                 b.append(String.format(" (%s)", amountHint));
1148
1149             if (!TextUtils.isEmpty(currency))
1150                 b.append(" ")
1151                  .append(currency);
1152
1153             if (!TextUtils.isEmpty(comment))
1154                 b.append(String.format(" /%s/", comment));
1155
1156             return b.toString();
1157         }
1158         public boolean equalContents(TransactionAccount other) {
1159             if (other == null)
1160                 return false;
1161
1162             boolean equal = TextUtils.equals(accountName, other.accountName);
1163             equal = equal && TextUtils.equals(comment, other.comment) &&
1164                     (amountSet ? other.amountSet && amount == other.amount : !other.amountSet);
1165
1166             // compare amount hint only if there is no amount
1167             if (!amountSet)
1168                 equal = equal && (amountHintIsSet ? other.amountHintIsSet &&
1169                                                     TextUtils.equals(amountHint, other.amountHint)
1170                                                   : !other.amountHintIsSet);
1171             equal = equal && TextUtils.equals(currency, other.currency) && isLast == other.isLast;
1172
1173             Logger.debug("new-trans",
1174                     String.format("Comparing {%s} and {%s}: %s", this.toString(), other.toString(),
1175                             equal));
1176             return equal;
1177         }
1178     }
1179
1180     private static class BalanceForCurrency {
1181         private final HashMap<String, Float> hashMap = new HashMap<>();
1182         float get(String currencyName) {
1183             Float f = hashMap.get(currencyName);
1184             if (f == null) {
1185                 f = 0f;
1186                 hashMap.put(currencyName, f);
1187             }
1188             return f;
1189         }
1190         void add(String currencyName, float amount) {
1191             hashMap.put(currencyName, get(currencyName) + amount);
1192         }
1193         Set<String> currencies() {
1194             return hashMap.keySet();
1195         }
1196         boolean containsCurrency(String currencyName) {
1197             return hashMap.containsKey(currencyName);
1198         }
1199     }
1200
1201     private static class ItemsForCurrency {
1202         private final HashMap<String, List<Item>> hashMap = new HashMap<>();
1203         @NonNull
1204         List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
1205             List<NewTransactionModel.Item> list = hashMap.get(currencyName);
1206             if (list == null) {
1207                 list = new ArrayList<>();
1208                 hashMap.put(currencyName, list);
1209             }
1210             return list;
1211         }
1212         void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
1213             getList(currencyName).add(item);
1214         }
1215         int size(@Nullable String currencyName) {
1216             return this.getList(currencyName)
1217                        .size();
1218         }
1219         Set<String> currencies() {
1220             return hashMap.keySet();
1221         }
1222     }
1223 }