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 public void moveItemLast(int index) {
311 3 <-- desired position
313 int itemCount = items.size();
315 if (index < itemCount - 1) {
316 Item acc = items.remove(index);
317 items.add(itemCount - 1, acc);
320 enum ItemType {generalData, transactionRow, bottomFiller}
322 //==========================================================================================
324 enum FocusedElement {Account, Comment, Amount}
327 private ItemType type;
328 private MutableLiveData<Date> date = new MutableLiveData<>();
329 private MutableLiveData<String> description = new MutableLiveData<>();
330 private LedgerTransactionAccount account;
331 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
332 private NewTransactionModel model;
333 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
334 private FocusedElement focusedElement = FocusedElement.Account;
335 private MutableLiveData<String> comment = new MutableLiveData<>(null);
336 private MutableLiveData<Boolean> commentVisible = new MutableLiveData<>(false);
337 public Item(NewTransactionModel model) {
339 type = ItemType.bottomFiller;
340 editable.setValue(false);
342 public Item(NewTransactionModel model, Date date, String description) {
344 this.type = ItemType.generalData;
345 this.date.setValue(date);
346 this.description.setValue(description);
347 this.editable.setValue(true);
349 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
351 this.type = ItemType.transactionRow;
352 this.account = account;
353 this.editable.setValue(true);
355 public FocusedElement getFocusedElement() {
356 return focusedElement;
358 public void setFocusedElement(FocusedElement focusedElement) {
359 this.focusedElement = focusedElement;
361 public NewTransactionModel getModel() {
364 public void setEditable(boolean editable) {
365 ensureType(ItemType.generalData, ItemType.transactionRow);
366 this.editable.setValue(editable);
368 private void ensureType(ItemType type1, ItemType type2) {
369 if ((type != type1) && (type != type2)) {
370 throw new RuntimeException(
371 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
375 public String getAmountHint() {
376 ensureType(ItemType.transactionRow);
377 return amountHint.getValue();
379 public void setAmountHint(String amountHint) {
380 ensureType(ItemType.transactionRow);
382 // avoid unnecessary triggers
383 if (amountHint == null) {
384 if (this.amountHint.getValue() == null)
388 if (amountHint.equals(this.amountHint.getValue()))
392 this.amountHint.setValue(amountHint);
394 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
396 androidx.lifecycle.Observer<? super String> observer) {
397 this.amountHint.observe(owner, observer);
399 public void stopObservingAmountHint(
400 @NonNull androidx.lifecycle.Observer<? super String> observer) {
401 this.amountHint.removeObserver(observer);
403 public ItemType getType() {
406 public void ensureType(ItemType wantedType) {
407 if (type != wantedType) {
408 throw new RuntimeException(
409 String.format("Actual type (%s) differs from wanted (%s)", type,
413 public Date getDate() {
414 ensureType(ItemType.generalData);
415 return date.getValue();
417 public void setDate(Date date) {
418 ensureType(ItemType.generalData);
419 this.date.setValue(date);
421 public void setDate(String text) {
422 if ((text == null) || text.trim()
425 setDate((Date) null);
429 int year, month, day;
430 final Calendar c = GregorianCalendar.getInstance();
431 Matcher m = reYMD.matcher(text);
433 year = Integer.parseInt(m.group(1));
434 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
435 day = Integer.parseInt(m.group(3));
438 year = c.get(Calendar.YEAR);
439 m = reMD.matcher(text);
441 month = Integer.parseInt(m.group(1)) - 1;
442 day = Integer.parseInt(m.group(2));
445 month = c.get(Calendar.MONTH);
446 m = reD.matcher(text);
448 day = Integer.parseInt(m.group(1));
451 day = c.get(Calendar.DAY_OF_MONTH);
456 c.set(year, month, day);
458 this.setDate(c.getTime());
460 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
461 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
462 this.date.observe(owner, observer);
464 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
465 this.date.removeObserver(observer);
467 public String getDescription() {
468 ensureType(ItemType.generalData);
469 return description.getValue();
471 public void setDescription(String description) {
472 ensureType(ItemType.generalData);
473 this.description.setValue(description);
475 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
477 androidx.lifecycle.Observer<? super String> observer) {
478 this.description.observe(owner, observer);
480 public void stopObservingDescription(
481 @NonNull androidx.lifecycle.Observer<? super String> observer) {
482 this.description.removeObserver(observer);
484 public LedgerTransactionAccount getAccount() {
485 ensureType(ItemType.transactionRow);
488 public void setAccountName(String name) {
489 account.setAccountName(name);
494 * @return nicely formatted, shortest available date representation
496 public String getFormattedDate() {
499 Date time = date.getValue();
503 Calendar c = GregorianCalendar.getInstance();
505 Calendar today = GregorianCalendar.getInstance();
507 final int myYear = c.get(Calendar.YEAR);
508 final int myMonth = c.get(Calendar.MONTH);
509 final int myDay = c.get(Calendar.DAY_OF_MONTH);
511 if (today.get(Calendar.YEAR) != myYear) {
512 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
515 if (today.get(Calendar.MONTH) != myMonth) {
516 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
519 return String.valueOf(myDay);
521 public void observeEditableFlag(NewTransactionActivity activity,
522 Observer<Boolean> observer) {
523 editable.observe(activity, observer);
525 public void stopObservingEditableFlag(Observer<Boolean> observer) {
526 editable.removeObserver(observer);
528 public void observeCommentVisible(NewTransactionActivity activity,
529 Observer<Boolean> observer) {
530 commentVisible.observe(activity, observer);
532 public void stopObservingCommentVisible(Observer<Boolean> observer) {
533 commentVisible.removeObserver(observer);
535 public void observeComment(NewTransactionActivity activity,
536 Observer<String> observer) {
537 comment.observe(activity, observer);
539 public void stopObservingComment(Observer<String> observer) {
540 comment.removeObserver(observer);
542 public void setComment(String comment) {
543 getAccount().setComment(comment);
544 this.comment.postValue(comment);