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.
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.
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/>.
18 package net.ktnx.mobileledger.ui.activity;
20 import android.annotation.SuppressLint;
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;
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;
33 import org.jetbrains.annotations.NotNull;
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;
44 import static net.ktnx.mobileledger.utils.Logger.debug;
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 private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
57 public boolean getSimulateSave() {
58 return simulateSave.getValue();
60 public void setSimulateSave(boolean simulateSave) {
61 this.simulateSave.setValue(simulateSave);
63 public void toggleSimulateSave() {
64 simulateSave.setValue(!simulateSave.getValue());
66 public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
68 androidx.lifecycle.Observer<? super Boolean> observer) {
69 this.simulateSave.observe(owner, observer);
71 public int getAccountCount() {
74 public Date getDate() {
75 return header.date.getValue();
77 public String getDescription() {
78 return header.description.getValue();
80 public LiveData<Boolean> isSubmittable() {
81 return this.isSubmittable;
84 header.date.setValue(null);
85 header.description.setValue(null);
87 items.add(new Item(this, new LedgerTransactionAccount("")));
88 items.add(new Item(this, new LedgerTransactionAccount("")));
89 focusedItem.setValue(0);
91 public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
92 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
93 this.focusedItem.observe(owner, observer);
95 public void stopObservingFocusedItem(
96 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
97 this.focusedItem.removeObserver(observer);
99 public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
101 androidx.lifecycle.Observer<? super Integer> observer) {
102 this.accountCount.observe(owner, observer);
104 public void stopObservingAccountCount(
105 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
106 this.accountCount.removeObserver(observer);
108 public int getFocusedItem() { return focusedItem.getValue(); }
109 public void setFocusedItem(int position) {
110 focusedItem.setValue(position);
112 public int addAccount(LedgerTransactionAccount acc) {
113 items.add(new Item(this, acc));
114 accountCount.setValue(getAccountCount());
117 boolean accountsInInitialState() {
118 for (Item item : items) {
119 LedgerTransactionAccount acc = item.getAccount();
120 if (acc.isAmountSet())
122 if (!acc.getAccountName()
130 LedgerTransactionAccount getAccount(int index) {
131 return items.get(index)
134 public Item getItem(int index) {
139 if (index <= items.size())
140 return items.get(index - 1);
145 A transaction is submittable if:
147 1) has at least two account names
148 2) each amount has account name
149 3) amounts must balance to 0, or
150 3a) there must be exactly one empty amount (with account)
151 4) empty accounts with empty amounts are ignored
152 5) a row with an empty account name or empty amount is guaranteed to exist
154 @SuppressLint("DefaultLocale")
155 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
160 final String descriptionText = getDescription();
161 boolean submittable = true;
162 List<Item> itemsWithEmptyAmount = new ArrayList<>();
163 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
166 if ((descriptionText == null) || descriptionText.trim()
169 Logger.debug("submittable", "Transaction not submittable: missing description");
173 for (int i = 0; i < this.items.size(); i++) {
174 Item item = this.items.get(i);
176 LedgerTransactionAccount acc = item.getAccount();
177 String acc_name = acc.getAccountName()
179 if (acc_name.isEmpty()) {
182 if (acc.isAmountSet()) {
183 // 2) each amount has account name
184 Logger.debug("submittable", String.format(
185 "Transaction not submittable: row %d has no account name, but has" +
186 " amount %1.2f", i + 1, acc.getAmount()));
194 if (acc.isAmountSet()) {
196 balance += acc.getAmount();
199 itemsWithEmptyAmount.add(item);
201 if (!acc_name.isEmpty()) {
202 itemsWithAccountAndEmptyAmount.add(item);
207 // 1) has at least two account names
209 Logger.debug("submittable",
210 String.format("Transaction not submittable: only %d account names",
215 // 3) amount must balance to 0, or
216 // 3a) there must be exactly one empty amount (with account)
217 if (Misc.isZero(balance)) {
218 for (Item item : items) {
219 item.setAmountHint(null);
223 int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
224 if (balanceReceiversCount != 1) {
225 Logger.debug("submittable", (balanceReceiversCount == 0) ?
226 "Transaction not submittable: non-zero balance " +
227 "with no empty amounts with accounts" :
228 "Transaction not submittable: non-zero balance " +
229 "with multiple empty amounts with accounts");
233 // suggest off-balance amount to a row and remove hints on other rows
234 Item receiver = null;
235 if (!itemsWithAccountAndEmptyAmount.isEmpty())
236 receiver = itemsWithAccountAndEmptyAmount.get(0);
237 else if (!itemsWithEmptyAmount.isEmpty())
238 receiver = itemsWithEmptyAmount.get(0);
240 for (Item item : items) {
241 if (item.equals(receiver)) {
242 Logger.debug("submittable",
243 String.format("Setting amount hint to %1.2f", -balance));
244 item.setAmountHint(String.format("%1.2f", -balance));
247 item.setAmountHint(null);
251 // 5) a row with an empty account name or empty amount is guaranteed to exist
252 if ((empty_rows == 0) &&
253 ((this.items.size() == accounts) || (this.items.size() == amounts)))
259 debug("submittable", submittable ? "YES" : "NO");
260 isSubmittable.setValue(submittable);
262 if (BuildConfig.DEBUG) {
263 debug("submittable", "== Dump of all items");
264 for (int i = 0; i < items.size(); i++) {
265 Item item = items.get(i);
266 LedgerTransactionAccount acc = item.getAccount();
267 debug("submittable", String.format("Item %2d: [%4.2f] %s", i,
268 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
272 catch (NumberFormatException e) {
273 debug("submittable", "NO (because of NumberFormatException)");
274 isSubmittable.setValue(false);
276 catch (Exception e) {
278 debug("submittable", "NO (because of an Exception)");
279 isSubmittable.setValue(false);
282 public void removeItem(int pos) {
284 accountCount.setValue(getAccountCount());
286 public void sendCountNotifications() {
287 accountCount.setValue(getAccountCount());
289 public void sendFocusedNotification() {
290 focusedItem.setValue(focusedItem.getValue());
292 public void updateFocusedItem(int position) {
293 focusedItem.setValue(position);
295 public void noteFocusIsOnAccount(int position) {
296 getItem(position).setFocusIsOnAmount(false);
298 public void noteFocusIsOnAmount(int position) {
299 getItem(position).setFocusIsOnAmount(true);
301 enum ItemType {generalData, transactionRow, bottomFiller}
303 //==========================================================================================
305 class Item extends Object {
306 private ItemType type;
307 private MutableLiveData<Date> date = new MutableLiveData<>();
308 private MutableLiveData<String> description = new MutableLiveData<>();
309 private LedgerTransactionAccount account;
310 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
311 private NewTransactionModel model;
312 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
313 private boolean focusIsOnAmount = false;
314 public Item(NewTransactionModel model) {
316 type = ItemType.bottomFiller;
317 editable.setValue(false);
319 public Item(NewTransactionModel model, Date date, String description) {
321 this.type = ItemType.generalData;
322 this.date.setValue(date);
323 this.description.setValue(description);
324 this.editable.setValue(true);
326 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
328 this.type = ItemType.transactionRow;
329 this.account = account;
330 this.editable.setValue(true);
332 public boolean focusIsOnAmount() {
333 return focusIsOnAmount;
335 public NewTransactionModel getModel() {
338 public void setEditable(boolean editable) {
339 ensureType(ItemType.generalData, ItemType.transactionRow);
340 this.editable.setValue(editable);
342 private void ensureType(ItemType type1, ItemType type2) {
343 if ((type != type1) && (type != type2)) {
344 throw new RuntimeException(
345 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
349 public String getAmountHint() {
350 ensureType(ItemType.transactionRow);
351 return amountHint.getValue();
353 public void setAmountHint(String amountHint) {
354 ensureType(ItemType.transactionRow);
356 // avoid unnecessary triggers
357 if (amountHint == null) {
358 if (this.amountHint.getValue() == null)
362 if (amountHint.equals(this.amountHint.getValue()))
366 this.amountHint.setValue(amountHint);
368 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
370 androidx.lifecycle.Observer<? super String> observer) {
371 this.amountHint.observe(owner, observer);
373 public void stopObservingAmountHint(
374 @NonNull androidx.lifecycle.Observer<? super String> observer) {
375 this.amountHint.removeObserver(observer);
377 public ItemType getType() {
380 public void ensureType(ItemType wantedType) {
381 if (type != wantedType) {
382 throw new RuntimeException(
383 String.format("Actual type (%s) differs from wanted (%s)", type,
387 public Date getDate() {
388 ensureType(ItemType.generalData);
389 return date.getValue();
391 public void setDate(Date date) {
392 ensureType(ItemType.generalData);
393 this.date.setValue(date);
395 public void setDate(String text) {
396 if ((text == null) || text.trim()
399 setDate((Date) null);
403 int year, month, day;
404 final Calendar c = GregorianCalendar.getInstance();
405 Matcher m = reYMD.matcher(text);
407 year = Integer.parseInt(m.group(1));
408 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
409 day = Integer.parseInt(m.group(3));
412 year = c.get(Calendar.YEAR);
413 m = reMD.matcher(text);
415 month = Integer.parseInt(m.group(1)) - 1;
416 day = Integer.parseInt(m.group(2));
419 month = c.get(Calendar.MONTH);
420 m = reD.matcher(text);
422 day = Integer.parseInt(m.group(1));
425 day = c.get(Calendar.DAY_OF_MONTH);
430 c.set(year, month, day);
432 this.setDate(c.getTime());
434 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
435 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
436 this.date.observe(owner, observer);
438 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
439 this.date.removeObserver(observer);
441 public String getDescription() {
442 ensureType(ItemType.generalData);
443 return description.getValue();
445 public void setDescription(String description) {
446 ensureType(ItemType.generalData);
447 this.description.setValue(description);
449 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
451 androidx.lifecycle.Observer<? super String> observer) {
452 this.description.observe(owner, observer);
454 public void stopObservingDescription(
455 @NonNull androidx.lifecycle.Observer<? super String> observer) {
456 this.description.removeObserver(observer);
458 public LedgerTransactionAccount getAccount() {
459 ensureType(ItemType.transactionRow);
462 public void setAccountName(String name) {
463 account.setAccountName(name);
468 * @return nicely formatted, shortest available date representation
470 public String getFormattedDate() {
473 Date time = date.getValue();
477 Calendar c = GregorianCalendar.getInstance();
479 Calendar today = GregorianCalendar.getInstance();
481 final int myYear = c.get(Calendar.YEAR);
482 final int myMonth = c.get(Calendar.MONTH);
483 final int myDay = c.get(Calendar.DAY_OF_MONTH);
485 if (today.get(Calendar.YEAR) != myYear) {
486 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
489 if (today.get(Calendar.MONTH) != myMonth) {
490 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
493 return String.valueOf(myDay);
495 public void observeEditableFlag(NewTransactionActivity activity,
496 Observer<Boolean> observer) {
497 editable.observe(activity, observer);
499 public void stopObservingEditableFlag(Observer<Boolean> observer) {
500 editable.removeObserver(observer);
502 public void setFocusIsOnAmount(boolean flag) {
503 focusIsOnAmount = flag;