]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
9017973998da8c39efaf91b110d640de1b22decf
[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         public boolean focusIsOnAmount() {
299             return focusIsOnAmount;
300         }
301         private boolean focusIsOnAmount = false;
302         public Item(NewTransactionModel model) {
303             this.model = model;
304             type = ItemType.bottomFiller;
305             editable.setValue(false);
306         }
307         public Item(NewTransactionModel model, Date date, String description) {
308             this.model = model;
309             this.type = ItemType.generalData;
310             this.date.setValue(date);
311             this.description.setValue(description);
312             this.editable.setValue(true);
313         }
314         public Item(NewTransactionModel model, LedgerTransactionAccount account) {
315             this.model = model;
316             this.type = ItemType.transactionRow;
317             this.account = account;
318             this.editable.setValue(true);
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             int year, month, day;
382             final Calendar c = GregorianCalendar.getInstance();
383             Matcher m = reYMD.matcher(text);
384             if (m.matches()) {
385                 year = Integer.parseInt(m.group(1));
386                 month = Integer.parseInt(m.group(2)) - 1;   // month is 0-based
387                 day = Integer.parseInt(m.group(3));
388             }
389             else {
390                 year = c.get(Calendar.YEAR);
391                 m = reMD.matcher(text);
392                 if (m.matches()) {
393                     month = Integer.parseInt(m.group(1)) - 1;
394                     day = Integer.parseInt(m.group(2));
395                 }
396                 else {
397                     month = c.get(Calendar.MONTH);
398                     m = reD.matcher(text);
399                     if (m.matches()) {
400                         day = Integer.parseInt(m.group(1));
401                     }
402                     else {
403                         day = c.get(Calendar.DAY_OF_MONTH);
404                     }
405                 }
406             }
407
408             c.set(year, month, day);
409
410             this.setDate(c.getTime());
411         }
412         public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
413                                 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
414             this.date.observe(owner, observer);
415         }
416         public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
417             this.date.removeObserver(observer);
418         }
419         public String getDescription() {
420             ensureType(ItemType.generalData);
421             return description.getValue();
422         }
423         public void setDescription(String description) {
424             ensureType(ItemType.generalData);
425             this.description.setValue(description);
426         }
427         public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
428                                        @NonNull
429                                                androidx.lifecycle.Observer<? super String> observer) {
430             this.description.observe(owner, observer);
431         }
432         public void stopObservingDescription(
433                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
434             this.description.removeObserver(observer);
435         }
436         public LedgerTransactionAccount getAccount() {
437             ensureType(ItemType.transactionRow);
438             return account;
439         }
440         public void setAccountName(String name) {
441             account.setAccountName(name);
442         }
443         /**
444          * getFormattedDate()
445          *
446          * @return nicely formatted, shortest available date representation
447          */
448         public String getFormattedDate() {
449             if (date == null)
450                 return null;
451             Date time = date.getValue();
452             if (time == null)
453                 return null;
454
455             Calendar c = GregorianCalendar.getInstance();
456             c.setTime(time);
457             Calendar today = GregorianCalendar.getInstance();
458
459             final int myYear = c.get(Calendar.YEAR);
460             final int myMonth = c.get(Calendar.MONTH);
461             final int myDay = c.get(Calendar.DAY_OF_MONTH);
462
463             if (today.get(Calendar.YEAR) != myYear) {
464                 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
465             }
466
467             if (today.get(Calendar.MONTH) != myMonth) {
468                 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
469             }
470
471             return String.valueOf(myDay);
472         }
473         public void observeEditableFlag(NewTransactionActivity activity,
474                                         Observer<Boolean> observer) {
475             editable.observe(activity, observer);
476         }
477         public void stopObservingEditableFlag(Observer<Boolean> observer) {
478             editable.removeObserver(observer);
479         }
480         public void setFocusIsOnAmount(boolean flag) {
481             focusIsOnAmount = flag;
482         }
483     }
484 }