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