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", i,
269 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
273 catch (NumberFormatException e) {
274 debug("submittable", "NO (because of NumberFormatException)");
275 isSubmittable.setValue(false);
277 catch (Exception e) {
279 debug("submittable", "NO (because of an Exception)");
280 isSubmittable.setValue(false);
283 public void removeItem(int pos) {
285 accountCount.setValue(getAccountCount());
287 public void sendCountNotifications() {
288 accountCount.setValue(getAccountCount());
290 public void sendFocusedNotification() {
291 focusedItem.setValue(focusedItem.getValue());
293 public void updateFocusedItem(int position) {
294 focusedItem.setValue(position);
296 public void noteFocusIsOnAccount(int position) {
297 getItem(position).setFocusIsOnAmount(false);
299 public void noteFocusIsOnAmount(int position) {
300 getItem(position).setFocusIsOnAmount(true);
302 public void swapItems(int one, int two) {
303 Collections.swap(items, one-1, two-1);
305 enum ItemType {generalData, transactionRow, bottomFiller}
307 //==========================================================================================
309 class Item extends Object {
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 boolean focusIsOnAmount = false;
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 boolean focusIsOnAmount() {
337 return focusIsOnAmount;
339 public NewTransactionModel getModel() {
342 public void setEditable(boolean editable) {
343 ensureType(ItemType.generalData, ItemType.transactionRow);
344 this.editable.setValue(editable);
346 private void ensureType(ItemType type1, ItemType type2) {
347 if ((type != type1) && (type != type2)) {
348 throw new RuntimeException(
349 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
353 public String getAmountHint() {
354 ensureType(ItemType.transactionRow);
355 return amountHint.getValue();
357 public void setAmountHint(String amountHint) {
358 ensureType(ItemType.transactionRow);
360 // avoid unnecessary triggers
361 if (amountHint == null) {
362 if (this.amountHint.getValue() == null)
366 if (amountHint.equals(this.amountHint.getValue()))
370 this.amountHint.setValue(amountHint);
372 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
374 androidx.lifecycle.Observer<? super String> observer) {
375 this.amountHint.observe(owner, observer);
377 public void stopObservingAmountHint(
378 @NonNull androidx.lifecycle.Observer<? super String> observer) {
379 this.amountHint.removeObserver(observer);
381 public ItemType getType() {
384 public void ensureType(ItemType wantedType) {
385 if (type != wantedType) {
386 throw new RuntimeException(
387 String.format("Actual type (%s) differs from wanted (%s)", type,
391 public Date getDate() {
392 ensureType(ItemType.generalData);
393 return date.getValue();
395 public void setDate(Date date) {
396 ensureType(ItemType.generalData);
397 this.date.setValue(date);
399 public void setDate(String text) {
400 if ((text == null) || text.trim()
403 setDate((Date) null);
407 int year, month, day;
408 final Calendar c = GregorianCalendar.getInstance();
409 Matcher m = reYMD.matcher(text);
411 year = Integer.parseInt(m.group(1));
412 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
413 day = Integer.parseInt(m.group(3));
416 year = c.get(Calendar.YEAR);
417 m = reMD.matcher(text);
419 month = Integer.parseInt(m.group(1)) - 1;
420 day = Integer.parseInt(m.group(2));
423 month = c.get(Calendar.MONTH);
424 m = reD.matcher(text);
426 day = Integer.parseInt(m.group(1));
429 day = c.get(Calendar.DAY_OF_MONTH);
434 c.set(year, month, day);
436 this.setDate(c.getTime());
438 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
439 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
440 this.date.observe(owner, observer);
442 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
443 this.date.removeObserver(observer);
445 public String getDescription() {
446 ensureType(ItemType.generalData);
447 return description.getValue();
449 public void setDescription(String description) {
450 ensureType(ItemType.generalData);
451 this.description.setValue(description);
453 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
455 androidx.lifecycle.Observer<? super String> observer) {
456 this.description.observe(owner, observer);
458 public void stopObservingDescription(
459 @NonNull androidx.lifecycle.Observer<? super String> observer) {
460 this.description.removeObserver(observer);
462 public LedgerTransactionAccount getAccount() {
463 ensureType(ItemType.transactionRow);
466 public void setAccountName(String name) {
467 account.setAccountName(name);
472 * @return nicely formatted, shortest available date representation
474 public String getFormattedDate() {
477 Date time = date.getValue();
481 Calendar c = GregorianCalendar.getInstance();
483 Calendar today = GregorianCalendar.getInstance();
485 final int myYear = c.get(Calendar.YEAR);
486 final int myMonth = c.get(Calendar.MONTH);
487 final int myDay = c.get(Calendar.DAY_OF_MONTH);
489 if (today.get(Calendar.YEAR) != myYear) {
490 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
493 if (today.get(Calendar.MONTH) != myMonth) {
494 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
497 return String.valueOf(myDay);
499 public void observeEditableFlag(NewTransactionActivity activity,
500 Observer<Boolean> observer) {
501 editable.observe(activity, observer);
503 public void stopObservingEditableFlag(Observer<Boolean> observer) {
504 editable.removeObserver(observer);
506 public void setFocusIsOnAmount(boolean flag) {
507 focusIsOnAmount = flag;