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