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