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 public boolean getSimulateSave() {
60 return simulateSave.getValue();
62 public void setSimulateSave(boolean simulateSave) {
63 this.simulateSave.setValue(simulateSave);
65 public void toggleSimulateSave() {
66 simulateSave.setValue(!simulateSave.getValue());
68 public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
70 androidx.lifecycle.Observer<? super Boolean> observer) {
71 this.simulateSave.observe(owner, observer);
73 public int getAccountCount() {
76 public Date getDate() {
77 return header.date.getValue();
79 public String getDescription() {
80 return header.description.getValue();
82 public LiveData<Boolean> isSubmittable() {
83 return this.isSubmittable;
86 header.date.setValue(null);
87 header.description.setValue(null);
89 items.add(new Item(this, new LedgerTransactionAccount("")));
90 items.add(new Item(this, new LedgerTransactionAccount("")));
91 focusedItem.setValue(0);
93 public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
94 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
95 this.focusedItem.observe(owner, observer);
97 public void stopObservingFocusedItem(
98 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
99 this.focusedItem.removeObserver(observer);
101 public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
103 androidx.lifecycle.Observer<? super Integer> observer) {
104 this.accountCount.observe(owner, observer);
106 public void stopObservingAccountCount(
107 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
108 this.accountCount.removeObserver(observer);
110 public int getFocusedItem() { return focusedItem.getValue(); }
111 public void setFocusedItem(int position) {
112 focusedItem.setValue(position);
114 public int addAccount(LedgerTransactionAccount acc) {
115 items.add(new Item(this, acc));
116 accountCount.setValue(getAccountCount());
119 boolean accountsInInitialState() {
120 for (Item item : items) {
121 LedgerTransactionAccount acc = item.getAccount();
122 if (acc.isAmountSet())
124 if (!acc.getAccountName()
132 LedgerTransactionAccount getAccount(int index) {
133 return items.get(index)
136 public Item getItem(int index) {
141 if (index <= items.size())
142 return items.get(index - 1);
147 A transaction is submittable if:
149 1) has at least two account names
150 2) each amount has account name
151 3) amounts must balance to 0, or
152 3a) there must be exactly one empty amount (with account)
153 4) empty accounts with empty amounts are ignored
154 5) a row with an empty account name or empty amount is guaranteed to exist
156 @SuppressLint("DefaultLocale")
157 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
162 final String descriptionText = getDescription();
163 boolean submittable = true;
164 List<Item> itemsWithEmptyAmount = new ArrayList<>();
165 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
168 if ((descriptionText == null) || descriptionText.trim()
171 Logger.debug("submittable", "Transaction not submittable: missing description");
175 for (int i = 0; i < this.items.size(); i++) {
176 Item item = this.items.get(i);
178 LedgerTransactionAccount acc = item.getAccount();
179 String acc_name = acc.getAccountName()
181 if (acc_name.isEmpty()) {
184 if (acc.isAmountSet()) {
185 // 2) each amount has account name
186 Logger.debug("submittable", String.format(
187 "Transaction not submittable: row %d has no account name, but has" +
188 " amount %1.2f", i + 1, acc.getAmount()));
196 if (acc.isAmountSet()) {
198 balance += acc.getAmount();
201 itemsWithEmptyAmount.add(item);
203 if (!acc_name.isEmpty()) {
204 itemsWithAccountAndEmptyAmount.add(item);
209 // 1) has at least two account names
211 Logger.debug("submittable",
212 String.format("Transaction not submittable: only %d account names",
217 // 3) amount must balance to 0, or
218 // 3a) there must be exactly one empty amount (with account)
219 if (Misc.isZero(balance)) {
220 for (Item item : items) {
221 item.setAmountHint(null);
225 int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
226 if (balanceReceiversCount != 1) {
227 Logger.debug("submittable", (balanceReceiversCount == 0) ?
228 "Transaction not submittable: non-zero balance " +
229 "with no empty amounts with accounts" :
230 "Transaction not submittable: non-zero balance " +
231 "with multiple empty amounts with accounts");
235 // suggest off-balance amount to a row and remove hints on other rows
236 Item receiver = null;
237 if (!itemsWithAccountAndEmptyAmount.isEmpty())
238 receiver = itemsWithAccountAndEmptyAmount.get(0);
239 else if (!itemsWithEmptyAmount.isEmpty())
240 receiver = itemsWithEmptyAmount.get(0);
242 for (Item item : items) {
243 if (item.equals(receiver)) {
244 Logger.debug("submittable",
245 String.format("Setting amount hint to %1.2f", -balance));
246 item.setAmountHint(String.format("%1.2f", -balance));
249 item.setAmountHint(null);
253 // 5) a row with an empty account name or empty amount is guaranteed to exist
254 if ((empty_rows == 0) &&
255 ((this.items.size() == accounts) || (this.items.size() == amounts)))
261 debug("submittable", submittable ? "YES" : "NO");
262 isSubmittable.setValue(submittable);
264 if (BuildConfig.DEBUG) {
265 debug("submittable", "== Dump of all items");
266 for (int i = 0; i < items.size(); i++) {
267 Item item = items.get(i);
268 LedgerTransactionAccount acc = item.getAccount();
269 debug("submittable", String.format("Item %2d: [%4.2f] %s (%s)", i,
270 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName(),
275 catch (NumberFormatException e) {
276 debug("submittable", "NO (because of NumberFormatException)");
277 isSubmittable.setValue(false);
279 catch (Exception e) {
281 debug("submittable", "NO (because of an Exception)");
282 isSubmittable.setValue(false);
285 public void removeItem(int pos) {
287 accountCount.setValue(getAccountCount());
289 public void sendCountNotifications() {
290 accountCount.setValue(getAccountCount());
292 public void sendFocusedNotification() {
293 focusedItem.setValue(focusedItem.getValue());
295 public void updateFocusedItem(int position) {
296 focusedItem.setValue(position);
298 public void noteFocusChanged(int position, FocusedElement element) {
299 getItem(position).setFocusedElement(element);
301 public void swapItems(int one, int two) {
302 Collections.swap(items, one - 1, two - 1);
304 public void toggleComment(int position) {
305 final MutableLiveData<Boolean> commentVisible = getItem(position).commentVisible;
306 commentVisible.postValue(!commentVisible.getValue());
308 public void moveItemLast(int index) {
312 3 <-- desired position
314 int itemCount = items.size();
316 if (index < itemCount - 1) {
317 Item acc = items.remove(index);
318 items.add(itemCount - 1, acc);
321 enum ItemType {generalData, transactionRow, bottomFiller}
323 //==========================================================================================
325 enum FocusedElement {Account, Comment, Amount}
328 private ItemType type;
329 private MutableLiveData<Date> date = new MutableLiveData<>();
330 private MutableLiveData<String> description = new MutableLiveData<>();
331 private LedgerTransactionAccount account;
332 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
333 private NewTransactionModel model;
334 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
335 private FocusedElement focusedElement = FocusedElement.Account;
336 private MutableLiveData<String> comment = new MutableLiveData<>(null);
337 private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
338 private MutableLiveData<Currency> currency = new MutableLiveData<>(null);
339 public Item(NewTransactionModel model) {
341 type = ItemType.bottomFiller;
342 editable.setValue(false);
344 public Item(NewTransactionModel model, Date date, String description) {
346 this.type = ItemType.generalData;
347 this.date.setValue(date);
348 this.description.setValue(description);
349 this.editable.setValue(true);
351 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
353 this.type = ItemType.transactionRow;
354 this.account = account;
355 this.editable.setValue(true);
357 public FocusedElement getFocusedElement() {
358 return focusedElement;
360 public void setFocusedElement(FocusedElement focusedElement) {
361 this.focusedElement = focusedElement;
363 public NewTransactionModel getModel() {
366 public void setEditable(boolean editable) {
367 ensureType(ItemType.generalData, ItemType.transactionRow);
368 this.editable.setValue(editable);
370 private void ensureType(ItemType type1, ItemType type2) {
371 if ((type != type1) && (type != type2)) {
372 throw new RuntimeException(
373 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
377 public String getAmountHint() {
378 ensureType(ItemType.transactionRow);
379 return amountHint.getValue();
381 public void setAmountHint(String amountHint) {
382 ensureType(ItemType.transactionRow);
384 // avoid unnecessary triggers
385 if (amountHint == null) {
386 if (this.amountHint.getValue() == null)
390 if (amountHint.equals(this.amountHint.getValue()))
394 this.amountHint.setValue(amountHint);
396 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
398 androidx.lifecycle.Observer<? super String> observer) {
399 this.amountHint.observe(owner, observer);
401 public void stopObservingAmountHint(
402 @NonNull androidx.lifecycle.Observer<? super String> observer) {
403 this.amountHint.removeObserver(observer);
405 public ItemType getType() {
408 public void ensureType(ItemType wantedType) {
409 if (type != wantedType) {
410 throw new RuntimeException(
411 String.format("Actual type (%s) differs from wanted (%s)", type,
415 public Date getDate() {
416 ensureType(ItemType.generalData);
417 return date.getValue();
419 public void setDate(Date date) {
420 ensureType(ItemType.generalData);
421 this.date.setValue(date);
423 public void setDate(String text) {
424 if ((text == null) || text.trim()
427 setDate((Date) null);
431 int year, month, day;
432 final Calendar c = GregorianCalendar.getInstance();
433 Matcher m = reYMD.matcher(text);
435 year = Integer.parseInt(m.group(1));
436 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
437 day = Integer.parseInt(m.group(3));
440 year = c.get(Calendar.YEAR);
441 m = reMD.matcher(text);
443 month = Integer.parseInt(m.group(1)) - 1;
444 day = Integer.parseInt(m.group(2));
447 month = c.get(Calendar.MONTH);
448 m = reD.matcher(text);
450 day = Integer.parseInt(m.group(1));
453 day = c.get(Calendar.DAY_OF_MONTH);
458 c.set(year, month, day);
460 this.setDate(c.getTime());
462 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
463 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
464 this.date.observe(owner, observer);
466 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
467 this.date.removeObserver(observer);
469 public String getDescription() {
470 ensureType(ItemType.generalData);
471 return description.getValue();
473 public void setDescription(String description) {
474 ensureType(ItemType.generalData);
475 this.description.setValue(description);
477 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
479 androidx.lifecycle.Observer<? super String> observer) {
480 this.description.observe(owner, observer);
482 public void stopObservingDescription(
483 @NonNull androidx.lifecycle.Observer<? super String> observer) {
484 this.description.removeObserver(observer);
486 public LedgerTransactionAccount getAccount() {
487 ensureType(ItemType.transactionRow);
490 public void setAccountName(String name) {
491 account.setAccountName(name);
496 * @return nicely formatted, shortest available date representation
498 public String getFormattedDate() {
501 Date time = date.getValue();
505 Calendar c = GregorianCalendar.getInstance();
507 Calendar today = GregorianCalendar.getInstance();
509 final int myYear = c.get(Calendar.YEAR);
510 final int myMonth = c.get(Calendar.MONTH);
511 final int myDay = c.get(Calendar.DAY_OF_MONTH);
513 if (today.get(Calendar.YEAR) != myYear) {
514 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
517 if (today.get(Calendar.MONTH) != myMonth) {
518 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
521 return String.valueOf(myDay);
523 public void observeEditableFlag(NewTransactionActivity activity,
524 Observer<Boolean> observer) {
525 editable.observe(activity, observer);
527 public void stopObservingEditableFlag(Observer<Boolean> observer) {
528 editable.removeObserver(observer);
530 public void observeCommentVisible(NewTransactionActivity activity,
531 Observer<Boolean> observer) {
532 commentVisible.observe(activity, observer);
534 public void stopObservingCommentVisible(Observer<Boolean> observer) {
535 commentVisible.removeObserver(observer);
537 public void observeComment(NewTransactionActivity activity, Observer<String> observer) {
538 comment.observe(activity, observer);
540 public void stopObservingComment(Observer<String> observer) {
541 comment.removeObserver(observer);
543 public void setComment(String comment) {
544 getAccount().setComment(comment);
545 this.comment.postValue(comment);
547 public Currency getCurrency() {
548 return this.currency.getValue();
550 public void setCurrency(Currency currency) {
551 getAccount().setCurrency((currency != null && !currency.getName()
552 .isEmpty()) ? currency.getName()
554 this.currency.setValue(currency);
556 public void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
557 currency.observe(activity, observer);
559 public void stopObservingCurrency(Observer<Currency> observer) {
560 currency.removeObserver(observer);