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