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.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;
45 import static net.ktnx.mobileledger.utils.Logger.debug;
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();
61 public void setSimulateSave(boolean simulateSave) {
62 this.simulateSave.setValue(simulateSave);
64 public void toggleSimulateSave() {
65 simulateSave.setValue(!simulateSave.getValue());
67 public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
69 androidx.lifecycle.Observer<? super Boolean> observer) {
70 this.simulateSave.observe(owner, observer);
72 public int getAccountCount() {
75 public Date getDate() {
76 return header.date.getValue();
78 public String getDescription() {
79 return header.description.getValue();
81 public LiveData<Boolean> isSubmittable() {
82 return this.isSubmittable;
85 header.date.setValue(null);
86 header.description.setValue(null);
88 items.add(new Item(this, new LedgerTransactionAccount("")));
89 items.add(new Item(this, new LedgerTransactionAccount("")));
90 focusedItem.setValue(0);
92 public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
93 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
94 this.focusedItem.observe(owner, observer);
96 public void stopObservingFocusedItem(
97 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
98 this.focusedItem.removeObserver(observer);
100 public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
102 androidx.lifecycle.Observer<? super Integer> observer) {
103 this.accountCount.observe(owner, observer);
105 public void stopObservingAccountCount(
106 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
107 this.accountCount.removeObserver(observer);
109 public int getFocusedItem() { return focusedItem.getValue(); }
110 public void setFocusedItem(int position) {
111 focusedItem.setValue(position);
113 public int addAccount(LedgerTransactionAccount acc) {
114 items.add(new Item(this, acc));
115 accountCount.setValue(getAccountCount());
118 boolean accountsInInitialState() {
119 for (Item item : items) {
120 LedgerTransactionAccount acc = item.getAccount();
121 if (acc.isAmountSet())
123 if (!acc.getAccountName()
131 LedgerTransactionAccount getAccount(int index) {
132 return items.get(index)
135 public Item getItem(int index) {
140 if (index <= items.size())
141 return items.get(index - 1);
146 A transaction is submittable if:
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
155 @SuppressLint("DefaultLocale")
156 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
161 final String descriptionText = getDescription();
162 boolean submittable = true;
163 List<Item> itemsWithEmptyAmount = new ArrayList<>();
164 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
167 if ((descriptionText == null) || descriptionText.trim()
170 Logger.debug("submittable", "Transaction not submittable: missing description");
174 for (int i = 0; i < this.items.size(); i++) {
175 Item item = this.items.get(i);
177 LedgerTransactionAccount acc = item.getAccount();
178 String acc_name = acc.getAccountName()
180 if (acc_name.isEmpty()) {
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()));
195 if (acc.isAmountSet()) {
197 balance += acc.getAmount();
200 itemsWithEmptyAmount.add(item);
202 if (!acc_name.isEmpty()) {
203 itemsWithAccountAndEmptyAmount.add(item);
208 // 1) has at least two account names
210 Logger.debug("submittable",
211 String.format("Transaction not submittable: only %d account names",
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);
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");
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);
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));
248 item.setAmountHint(null);
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)))
260 debug("submittable", submittable ? "YES" : "NO");
261 isSubmittable.setValue(submittable);
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(),
274 catch (NumberFormatException e) {
275 debug("submittable", "NO (because of NumberFormatException)");
276 isSubmittable.setValue(false);
278 catch (Exception e) {
280 debug("submittable", "NO (because of an Exception)");
281 isSubmittable.setValue(false);
284 public void removeItem(int pos) {
286 accountCount.setValue(getAccountCount());
288 public void sendCountNotifications() {
289 accountCount.setValue(getAccountCount());
291 public void sendFocusedNotification() {
292 focusedItem.setValue(focusedItem.getValue());
294 public void updateFocusedItem(int position) {
295 focusedItem.setValue(position);
297 public void noteFocusChanged(int position, FocusedElement element) {
298 getItem(position).setFocusedElement(element);
300 public void swapItems(int one, int two) {
301 Collections.swap(items, one-1, two-1);
303 public void toggleComment(int position) {
304 final MutableLiveData<Boolean> commentVisible = getItem(position).commentVisible;
305 commentVisible.postValue(!commentVisible.getValue());
307 enum ItemType {generalData, transactionRow, bottomFiller}
309 //==========================================================================================
311 enum FocusedElement {Account, Comment, Amount}
314 private ItemType type;
315 private MutableLiveData<Date> date = new MutableLiveData<>();
316 private MutableLiveData<String> description = new MutableLiveData<>();
317 private LedgerTransactionAccount account;
318 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
319 private NewTransactionModel model;
320 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
321 private FocusedElement focusedElement = FocusedElement.Account;
322 private MutableLiveData<String> comment = new MutableLiveData<>(null);
323 private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
324 public Item(NewTransactionModel model) {
326 type = ItemType.bottomFiller;
327 editable.setValue(false);
329 public Item(NewTransactionModel model, Date date, String description) {
331 this.type = ItemType.generalData;
332 this.date.setValue(date);
333 this.description.setValue(description);
334 this.editable.setValue(true);
336 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
338 this.type = ItemType.transactionRow;
339 this.account = account;
340 this.editable.setValue(true);
342 public FocusedElement getFocusedElement() {
343 return focusedElement;
345 public void setFocusedElement(FocusedElement focusedElement) {
346 this.focusedElement = focusedElement;
348 public NewTransactionModel getModel() {
351 public void setEditable(boolean editable) {
352 ensureType(ItemType.generalData, ItemType.transactionRow);
353 this.editable.setValue(editable);
355 private void ensureType(ItemType type1, ItemType type2) {
356 if ((type != type1) && (type != type2)) {
357 throw new RuntimeException(
358 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
362 public String getAmountHint() {
363 ensureType(ItemType.transactionRow);
364 return amountHint.getValue();
366 public void setAmountHint(String amountHint) {
367 ensureType(ItemType.transactionRow);
369 // avoid unnecessary triggers
370 if (amountHint == null) {
371 if (this.amountHint.getValue() == null)
375 if (amountHint.equals(this.amountHint.getValue()))
379 this.amountHint.setValue(amountHint);
381 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
383 androidx.lifecycle.Observer<? super String> observer) {
384 this.amountHint.observe(owner, observer);
386 public void stopObservingAmountHint(
387 @NonNull androidx.lifecycle.Observer<? super String> observer) {
388 this.amountHint.removeObserver(observer);
390 public ItemType getType() {
393 public void ensureType(ItemType wantedType) {
394 if (type != wantedType) {
395 throw new RuntimeException(
396 String.format("Actual type (%s) differs from wanted (%s)", type,
400 public Date getDate() {
401 ensureType(ItemType.generalData);
402 return date.getValue();
404 public void setDate(Date date) {
405 ensureType(ItemType.generalData);
406 this.date.setValue(date);
408 public void setDate(String text) {
409 if ((text == null) || text.trim()
412 setDate((Date) null);
416 int year, month, day;
417 final Calendar c = GregorianCalendar.getInstance();
418 Matcher m = reYMD.matcher(text);
420 year = Integer.parseInt(m.group(1));
421 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
422 day = Integer.parseInt(m.group(3));
425 year = c.get(Calendar.YEAR);
426 m = reMD.matcher(text);
428 month = Integer.parseInt(m.group(1)) - 1;
429 day = Integer.parseInt(m.group(2));
432 month = c.get(Calendar.MONTH);
433 m = reD.matcher(text);
435 day = Integer.parseInt(m.group(1));
438 day = c.get(Calendar.DAY_OF_MONTH);
443 c.set(year, month, day);
445 this.setDate(c.getTime());
447 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
448 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
449 this.date.observe(owner, observer);
451 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
452 this.date.removeObserver(observer);
454 public String getDescription() {
455 ensureType(ItemType.generalData);
456 return description.getValue();
458 public void setDescription(String description) {
459 ensureType(ItemType.generalData);
460 this.description.setValue(description);
462 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
464 androidx.lifecycle.Observer<? super String> observer) {
465 this.description.observe(owner, observer);
467 public void stopObservingDescription(
468 @NonNull androidx.lifecycle.Observer<? super String> observer) {
469 this.description.removeObserver(observer);
471 public LedgerTransactionAccount getAccount() {
472 ensureType(ItemType.transactionRow);
475 public void setAccountName(String name) {
476 account.setAccountName(name);
481 * @return nicely formatted, shortest available date representation
483 public String getFormattedDate() {
486 Date time = date.getValue();
490 Calendar c = GregorianCalendar.getInstance();
492 Calendar today = GregorianCalendar.getInstance();
494 final int myYear = c.get(Calendar.YEAR);
495 final int myMonth = c.get(Calendar.MONTH);
496 final int myDay = c.get(Calendar.DAY_OF_MONTH);
498 if (today.get(Calendar.YEAR) != myYear) {
499 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
502 if (today.get(Calendar.MONTH) != myMonth) {
503 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
506 return String.valueOf(myDay);
508 public void observeEditableFlag(NewTransactionActivity activity,
509 Observer<Boolean> observer) {
510 editable.observe(activity, observer);
512 public void stopObservingEditableFlag(Observer<Boolean> observer) {
513 editable.removeObserver(observer);
515 public void observeCommentVisible(NewTransactionActivity activity,
516 Observer<Boolean> observer) {
517 commentVisible.observe(activity, observer);
519 public void stopObservingCommentVisible(Observer<Boolean> observer) {
520 commentVisible.removeObserver(observer);
522 public void observeComment(NewTransactionActivity activity,
523 Observer<String> observer) {
524 comment.observe(activity, observer);
526 public void stopObservingComment(Observer<String> observer) {
527 comment.removeObserver(observer);
529 public void setComment(String comment) {
530 getAccount().setComment(comment);
531 this.comment.postValue(comment);