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 enum ItemType {generalData, transactionRow, bottomFiller}
305 //==========================================================================================
307 enum FocusedElement {Account, Comment, Amount}
310 private ItemType type;
311 private MutableLiveData<Date> date = new MutableLiveData<>();
312 private MutableLiveData<String> description = new MutableLiveData<>();
313 private LedgerTransactionAccount account;
314 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
315 private NewTransactionModel model;
316 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
317 private FocusedElement focusedElement = FocusedElement.Account;
318 public Item(NewTransactionModel model) {
320 type = ItemType.bottomFiller;
321 editable.setValue(false);
323 public Item(NewTransactionModel model, Date date, String description) {
325 this.type = ItemType.generalData;
326 this.date.setValue(date);
327 this.description.setValue(description);
328 this.editable.setValue(true);
330 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
332 this.type = ItemType.transactionRow;
333 this.account = account;
334 this.editable.setValue(true);
336 public FocusedElement getFocusedElement() {
337 return focusedElement;
339 public void setFocusedElement(FocusedElement focusedElement) {
340 this.focusedElement = focusedElement;
342 public NewTransactionModel getModel() {
345 public void setEditable(boolean editable) {
346 ensureType(ItemType.generalData, ItemType.transactionRow);
347 this.editable.setValue(editable);
349 private void ensureType(ItemType type1, ItemType type2) {
350 if ((type != type1) && (type != type2)) {
351 throw new RuntimeException(
352 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
356 public String getAmountHint() {
357 ensureType(ItemType.transactionRow);
358 return amountHint.getValue();
360 public void setAmountHint(String amountHint) {
361 ensureType(ItemType.transactionRow);
363 // avoid unnecessary triggers
364 if (amountHint == null) {
365 if (this.amountHint.getValue() == null)
369 if (amountHint.equals(this.amountHint.getValue()))
373 this.amountHint.setValue(amountHint);
375 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
377 androidx.lifecycle.Observer<? super String> observer) {
378 this.amountHint.observe(owner, observer);
380 public void stopObservingAmountHint(
381 @NonNull androidx.lifecycle.Observer<? super String> observer) {
382 this.amountHint.removeObserver(observer);
384 public ItemType getType() {
387 public void ensureType(ItemType wantedType) {
388 if (type != wantedType) {
389 throw new RuntimeException(
390 String.format("Actual type (%s) differs from wanted (%s)", type,
394 public Date getDate() {
395 ensureType(ItemType.generalData);
396 return date.getValue();
398 public void setDate(Date date) {
399 ensureType(ItemType.generalData);
400 this.date.setValue(date);
402 public void setDate(String text) {
403 if ((text == null) || text.trim()
406 setDate((Date) null);
410 int year, month, day;
411 final Calendar c = GregorianCalendar.getInstance();
412 Matcher m = reYMD.matcher(text);
414 year = Integer.parseInt(m.group(1));
415 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
416 day = Integer.parseInt(m.group(3));
419 year = c.get(Calendar.YEAR);
420 m = reMD.matcher(text);
422 month = Integer.parseInt(m.group(1)) - 1;
423 day = Integer.parseInt(m.group(2));
426 month = c.get(Calendar.MONTH);
427 m = reD.matcher(text);
429 day = Integer.parseInt(m.group(1));
432 day = c.get(Calendar.DAY_OF_MONTH);
437 c.set(year, month, day);
439 this.setDate(c.getTime());
441 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
442 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
443 this.date.observe(owner, observer);
445 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
446 this.date.removeObserver(observer);
448 public String getDescription() {
449 ensureType(ItemType.generalData);
450 return description.getValue();
452 public void setDescription(String description) {
453 ensureType(ItemType.generalData);
454 this.description.setValue(description);
456 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
458 androidx.lifecycle.Observer<? super String> observer) {
459 this.description.observe(owner, observer);
461 public void stopObservingDescription(
462 @NonNull androidx.lifecycle.Observer<? super String> observer) {
463 this.description.removeObserver(observer);
465 public LedgerTransactionAccount getAccount() {
466 ensureType(ItemType.transactionRow);
469 public void setAccountName(String name) {
470 account.setAccountName(name);
475 * @return nicely formatted, shortest available date representation
477 public String getFormattedDate() {
480 Date time = date.getValue();
484 Calendar c = GregorianCalendar.getInstance();
486 Calendar today = GregorianCalendar.getInstance();
488 final int myYear = c.get(Calendar.YEAR);
489 final int myMonth = c.get(Calendar.MONTH);
490 final int myDay = c.get(Calendar.DAY_OF_MONTH);
492 if (today.get(Calendar.YEAR) != myYear) {
493 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
496 if (today.get(Calendar.MONTH) != myMonth) {
497 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
500 return String.valueOf(myDay);
502 public void observeEditableFlag(NewTransactionActivity activity,
503 Observer<Boolean> observer) {
504 editable.observe(activity, observer);
506 public void stopObservingEditableFlag(Observer<Boolean> observer) {
507 editable.removeObserver(observer);