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