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