]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java
NT: row focus survives re-configuration
[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     enum ItemType {generalData, transactionRow, bottomFiller}
281
282     //==========================================================================================
283
284     class Item extends Object {
285         private ItemType type;
286         private MutableLiveData<Date> date = new MutableLiveData<>();
287         private MutableLiveData<String> description = new MutableLiveData<>();
288         private LedgerTransactionAccount account;
289         private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
290         private NewTransactionModel model;
291         private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
292         public Item(NewTransactionModel model) {
293             this.model = model;
294             type = ItemType.bottomFiller;
295             editable.setValue(false);
296         }
297         public Item(NewTransactionModel model, Date date, String description) {
298             this.model = model;
299             this.type = ItemType.generalData;
300             this.date.setValue(date);
301             this.description.setValue(description);
302             this.editable.setValue(true);
303         }
304         public Item(NewTransactionModel model, LedgerTransactionAccount account) {
305             this.model = model;
306             this.type = ItemType.transactionRow;
307             this.account = account;
308             this.editable.setValue(true);
309         }
310         public NewTransactionModel getModel() {
311             return model;
312         }
313         public void setEditable(boolean editable) {
314             ensureType(ItemType.generalData, ItemType.transactionRow);
315             this.editable.setValue(editable);
316         }
317         private void ensureType(ItemType type1, ItemType type2) {
318             if ((type != type1) && (type != type2)) {
319                 throw new RuntimeException(
320                         String.format("Actual type (%s) differs from wanted (%s or %s)", type,
321                                 type1, type2));
322             }
323         }
324         public String getAmountHint() {
325             ensureType(ItemType.transactionRow);
326             return amountHint.getValue();
327         }
328         public void setAmountHint(String amountHint) {
329             ensureType(ItemType.transactionRow);
330
331             // avoid unnecessary triggers
332             if (amountHint == null) {
333                 if (this.amountHint.getValue() == null)
334                     return;
335             }
336             else {
337                 if (amountHint.equals(this.amountHint.getValue()))
338                     return;
339             }
340
341             this.amountHint.setValue(amountHint);
342         }
343         public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
344                                       @NonNull
345                                               androidx.lifecycle.Observer<? super String> observer) {
346             this.amountHint.observe(owner, observer);
347         }
348         public void stopObservingAmountHint(
349                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
350             this.amountHint.removeObserver(observer);
351         }
352         public ItemType getType() {
353             return type;
354         }
355         public void ensureType(ItemType wantedType) {
356             if (type != wantedType) {
357                 throw new RuntimeException(
358                         String.format("Actual type (%s) differs from wanted (%s)", type,
359                                 wantedType));
360             }
361         }
362         public Date getDate() {
363             ensureType(ItemType.generalData);
364             return date.getValue();
365         }
366         public void setDate(Date date) {
367             ensureType(ItemType.generalData);
368             this.date.setValue(date);
369         }
370         public void setDate(String text) {
371             int year, month, day;
372             final Calendar c = GregorianCalendar.getInstance();
373             Matcher m = reYMD.matcher(text);
374             if (m.matches()) {
375                 year = Integer.parseInt(m.group(1));
376                 month = Integer.parseInt(m.group(2)) - 1;   // month is 0-based
377                 day = Integer.parseInt(m.group(3));
378             }
379             else {
380                 year = c.get(Calendar.YEAR);
381                 m = reMD.matcher(text);
382                 if (m.matches()) {
383                     month = Integer.parseInt(m.group(1)) - 1;
384                     day = Integer.parseInt(m.group(2));
385                 }
386                 else {
387                     month = c.get(Calendar.MONTH);
388                     m = reD.matcher(text);
389                     if (m.matches()) {
390                         day = Integer.parseInt(m.group(1));
391                     }
392                     else {
393                         day = c.get(Calendar.DAY_OF_MONTH);
394                     }
395                 }
396             }
397
398             c.set(year, month, day);
399
400             this.setDate(c.getTime());
401         }
402         public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
403                                 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
404             this.date.observe(owner, observer);
405         }
406         public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
407             this.date.removeObserver(observer);
408         }
409         public String getDescription() {
410             ensureType(ItemType.generalData);
411             return description.getValue();
412         }
413         public void setDescription(String description) {
414             ensureType(ItemType.generalData);
415             this.description.setValue(description);
416         }
417         public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
418                                        @NonNull
419                                                androidx.lifecycle.Observer<? super String> observer) {
420             this.description.observe(owner, observer);
421         }
422         public void stopObservingDescription(
423                 @NonNull androidx.lifecycle.Observer<? super String> observer) {
424             this.description.removeObserver(observer);
425         }
426         public LedgerTransactionAccount getAccount() {
427             ensureType(ItemType.transactionRow);
428             return account;
429         }
430         public void setAccountName(String name) {
431             account.setAccountName(name);
432         }
433         /**
434          * getFormattedDate()
435          *
436          * @return nicely formatted, shortest available date representation
437          */
438         public String getFormattedDate() {
439             if (date == null)
440                 return null;
441             Date time = date.getValue();
442             if (time == null)
443                 return null;
444
445             Calendar c = GregorianCalendar.getInstance();
446             c.setTime(time);
447             Calendar today = GregorianCalendar.getInstance();
448
449             final int myYear = c.get(Calendar.YEAR);
450             final int myMonth = c.get(Calendar.MONTH);
451             final int myDay = c.get(Calendar.DAY_OF_MONTH);
452
453             if (today.get(Calendar.YEAR) != myYear) {
454                 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
455             }
456
457             if (today.get(Calendar.MONTH) != myMonth) {
458                 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
459             }
460
461             return String.valueOf(myDay);
462         }
463         public void observeEditableFlag(NewTransactionActivity activity,
464                                         Observer<Boolean> observer) {
465             editable.observe(activity, observer);
466         }
467         public void stopObservingEditableFlag(Observer<Boolean> observer) {
468             editable.removeObserver(observer);
469         }
470     }
471 }