]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
NT: move item touch helper to the adapter
[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", i,
269                             acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
270                 }
271             }
272         }
273         catch (NumberFormatException e) {
274             debug("submittable", "NO (because of NumberFormatException)");
275             isSubmittable.setValue(false);
276         }
277         catch (Exception e) {
278             e.printStackTrace();
279             debug("submittable", "NO (because of an Exception)");
280             isSubmittable.setValue(false);
281         }
282     }
283     public void removeItem(int pos) {
284         items.remove(pos);
285         accountCount.setValue(getAccountCount());
286     }
287     public void sendCountNotifications() {
288         accountCount.setValue(getAccountCount());
289     }
290     public void sendFocusedNotification() {
291         focusedItem.setValue(focusedItem.getValue());
292     }
293     public void updateFocusedItem(int position) {
294         focusedItem.setValue(position);
295     }
296     public void noteFocusIsOnAccount(int position) {
297         getItem(position).setFocusIsOnAmount(false);
298     }
299     public void noteFocusIsOnAmount(int position) {
300         getItem(position).setFocusIsOnAmount(true);
301     }
302     public void swapItems(int one, int two) {
303         Collections.swap(items, one-1, two-1);
304     }
305     enum ItemType {generalData, transactionRow, bottomFiller}
306
307     //==========================================================================================
308
309     class Item extends Object {
310         private ItemType type;
311         private MutableLiveData<Date> date = new MutableLiveData<>();
312         private MutableLiveData<String> description = new MutableLiveData<>();
313         private LedgerTransactionAccount account;
314         private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
315         private NewTransactionModel model;
316         private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
317         private boolean focusIsOnAmount = false;
318         public Item(NewTransactionModel model) {
319             this.model = model;
320             type = ItemType.bottomFiller;
321             editable.setValue(false);
322         }
323         public Item(NewTransactionModel model, Date date, String description) {
324             this.model = model;
325             this.type = ItemType.generalData;
326             this.date.setValue(date);
327             this.description.setValue(description);
328             this.editable.setValue(true);
329         }
330         public Item(NewTransactionModel model, LedgerTransactionAccount account) {
331             this.model = model;
332             this.type = ItemType.transactionRow;
333             this.account = account;
334             this.editable.setValue(true);
335         }
336         public boolean focusIsOnAmount() {
337             return focusIsOnAmount;
338         }
339         public NewTransactionModel getModel() {
340             return model;
341         }
342         public void setEditable(boolean editable) {
343             ensureType(ItemType.generalData, ItemType.transactionRow);
344             this.editable.setValue(editable);
345         }
346         private void ensureType(ItemType type1, ItemType type2) {
347             if ((type != type1) && (type != type2)) {
348                 throw new RuntimeException(
349                         String.format("Actual type (%s) differs from wanted (%s or %s)", type,
350                                 type1, type2));
351             }
352         }
353         public String getAmountHint() {
354             ensureType(ItemType.transactionRow);
355             return amountHint.getValue();
356         }
357         public void setAmountHint(String amountHint) {
358             ensureType(ItemType.transactionRow);
359
360             // avoid unnecessary triggers
361             if (amountHint == null) {
362                 if (this.amountHint.getValue() == null)
363                     return;
364             }
365             else {
366                 if (amountHint.equals(this.amountHint.getValue()))
367                     return;
368             }
369
370             this.amountHint.setValue(amountHint);
371         }
372         public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
373                                       @NonNull
374                                               androidx.lifecycle.Observer<? super String> observer) {
375             this.amountHint.observe(owner, observer);
376         }
377         public void stopObservingAmountHint(
378                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
379             this.amountHint.removeObserver(observer);
380         }
381         public ItemType getType() {
382             return type;
383         }
384         public void ensureType(ItemType wantedType) {
385             if (type != wantedType) {
386                 throw new RuntimeException(
387                         String.format("Actual type (%s) differs from wanted (%s)", type,
388                                 wantedType));
389             }
390         }
391         public Date getDate() {
392             ensureType(ItemType.generalData);
393             return date.getValue();
394         }
395         public void setDate(Date date) {
396             ensureType(ItemType.generalData);
397             this.date.setValue(date);
398         }
399         public void setDate(String text) {
400             if ((text == null) || text.trim()
401                                       .isEmpty())
402             {
403                 setDate((Date) null);
404                 return;
405             }
406
407             int year, month, day;
408             final Calendar c = GregorianCalendar.getInstance();
409             Matcher m = reYMD.matcher(text);
410             if (m.matches()) {
411                 year = Integer.parseInt(m.group(1));
412                 month = Integer.parseInt(m.group(2)) - 1;   // month is 0-based
413                 day = Integer.parseInt(m.group(3));
414             }
415             else {
416                 year = c.get(Calendar.YEAR);
417                 m = reMD.matcher(text);
418                 if (m.matches()) {
419                     month = Integer.parseInt(m.group(1)) - 1;
420                     day = Integer.parseInt(m.group(2));
421                 }
422                 else {
423                     month = c.get(Calendar.MONTH);
424                     m = reD.matcher(text);
425                     if (m.matches()) {
426                         day = Integer.parseInt(m.group(1));
427                     }
428                     else {
429                         day = c.get(Calendar.DAY_OF_MONTH);
430                     }
431                 }
432             }
433
434             c.set(year, month, day);
435
436             this.setDate(c.getTime());
437         }
438         public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
439                                 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
440             this.date.observe(owner, observer);
441         }
442         public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
443             this.date.removeObserver(observer);
444         }
445         public String getDescription() {
446             ensureType(ItemType.generalData);
447             return description.getValue();
448         }
449         public void setDescription(String description) {
450             ensureType(ItemType.generalData);
451             this.description.setValue(description);
452         }
453         public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
454                                        @NonNull
455                                                androidx.lifecycle.Observer<? super String> observer) {
456             this.description.observe(owner, observer);
457         }
458         public void stopObservingDescription(
459                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
460             this.description.removeObserver(observer);
461         }
462         public LedgerTransactionAccount getAccount() {
463             ensureType(ItemType.transactionRow);
464             return account;
465         }
466         public void setAccountName(String name) {
467             account.setAccountName(name);
468         }
469         /**
470          * getFormattedDate()
471          *
472          * @return nicely formatted, shortest available date representation
473          */
474         public String getFormattedDate() {
475             if (date == null)
476                 return null;
477             Date time = date.getValue();
478             if (time == null)
479                 return null;
480
481             Calendar c = GregorianCalendar.getInstance();
482             c.setTime(time);
483             Calendar today = GregorianCalendar.getInstance();
484
485             final int myYear = c.get(Calendar.YEAR);
486             final int myMonth = c.get(Calendar.MONTH);
487             final int myDay = c.get(Calendar.DAY_OF_MONTH);
488
489             if (today.get(Calendar.YEAR) != myYear) {
490                 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
491             }
492
493             if (today.get(Calendar.MONTH) != myMonth) {
494                 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
495             }
496
497             return String.valueOf(myDay);
498         }
499         public void observeEditableFlag(NewTransactionActivity activity,
500                                         Observer<Boolean> observer) {
501             editable.observe(activity, observer);
502         }
503         public void stopObservingEditableFlag(Observer<Boolean> observer) {
504             editable.removeObserver(observer);
505         }
506         public void setFocusIsOnAmount(boolean flag) {
507             focusIsOnAmount = flag;
508         }
509     }
510 }