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