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.Currency;
30 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
31 import net.ktnx.mobileledger.utils.Logger;
32 import net.ktnx.mobileledger.utils.Misc;
34 import org.jetbrains.annotations.NotNull;
36 import java.util.ArrayList;
37 import java.util.Calendar;
38 import java.util.Collections;
39 import java.util.Date;
40 import java.util.GregorianCalendar;
41 import java.util.List;
42 import java.util.Locale;
43 import java.util.regex.Matcher;
44 import java.util.regex.Pattern;
46 import static net.ktnx.mobileledger.utils.Logger.debug;
48 public class NewTransactionModel extends ViewModel {
49 static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
50 static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
51 static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$");
52 private final Item header = new Item(this, null, "");
53 private final Item trailer = new Item(this);
54 private final ArrayList<Item> items = new ArrayList<>();
55 private final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
56 private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
57 private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
58 private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
59 final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
60 public boolean getSimulateSave() {
61 return simulateSave.getValue();
63 public void setSimulateSave(boolean simulateSave) {
64 this.simulateSave.setValue(simulateSave);
66 public void toggleSimulateSave() {
67 simulateSave.setValue(!simulateSave.getValue());
69 public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
71 androidx.lifecycle.Observer<? super Boolean> observer) {
72 this.simulateSave.observe(owner, observer);
74 public int getAccountCount() {
77 public Date getDate() {
78 return header.date.getValue();
80 public String getDescription() {
81 return header.description.getValue();
83 public LiveData<Boolean> isSubmittable() {
84 return this.isSubmittable;
87 header.date.setValue(null);
88 header.description.setValue(null);
90 items.add(new Item(this, new LedgerTransactionAccount("")));
91 items.add(new Item(this, new LedgerTransactionAccount("")));
92 focusedItem.setValue(0);
94 public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
95 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
96 this.focusedItem.observe(owner, observer);
98 public void stopObservingFocusedItem(
99 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
100 this.focusedItem.removeObserver(observer);
102 public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
104 androidx.lifecycle.Observer<? super Integer> observer) {
105 this.accountCount.observe(owner, observer);
107 public void stopObservingAccountCount(
108 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
109 this.accountCount.removeObserver(observer);
111 public int getFocusedItem() { return focusedItem.getValue(); }
112 public void setFocusedItem(int position) {
113 focusedItem.setValue(position);
115 public int addAccount(LedgerTransactionAccount acc) {
116 items.add(new Item(this, acc));
117 accountCount.setValue(getAccountCount());
120 boolean accountsInInitialState() {
121 for (Item item : items) {
122 LedgerTransactionAccount acc = item.getAccount();
123 if (acc.isAmountSet())
125 if (!acc.getAccountName()
133 LedgerTransactionAccount getAccount(int index) {
134 return items.get(index)
137 public Item getItem(int index) {
142 if (index <= items.size())
143 return items.get(index - 1);
148 A transaction is submittable if:
150 1) has at least two account names
151 2) each amount has account name
152 3) amounts must balance to 0, or
153 3a) there must be exactly one empty amount (with account)
154 4) empty accounts with empty amounts are ignored
155 5) a row with an empty account name or empty amount is guaranteed to exist
156 6) at least two rows need to be present in the ledger
159 @SuppressLint("DefaultLocale")
160 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
165 final String descriptionText = getDescription();
166 boolean submittable = true;
167 List<Item> itemsWithEmptyAmount = new ArrayList<>();
168 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
171 if ((descriptionText == null) || descriptionText.trim()
174 Logger.debug("submittable", "Transaction not submittable: missing description");
178 for (int i = 0; i < this.items.size(); i++) {
179 Item item = this.items.get(i);
181 LedgerTransactionAccount acc = item.getAccount();
182 String acc_name = acc.getAccountName()
184 if (acc_name.isEmpty()) {
187 if (acc.isAmountSet()) {
188 // 2) each amount has account name
189 Logger.debug("submittable", String.format(
190 "Transaction not submittable: row %d has no account name, but has" +
191 " amount %1.2f", i + 1, acc.getAmount()));
199 if (acc.isAmountSet()) {
201 balance += acc.getAmount();
204 itemsWithEmptyAmount.add(item);
206 if (!acc_name.isEmpty()) {
207 itemsWithAccountAndEmptyAmount.add(item);
212 // 1) has at least two account names
214 Logger.debug("submittable",
215 String.format("Transaction not submittable: only %d account names",
220 // 3) amount must balance to 0, or
221 // 3a) there must be exactly one empty amount (with account)
222 if (Misc.isZero(balance)) {
223 for (Item item : items) {
224 item.setAmountHint(null);
228 int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
229 if (balanceReceiversCount != 1) {
230 Logger.debug("submittable", (balanceReceiversCount == 0) ?
231 "Transaction not submittable: non-zero balance " +
232 "with no empty amounts with accounts" :
233 "Transaction not submittable: non-zero balance " +
234 "with multiple empty amounts with accounts");
238 // suggest off-balance amount to a row and remove hints on other rows
239 Item receiver = null;
240 if (!itemsWithAccountAndEmptyAmount.isEmpty())
241 receiver = itemsWithAccountAndEmptyAmount.get(0);
242 else if (!itemsWithEmptyAmount.isEmpty())
243 receiver = itemsWithEmptyAmount.get(0);
245 for (Item item : items) {
246 if (item.equals(receiver)) {
247 Logger.debug("submittable",
248 String.format("Setting amount hint to %1.2f", -balance));
249 item.setAmountHint(String.format("%1.2f", -balance));
252 item.setAmountHint(null);
256 // 5) a row with an empty account name or empty amount is guaranteed to exist
257 if ((empty_rows == 0) &&
258 ((this.items.size() == accounts) || (this.items.size() == amounts)))
263 // 6) at least two rows need to be present in the ledger
264 while (this.items.size() < 2) adapter.addRow();
267 debug("submittable", submittable ? "YES" : "NO");
268 isSubmittable.setValue(submittable);
270 if (BuildConfig.DEBUG) {
271 debug("submittable", "== Dump of all items");
272 for (int i = 0; i < items.size(); i++) {
273 Item item = items.get(i);
274 LedgerTransactionAccount acc = item.getAccount();
275 debug("submittable", String.format("Item %2d: [%4.2f] %s (%s)", i,
276 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName(),
281 catch (NumberFormatException e) {
282 debug("submittable", "NO (because of NumberFormatException)");
283 isSubmittable.setValue(false);
285 catch (Exception e) {
287 debug("submittable", "NO (because of an Exception)");
288 isSubmittable.setValue(false);
291 public void removeItem(int pos) {
293 accountCount.setValue(getAccountCount());
295 public void sendCountNotifications() {
296 accountCount.setValue(getAccountCount());
298 public void sendFocusedNotification() {
299 focusedItem.setValue(focusedItem.getValue());
301 public void updateFocusedItem(int position) {
302 focusedItem.setValue(position);
304 public void noteFocusChanged(int position, FocusedElement element) {
305 getItem(position).setFocusedElement(element);
307 public void swapItems(int one, int two) {
308 Collections.swap(items, one - 1, two - 1);
310 public void toggleComment(int position) {
311 final MutableLiveData<Boolean> commentVisible = getItem(position).commentVisible;
312 commentVisible.postValue(!commentVisible.getValue());
314 public void moveItemLast(int index) {
318 3 <-- desired position
320 int itemCount = items.size();
322 if (index < itemCount - 1) {
323 Item acc = items.remove(index);
324 items.add(itemCount - 1, acc);
327 public void toggleCurrencyVisible() {
328 showCurrency.setValue(!showCurrency.getValue());
330 enum ItemType {generalData, transactionRow, bottomFiller}
332 //==========================================================================================
334 enum FocusedElement {Account, Comment, Amount}
337 private ItemType type;
338 private MutableLiveData<Date> date = new MutableLiveData<>();
339 private MutableLiveData<String> description = new MutableLiveData<>();
340 private LedgerTransactionAccount account;
341 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
342 private NewTransactionModel model;
343 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
344 private FocusedElement focusedElement = FocusedElement.Account;
345 private MutableLiveData<String> comment = new MutableLiveData<>(null);
346 private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
347 private MutableLiveData<Currency> currency = new MutableLiveData<>(null);
348 public Item(NewTransactionModel model) {
350 type = ItemType.bottomFiller;
351 editable.setValue(false);
353 public Item(NewTransactionModel model, Date date, String description) {
355 this.type = ItemType.generalData;
356 this.date.setValue(date);
357 this.description.setValue(description);
358 this.editable.setValue(true);
360 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
362 this.type = ItemType.transactionRow;
363 this.account = account;
364 this.editable.setValue(true);
366 public FocusedElement getFocusedElement() {
367 return focusedElement;
369 public void setFocusedElement(FocusedElement focusedElement) {
370 this.focusedElement = focusedElement;
372 public NewTransactionModel getModel() {
375 public void setEditable(boolean editable) {
376 ensureType(ItemType.generalData, ItemType.transactionRow);
377 this.editable.setValue(editable);
379 private void ensureType(ItemType type1, ItemType type2) {
380 if ((type != type1) && (type != type2)) {
381 throw new RuntimeException(
382 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
386 public String getAmountHint() {
387 ensureType(ItemType.transactionRow);
388 return amountHint.getValue();
390 public void setAmountHint(String amountHint) {
391 ensureType(ItemType.transactionRow);
393 // avoid unnecessary triggers
394 if (amountHint == null) {
395 if (this.amountHint.getValue() == null)
399 if (amountHint.equals(this.amountHint.getValue()))
403 this.amountHint.setValue(amountHint);
405 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
407 androidx.lifecycle.Observer<? super String> observer) {
408 this.amountHint.observe(owner, observer);
410 public void stopObservingAmountHint(
411 @NonNull androidx.lifecycle.Observer<? super String> observer) {
412 this.amountHint.removeObserver(observer);
414 public ItemType getType() {
417 public void ensureType(ItemType wantedType) {
418 if (type != wantedType) {
419 throw new RuntimeException(
420 String.format("Actual type (%s) differs from wanted (%s)", type,
424 public Date getDate() {
425 ensureType(ItemType.generalData);
426 return date.getValue();
428 public void setDate(Date date) {
429 ensureType(ItemType.generalData);
430 this.date.setValue(date);
432 public void setDate(String text) {
433 if ((text == null) || text.trim()
436 setDate((Date) null);
440 int year, month, day;
441 final Calendar c = GregorianCalendar.getInstance();
442 Matcher m = reYMD.matcher(text);
444 year = Integer.parseInt(m.group(1));
445 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
446 day = Integer.parseInt(m.group(3));
449 year = c.get(Calendar.YEAR);
450 m = reMD.matcher(text);
452 month = Integer.parseInt(m.group(1)) - 1;
453 day = Integer.parseInt(m.group(2));
456 month = c.get(Calendar.MONTH);
457 m = reD.matcher(text);
459 day = Integer.parseInt(m.group(1));
462 day = c.get(Calendar.DAY_OF_MONTH);
467 c.set(year, month, day);
469 this.setDate(c.getTime());
471 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
472 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
473 this.date.observe(owner, observer);
475 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
476 this.date.removeObserver(observer);
478 public String getDescription() {
479 ensureType(ItemType.generalData);
480 return description.getValue();
482 public void setDescription(String description) {
483 ensureType(ItemType.generalData);
484 this.description.setValue(description);
486 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
488 androidx.lifecycle.Observer<? super String> observer) {
489 this.description.observe(owner, observer);
491 public void stopObservingDescription(
492 @NonNull androidx.lifecycle.Observer<? super String> observer) {
493 this.description.removeObserver(observer);
495 public LedgerTransactionAccount getAccount() {
496 ensureType(ItemType.transactionRow);
499 public void setAccountName(String name) {
500 account.setAccountName(name);
505 * @return nicely formatted, shortest available date representation
507 public String getFormattedDate() {
510 Date time = date.getValue();
514 Calendar c = GregorianCalendar.getInstance();
516 Calendar today = GregorianCalendar.getInstance();
518 final int myYear = c.get(Calendar.YEAR);
519 final int myMonth = c.get(Calendar.MONTH);
520 final int myDay = c.get(Calendar.DAY_OF_MONTH);
522 if (today.get(Calendar.YEAR) != myYear) {
523 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
526 if (today.get(Calendar.MONTH) != myMonth) {
527 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
530 return String.valueOf(myDay);
532 public void observeEditableFlag(NewTransactionActivity activity,
533 Observer<Boolean> observer) {
534 editable.observe(activity, observer);
536 public void stopObservingEditableFlag(Observer<Boolean> observer) {
537 editable.removeObserver(observer);
539 public void observeCommentVisible(NewTransactionActivity activity,
540 Observer<Boolean> observer) {
541 commentVisible.observe(activity, observer);
543 public void stopObservingCommentVisible(Observer<Boolean> observer) {
544 commentVisible.removeObserver(observer);
546 public void observeComment(NewTransactionActivity activity, Observer<String> observer) {
547 comment.observe(activity, observer);
549 public void stopObservingComment(Observer<String> observer) {
550 comment.removeObserver(observer);
552 public void setComment(String comment) {
553 getAccount().setComment(comment);
554 this.comment.postValue(comment);
556 public Currency getCurrency() {
557 return this.currency.getValue();
559 public void setCurrency(Currency currency) {
560 getAccount().setCurrency((currency != null && !currency.getName()
561 .isEmpty()) ? currency.getName()
563 this.currency.setValue(currency);
565 public void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
566 currency.observe(activity, observer);
568 public void stopObservingCurrency(Observer<Currency> observer) {
569 currency.removeObserver(observer);