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