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