]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
NT: new rules for determining whether transaction can be submitted (not quite finished)
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / activity / NewTransactionModel.java
1 /*
2  * Copyright © 2019 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.activity;
19
20 import android.annotation.SuppressLint;
21
22 import androidx.annotation.NonNull;
23 import androidx.lifecycle.LiveData;
24 import androidx.lifecycle.MutableLiveData;
25 import androidx.lifecycle.Observer;
26 import androidx.lifecycle.ViewModel;
27
28 import net.ktnx.mobileledger.BuildConfig;
29 import net.ktnx.mobileledger.model.Currency;
30 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
31 import net.ktnx.mobileledger.utils.Logger;
32 import net.ktnx.mobileledger.utils.Misc;
33
34 import org.jetbrains.annotations.NotNull;
35
36 import java.util.ArrayList;
37 import java.util.Calendar;
38 import java.util.Collections;
39 import java.util.Date;
40 import java.util.GregorianCalendar;
41 import java.util.HashMap;
42 import java.util.List;
43 import java.util.Locale;
44 import java.util.Map;
45 import java.util.regex.Matcher;
46 import java.util.regex.Pattern;
47
48 import static net.ktnx.mobileledger.utils.Logger.debug;
49
50 public class NewTransactionModel extends ViewModel {
51     static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
52     static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
53     static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$");
54     final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
55     private final Item header = new Item(this, null, "");
56     private final Item trailer = new Item(this);
57     private final ArrayList<Item> items = new ArrayList<>();
58     private final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
59     private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
60     private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
61     private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
62     public boolean getSimulateSave() {
63         return simulateSave.getValue();
64     }
65     public void setSimulateSave(boolean simulateSave) {
66         this.simulateSave.setValue(simulateSave);
67     }
68     public void toggleSimulateSave() {
69         simulateSave.setValue(!simulateSave.getValue());
70     }
71     public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
72                                     @NonNull
73                                             androidx.lifecycle.Observer<? super Boolean> observer) {
74         this.simulateSave.observe(owner, observer);
75     }
76     public int getAccountCount() {
77         return items.size();
78     }
79     public Date getDate() {
80         return header.date.getValue();
81     }
82     public String getDescription() {
83         return header.description.getValue();
84     }
85     public LiveData<Boolean> isSubmittable() {
86         return this.isSubmittable;
87     }
88     void reset() {
89         header.date.setValue(null);
90         header.description.setValue(null);
91         items.clear();
92         items.add(new Item(this, new LedgerTransactionAccount("")));
93         items.add(new Item(this, new LedgerTransactionAccount("")));
94         focusedItem.setValue(0);
95     }
96     public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
97                                    @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
98         this.focusedItem.observe(owner, observer);
99     }
100     public void stopObservingFocusedItem(
101             @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
102         this.focusedItem.removeObserver(observer);
103     }
104     public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
105                                     @NonNull
106                                             androidx.lifecycle.Observer<? super Integer> observer) {
107         this.accountCount.observe(owner, observer);
108     }
109     public void stopObservingAccountCount(
110             @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
111         this.accountCount.removeObserver(observer);
112     }
113     public int getFocusedItem() { return focusedItem.getValue(); }
114     public void setFocusedItem(int position) {
115         focusedItem.setValue(position);
116     }
117     public int addAccount(LedgerTransactionAccount acc) {
118         items.add(new Item(this, acc));
119         accountCount.setValue(getAccountCount());
120         return items.size();
121     }
122     boolean accountsInInitialState() {
123         for (Item item : items) {
124             LedgerTransactionAccount acc = item.getAccount();
125             if (acc.isAmountSet())
126                 return false;
127             if (!acc.getAccountName()
128                     .trim()
129                     .isEmpty())
130                 return false;
131         }
132
133         return true;
134     }
135     LedgerTransactionAccount getAccount(int index) {
136         return items.get(index)
137                     .getAccount();
138     }
139     public Item getItem(int index) {
140         if (index == 0) {
141             return header;
142         }
143
144         if (index <= items.size())
145             return items.get(index - 1);
146
147         return trailer;
148     }
149     /*
150      A transaction is submittable if:
151      0) has description
152      1) has at least two account names
153      2) each row with amount has account name
154      3) for each commodity:
155      3a) amounts must balance to 0, or
156      3b) there must be exactly one empty amount (with account)
157      4) empty accounts with empty amounts are ignored
158      5) a row with an empty account name or empty amount is guaranteed to exist for each commodity
159      6) at least two rows need to be present in the ledger
160
161     */
162     @SuppressLint("DefaultLocale")
163     public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
164         int accounts = 0;
165         int amounts = 0;
166         int empty_rows = 0;
167         final Map<String, Float> balance = new HashMap<>();
168         final String descriptionText = getDescription();
169         boolean submittable = true;
170         final Map<String, List<Item>> itemsWithEmptyAmountForCurrency = new HashMap<>();
171         final Map<String, List<Item>> itemsWithAccountAndEmptyAmountForCurrency = new HashMap<>();
172
173         try {
174             if ((descriptionText == null) || descriptionText.trim()
175                                                             .isEmpty())
176             {
177                 Logger.debug("submittable", "Transaction not submittable: missing description");
178                 submittable = false;
179             }
180
181             for (int i = 0; i < this.items.size(); i++) {
182                 Item item = this.items.get(i);
183
184                 LedgerTransactionAccount acc = item.getAccount();
185                 String acc_name = acc.getAccountName()
186                                      .trim();
187                 String currName = acc.getCurrency();
188
189                 if (acc_name.isEmpty()) {
190                     empty_rows++;
191
192                     if (acc.isAmountSet()) {
193                         // 2) each amount has account name
194                         Logger.debug("submittable", String.format(
195                                 "Transaction not submittable: row %d has no account name, but has" +
196                                 " amount %1.2f", i + 1, acc.getAmount()));
197                         submittable = false;
198                     }
199                 }
200                 else {
201                     accounts++;
202                 }
203
204                 if (acc.isAmountSet()) {
205                     amounts++;
206                     Float bal = balance.get(currName);
207                     if (bal == null)
208                         bal = 0f;
209                     bal += acc.getAmount();
210                     balance.put(currName, bal);
211                 }
212                 else {
213                     {
214                         List<Item> list = itemsWithEmptyAmountForCurrency.get(currName);
215                         if (list == null)
216                             list = new ArrayList<>();
217                         itemsWithEmptyAmountForCurrency.put(currName, list);
218                         list.add(item);
219                     }
220
221                     if (!acc_name.isEmpty()) {
222                         List<Item> list =
223                                 itemsWithAccountAndEmptyAmountForCurrency.get(acc.getCurrency());
224                         if (list == null) {
225                             list = new ArrayList<>();
226                             itemsWithAccountAndEmptyAmountForCurrency.put(acc.getCurrency(), list);
227                         }
228                         list.add(item);
229                     }
230                 }
231             }
232
233             // 1) has at least two account names
234             if (accounts < 2) {
235                 if (accounts == 0)
236                     Logger.debug("submittable", "Transaction not submittable: no account names");
237                 else if (accounts == 1)
238                     Logger.debug("submittable",
239                             "Transaction not submittable: only one account name");
240                 else
241                     Logger.debug("submittable",
242                             String.format("Transaction not submittable: only %d account names",
243                                     accounts));
244                 submittable = false;
245             }
246
247             // 3) for each commodity:
248             // 3a) amount must balance to 0, or
249             // 3b) there must be exactly one empty amount (with account)
250             for (String balCurrency : balance.keySet()) {
251                 Float bal = balance.get(balCurrency);
252                 float currencyBalance = (bal == null) ? 0f : bal;
253                 if (Misc.isZero(currencyBalance)) {
254                     // remove hints from all amount inputs in that currency
255                     for (Item item : items) {
256                         final String itemCurrency = item.getCurrency()
257                                                         .getName();
258                         if (((balCurrency == null) && (null == itemCurrency)) ||
259                             ((balCurrency != null) && balCurrency.equals(itemCurrency)))
260                             item.setAmountHint(null);
261                     }
262                 }
263                 else {
264                     List<Item> list = itemsWithAccountAndEmptyAmountForCurrency.get(balCurrency);
265                     int balanceReceiversCount = (list == null) ? 0 : list.size();
266                     if (balanceReceiversCount != 1) {
267                         Logger.debug("submittable", (balanceReceiversCount == 0) ? String.format(
268                                 "Transaction not submittable [%s]: non-zero balance " +
269                                 "with no empty amounts with accounts", balCurrency) : String.format(
270                                 "Transaction not submittable [%s]: non-zero balance " +
271                                 "with multiple empty amounts with accounts", balCurrency));
272                         submittable = false;
273                     }
274
275                     List<Item> emptyAmountList = itemsWithEmptyAmountForCurrency.get(balCurrency);
276
277                     // suggest off-balance amount to a row and remove hints on other rows
278                     Item receiver = null;
279                     if ((list != null) && !list.isEmpty())
280                         receiver = list.get(0);
281                     else if ((emptyAmountList != null) && !emptyAmountList.isEmpty())
282                         receiver = emptyAmountList.get(0);
283
284                     for (Item item : items) {
285                         if (item.equals(receiver)) {
286                             Logger.debug("submittable",
287                                     String.format("Setting amount hint to %1.2f",
288                                             -currencyBalance));
289                             item.setAmountHint(String.format("%1.2f", -currencyBalance));
290                         }
291                         else
292                             item.setAmountHint(null);
293                     }
294                 }
295             }
296
297             // 5) a row with an empty account name or empty amount is guaranteed to exist for
298             // each commodity
299             for (String balCurrency : balance.keySet()) {
300                 if ((empty_rows == 0) &&
301                     ((this.items.size() == accounts) || (this.items.size() == amounts)))
302                 {
303                     adapter.addRow(balCurrency);
304                 }
305             }
306
307             // 6) at least two rows need to be present in the ledger
308             while (this.items.size() < 2)
309                 adapter.addRow();
310
311
312             debug("submittable", submittable ? "YES" : "NO");
313             isSubmittable.setValue(submittable);
314
315             if (BuildConfig.DEBUG) {
316                 debug("submittable", "== Dump of all items");
317                 for (int i = 0; i < items.size(); i++) {
318                     Item item = items.get(i);
319                     LedgerTransactionAccount acc = item.getAccount();
320                     debug("submittable", String.format("Item %2d: [%4.2f %s] %s // %s", i,
321                             acc.isAmountSet() ? acc.getAmount() : 0, acc.getCurrency(),
322                             acc.getAccountName(), acc.getComment()));
323                 }
324             }
325         }
326         catch (NumberFormatException e) {
327             debug("submittable", "NO (because of NumberFormatException)");
328             isSubmittable.setValue(false);
329         }
330         catch (Exception e) {
331             e.printStackTrace();
332             debug("submittable", "NO (because of an Exception)");
333             isSubmittable.setValue(false);
334         }
335     }
336     public void removeItem(int pos) {
337         items.remove(pos);
338         accountCount.setValue(getAccountCount());
339     }
340     public void sendCountNotifications() {
341         accountCount.setValue(getAccountCount());
342     }
343     public void sendFocusedNotification() {
344         focusedItem.setValue(focusedItem.getValue());
345     }
346     public void updateFocusedItem(int position) {
347         focusedItem.setValue(position);
348     }
349     public void noteFocusChanged(int position, FocusedElement element) {
350         getItem(position).setFocusedElement(element);
351     }
352     public void swapItems(int one, int two) {
353         Collections.swap(items, one - 1, two - 1);
354     }
355     public void toggleComment(int position) {
356         final MutableLiveData<Boolean> commentVisible = getItem(position).commentVisible;
357         commentVisible.postValue(!commentVisible.getValue());
358     }
359     public void moveItemLast(int index) {
360         /*   0
361              1   <-- index
362              2
363              3   <-- desired position
364          */
365         int itemCount = items.size();
366
367         if (index < itemCount - 1) {
368             Item acc = items.remove(index);
369             items.add(itemCount - 1, acc);
370         }
371     }
372     public void toggleCurrencyVisible() {
373         showCurrency.setValue(!showCurrency.getValue());
374     }
375     enum ItemType {generalData, transactionRow, bottomFiller}
376
377     //==========================================================================================
378
379     enum FocusedElement {Account, Comment, Amount}
380
381     class Item {
382         private ItemType type;
383         private MutableLiveData<Date> date = new MutableLiveData<>();
384         private MutableLiveData<String> description = new MutableLiveData<>();
385         private LedgerTransactionAccount account;
386         private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
387         private NewTransactionModel model;
388         private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
389         private FocusedElement focusedElement = FocusedElement.Account;
390         private MutableLiveData<String> comment = new MutableLiveData<>(null);
391         private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
392         private MutableLiveData<Currency> currency = new MutableLiveData<>(null);
393         public Item(NewTransactionModel model) {
394             this.model = model;
395             type = ItemType.bottomFiller;
396             editable.setValue(false);
397         }
398         public Item(NewTransactionModel model, Date date, String description) {
399             this.model = model;
400             this.type = ItemType.generalData;
401             this.date.setValue(date);
402             this.description.setValue(description);
403             this.editable.setValue(true);
404         }
405         public Item(NewTransactionModel model, LedgerTransactionAccount account) {
406             this.model = model;
407             this.type = ItemType.transactionRow;
408             this.account = account;
409             this.editable.setValue(true);
410         }
411         public FocusedElement getFocusedElement() {
412             return focusedElement;
413         }
414         public void setFocusedElement(FocusedElement focusedElement) {
415             this.focusedElement = focusedElement;
416         }
417         public NewTransactionModel getModel() {
418             return model;
419         }
420         public void setEditable(boolean editable) {
421             ensureType(ItemType.generalData, ItemType.transactionRow);
422             this.editable.setValue(editable);
423         }
424         private void ensureType(ItemType type1, ItemType type2) {
425             if ((type != type1) && (type != type2)) {
426                 throw new RuntimeException(
427                         String.format("Actual type (%s) differs from wanted (%s or %s)", type,
428                                 type1, type2));
429             }
430         }
431         public String getAmountHint() {
432             ensureType(ItemType.transactionRow);
433             return amountHint.getValue();
434         }
435         public void setAmountHint(String amountHint) {
436             ensureType(ItemType.transactionRow);
437
438             // avoid unnecessary triggers
439             if (amountHint == null) {
440                 if (this.amountHint.getValue() == null)
441                     return;
442             }
443             else {
444                 if (amountHint.equals(this.amountHint.getValue()))
445                     return;
446             }
447
448             this.amountHint.setValue(amountHint);
449         }
450         public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
451                                       @NonNull
452                                               androidx.lifecycle.Observer<? super String> observer) {
453             this.amountHint.observe(owner, observer);
454         }
455         public void stopObservingAmountHint(
456                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
457             this.amountHint.removeObserver(observer);
458         }
459         public ItemType getType() {
460             return type;
461         }
462         public void ensureType(ItemType wantedType) {
463             if (type != wantedType) {
464                 throw new RuntimeException(
465                         String.format("Actual type (%s) differs from wanted (%s)", type,
466                                 wantedType));
467             }
468         }
469         public Date getDate() {
470             ensureType(ItemType.generalData);
471             return date.getValue();
472         }
473         public void setDate(Date date) {
474             ensureType(ItemType.generalData);
475             this.date.setValue(date);
476         }
477         public void setDate(String text) {
478             if ((text == null) || text.trim()
479                                       .isEmpty())
480             {
481                 setDate((Date) null);
482                 return;
483             }
484
485             int year, month, day;
486             final Calendar c = GregorianCalendar.getInstance();
487             Matcher m = reYMD.matcher(text);
488             if (m.matches()) {
489                 year = Integer.parseInt(m.group(1));
490                 month = Integer.parseInt(m.group(2)) - 1;   // month is 0-based
491                 day = Integer.parseInt(m.group(3));
492             }
493             else {
494                 year = c.get(Calendar.YEAR);
495                 m = reMD.matcher(text);
496                 if (m.matches()) {
497                     month = Integer.parseInt(m.group(1)) - 1;
498                     day = Integer.parseInt(m.group(2));
499                 }
500                 else {
501                     month = c.get(Calendar.MONTH);
502                     m = reD.matcher(text);
503                     if (m.matches()) {
504                         day = Integer.parseInt(m.group(1));
505                     }
506                     else {
507                         day = c.get(Calendar.DAY_OF_MONTH);
508                     }
509                 }
510             }
511
512             c.set(year, month, day);
513
514             this.setDate(c.getTime());
515         }
516         public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
517                                 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
518             this.date.observe(owner, observer);
519         }
520         public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
521             this.date.removeObserver(observer);
522         }
523         public String getDescription() {
524             ensureType(ItemType.generalData);
525             return description.getValue();
526         }
527         public void setDescription(String description) {
528             ensureType(ItemType.generalData);
529             this.description.setValue(description);
530         }
531         public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
532                                        @NonNull
533                                                androidx.lifecycle.Observer<? super String> observer) {
534             this.description.observe(owner, observer);
535         }
536         public void stopObservingDescription(
537                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
538             this.description.removeObserver(observer);
539         }
540         public LedgerTransactionAccount getAccount() {
541             ensureType(ItemType.transactionRow);
542             return account;
543         }
544         public void setAccountName(String name) {
545             account.setAccountName(name);
546         }
547         /**
548          * getFormattedDate()
549          *
550          * @return nicely formatted, shortest available date representation
551          */
552         public String getFormattedDate() {
553             if (date == null)
554                 return null;
555             Date time = date.getValue();
556             if (time == null)
557                 return null;
558
559             Calendar c = GregorianCalendar.getInstance();
560             c.setTime(time);
561             Calendar today = GregorianCalendar.getInstance();
562
563             final int myYear = c.get(Calendar.YEAR);
564             final int myMonth = c.get(Calendar.MONTH);
565             final int myDay = c.get(Calendar.DAY_OF_MONTH);
566
567             if (today.get(Calendar.YEAR) != myYear) {
568                 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
569             }
570
571             if (today.get(Calendar.MONTH) != myMonth) {
572                 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
573             }
574
575             return String.valueOf(myDay);
576         }
577         public void observeEditableFlag(NewTransactionActivity activity,
578                                         Observer<Boolean> observer) {
579             editable.observe(activity, observer);
580         }
581         public void stopObservingEditableFlag(Observer<Boolean> observer) {
582             editable.removeObserver(observer);
583         }
584         public void observeCommentVisible(NewTransactionActivity activity,
585                                           Observer<Boolean> observer) {
586             commentVisible.observe(activity, observer);
587         }
588         public void stopObservingCommentVisible(Observer<Boolean> observer) {
589             commentVisible.removeObserver(observer);
590         }
591         public void observeComment(NewTransactionActivity activity, Observer<String> observer) {
592             comment.observe(activity, observer);
593         }
594         public void stopObservingComment(Observer<String> observer) {
595             comment.removeObserver(observer);
596         }
597         public void setComment(String comment) {
598             getAccount().setComment(comment);
599             this.comment.postValue(comment);
600         }
601         public Currency getCurrency() {
602             return this.currency.getValue();
603         }
604         public void setCurrency(Currency currency) {
605             getAccount().setCurrency((currency != null && !currency.getName()
606                                                                    .isEmpty()) ? currency.getName()
607                                                                                : null);
608             this.currency.setValue(currency);
609         }
610         public void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
611             currency.observe(activity, observer);
612         }
613         public void stopObservingCurrency(Observer<Currency> observer) {
614             currency.removeObserver(observer);
615         }
616     }
617 }