]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
fully functional comments UI
[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     enum ItemType {generalData, transactionRow, bottomFiller}
308
309     //==========================================================================================
310
311     enum FocusedElement {Account, Comment, Amount}
312
313     class Item {
314         private ItemType type;
315         private MutableLiveData<Date> date = new MutableLiveData<>();
316         private MutableLiveData<String> description = new MutableLiveData<>();
317         private LedgerTransactionAccount account;
318         private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
319         private NewTransactionModel model;
320         private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
321         private FocusedElement focusedElement = FocusedElement.Account;
322         private MutableLiveData<String> comment = new MutableLiveData<>(null);
323         private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
324         public Item(NewTransactionModel model) {
325             this.model = model;
326             type = ItemType.bottomFiller;
327             editable.setValue(false);
328         }
329         public Item(NewTransactionModel model, Date date, String description) {
330             this.model = model;
331             this.type = ItemType.generalData;
332             this.date.setValue(date);
333             this.description.setValue(description);
334             this.editable.setValue(true);
335         }
336         public Item(NewTransactionModel model, LedgerTransactionAccount account) {
337             this.model = model;
338             this.type = ItemType.transactionRow;
339             this.account = account;
340             this.editable.setValue(true);
341         }
342         public FocusedElement getFocusedElement() {
343             return focusedElement;
344         }
345         public void setFocusedElement(FocusedElement focusedElement) {
346             this.focusedElement = focusedElement;
347         }
348         public NewTransactionModel getModel() {
349             return model;
350         }
351         public void setEditable(boolean editable) {
352             ensureType(ItemType.generalData, ItemType.transactionRow);
353             this.editable.setValue(editable);
354         }
355         private void ensureType(ItemType type1, ItemType type2) {
356             if ((type != type1) && (type != type2)) {
357                 throw new RuntimeException(
358                         String.format("Actual type (%s) differs from wanted (%s or %s)", type,
359                                 type1, type2));
360             }
361         }
362         public String getAmountHint() {
363             ensureType(ItemType.transactionRow);
364             return amountHint.getValue();
365         }
366         public void setAmountHint(String amountHint) {
367             ensureType(ItemType.transactionRow);
368
369             // avoid unnecessary triggers
370             if (amountHint == null) {
371                 if (this.amountHint.getValue() == null)
372                     return;
373             }
374             else {
375                 if (amountHint.equals(this.amountHint.getValue()))
376                     return;
377             }
378
379             this.amountHint.setValue(amountHint);
380         }
381         public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
382                                       @NonNull
383                                               androidx.lifecycle.Observer<? super String> observer) {
384             this.amountHint.observe(owner, observer);
385         }
386         public void stopObservingAmountHint(
387                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
388             this.amountHint.removeObserver(observer);
389         }
390         public ItemType getType() {
391             return type;
392         }
393         public void ensureType(ItemType wantedType) {
394             if (type != wantedType) {
395                 throw new RuntimeException(
396                         String.format("Actual type (%s) differs from wanted (%s)", type,
397                                 wantedType));
398             }
399         }
400         public Date getDate() {
401             ensureType(ItemType.generalData);
402             return date.getValue();
403         }
404         public void setDate(Date date) {
405             ensureType(ItemType.generalData);
406             this.date.setValue(date);
407         }
408         public void setDate(String text) {
409             if ((text == null) || text.trim()
410                                       .isEmpty())
411             {
412                 setDate((Date) null);
413                 return;
414             }
415
416             int year, month, day;
417             final Calendar c = GregorianCalendar.getInstance();
418             Matcher m = reYMD.matcher(text);
419             if (m.matches()) {
420                 year = Integer.parseInt(m.group(1));
421                 month = Integer.parseInt(m.group(2)) - 1;   // month is 0-based
422                 day = Integer.parseInt(m.group(3));
423             }
424             else {
425                 year = c.get(Calendar.YEAR);
426                 m = reMD.matcher(text);
427                 if (m.matches()) {
428                     month = Integer.parseInt(m.group(1)) - 1;
429                     day = Integer.parseInt(m.group(2));
430                 }
431                 else {
432                     month = c.get(Calendar.MONTH);
433                     m = reD.matcher(text);
434                     if (m.matches()) {
435                         day = Integer.parseInt(m.group(1));
436                     }
437                     else {
438                         day = c.get(Calendar.DAY_OF_MONTH);
439                     }
440                 }
441             }
442
443             c.set(year, month, day);
444
445             this.setDate(c.getTime());
446         }
447         public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
448                                 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
449             this.date.observe(owner, observer);
450         }
451         public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
452             this.date.removeObserver(observer);
453         }
454         public String getDescription() {
455             ensureType(ItemType.generalData);
456             return description.getValue();
457         }
458         public void setDescription(String description) {
459             ensureType(ItemType.generalData);
460             this.description.setValue(description);
461         }
462         public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
463                                        @NonNull
464                                                androidx.lifecycle.Observer<? super String> observer) {
465             this.description.observe(owner, observer);
466         }
467         public void stopObservingDescription(
468                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
469             this.description.removeObserver(observer);
470         }
471         public LedgerTransactionAccount getAccount() {
472             ensureType(ItemType.transactionRow);
473             return account;
474         }
475         public void setAccountName(String name) {
476             account.setAccountName(name);
477         }
478         /**
479          * getFormattedDate()
480          *
481          * @return nicely formatted, shortest available date representation
482          */
483         public String getFormattedDate() {
484             if (date == null)
485                 return null;
486             Date time = date.getValue();
487             if (time == null)
488                 return null;
489
490             Calendar c = GregorianCalendar.getInstance();
491             c.setTime(time);
492             Calendar today = GregorianCalendar.getInstance();
493
494             final int myYear = c.get(Calendar.YEAR);
495             final int myMonth = c.get(Calendar.MONTH);
496             final int myDay = c.get(Calendar.DAY_OF_MONTH);
497
498             if (today.get(Calendar.YEAR) != myYear) {
499                 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
500             }
501
502             if (today.get(Calendar.MONTH) != myMonth) {
503                 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
504             }
505
506             return String.valueOf(myDay);
507         }
508         public void observeEditableFlag(NewTransactionActivity activity,
509                                         Observer<Boolean> observer) {
510             editable.observe(activity, observer);
511         }
512         public void stopObservingEditableFlag(Observer<Boolean> observer) {
513             editable.removeObserver(observer);
514         }
515         public void observeCommentVisible(NewTransactionActivity activity,
516                                           Observer<Boolean> observer) {
517             commentVisible.observe(activity, observer);
518         }
519         public void stopObservingCommentVisible(Observer<Boolean> observer) {
520             commentVisible.removeObserver(observer);
521         }
522         public void observeComment(NewTransactionActivity activity,
523                                           Observer<String> observer) {
524             comment.observe(activity, observer);
525         }
526         public void stopObservingComment(Observer<String> observer) {
527             comment.removeObserver(observer);
528         }
529         public void setComment(String comment) {
530             getAccount().setComment(comment);
531             this.comment.postValue(comment);
532         }
533     }
534 }