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