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.Date;
38 import java.util.GregorianCalendar;
39 import java.util.List;
40 import java.util.Locale;
41 import java.util.regex.Matcher;
42 import java.util.regex.Pattern;
44 import static net.ktnx.mobileledger.utils.Logger.debug;
46 public class NewTransactionModel extends ViewModel {
47 static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
48 static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
49 static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$");
50 private final Item header = new Item(this, null, "");
51 private final Item trailer = new Item(this);
52 private final ArrayList<Item> items = new ArrayList<>();
53 private final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
54 private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
55 private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
56 public int getAccountCount() {
59 public Date getDate() {
60 return header.date.getValue();
62 public String getDescription() {
63 return header.description.getValue();
65 public LiveData<Boolean> isSubmittable() {
66 return this.isSubmittable;
69 header.date.setValue(null);
70 header.description.setValue(null);
72 items.add(new Item(this, new LedgerTransactionAccount("")));
73 items.add(new Item(this, new LedgerTransactionAccount("")));
74 focusedItem.setValue(0);
76 public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
77 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
78 this.focusedItem.observe(owner, observer);
80 public void stopObservingFocusedItem(
81 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
82 this.focusedItem.removeObserver(observer);
84 public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
86 androidx.lifecycle.Observer<? super Integer> observer) {
87 this.accountCount.observe(owner, observer);
89 public void stopObservingAccountCount(
90 @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
91 this.accountCount.removeObserver(observer);
93 public int getFocusedItem() { return focusedItem.getValue(); }
94 public void setFocusedItem(int position) {
95 focusedItem.setValue(position);
97 public int addAccount(LedgerTransactionAccount acc) {
98 items.add(new Item(this, acc));
99 accountCount.setValue(getAccountCount());
102 boolean accountsInInitialState() {
103 for (Item item : items) {
104 LedgerTransactionAccount acc = item.getAccount();
105 if (acc.isAmountSet())
107 if (!acc.getAccountName()
115 LedgerTransactionAccount getAccount(int index) {
116 return items.get(index)
119 public Item getItem(int index) {
124 if (index <= items.size())
125 return items.get(index - 1);
130 A transaction is submittable if:
132 1) has at least two account names
133 2) each amount has account name
134 3) amounts must balance to 0, or
135 3a) there must be exactly one empty amount (with account)
136 4) empty accounts with empty amounts are ignored
137 5) a row with an empty account name or empty amount is guaranteed to exist
139 @SuppressLint("DefaultLocale")
140 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
145 final String descriptionText = getDescription();
146 boolean submittable = true;
147 List<Item> itemsWithEmptyAmount = new ArrayList<>();
148 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
151 if ((descriptionText == null) || descriptionText.trim()
154 Logger.debug("submittable", "Transaction not submittable: missing description");
158 for (int i = 0; i < this.items.size(); i++) {
159 Item item = this.items.get(i);
161 LedgerTransactionAccount acc = item.getAccount();
162 String acc_name = acc.getAccountName()
164 if (acc_name.isEmpty()) {
167 if (acc.isAmountSet()) {
168 // 2) each amount has account name
169 Logger.debug("submittable", String.format(
170 "Transaction not submittable: row %d has no account name, but has" +
171 " amount %1.2f", i + 1, acc.getAmount()));
179 if (acc.isAmountSet()) {
181 balance += acc.getAmount();
184 itemsWithEmptyAmount.add(item);
186 if (!acc_name.isEmpty()) {
187 itemsWithAccountAndEmptyAmount.add(item);
192 // 1) has at least two account names
194 Logger.debug("submittable",
195 String.format("Transaction not submittable: only %d account names",
200 // 3) amount must balance to 0, or
201 // 3a) there must be exactly one empty amount (with account)
202 if (Misc.isZero(balance)) {
203 for (Item item : items) {
204 item.setAmountHint(null);
208 int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
209 if (balanceReceiversCount != 1) {
210 Logger.debug("submittable", (balanceReceiversCount == 0) ?
211 "Transaction not submittable: non-zero balance " +
212 "with no empty amounts with accounts" :
213 "Transaction not submittable: non-zero balance " +
214 "with multiple empty amounts with accounts");
218 // suggest off-balance amount to a row and remove hints on other rows
219 Item receiver = null;
220 if (!itemsWithAccountAndEmptyAmount.isEmpty())
221 receiver = itemsWithAccountAndEmptyAmount.get(0);
222 else if (!itemsWithEmptyAmount.isEmpty())
223 receiver = itemsWithEmptyAmount.get(0);
225 for (Item item : items) {
226 if (item.equals(receiver)) {
227 Logger.debug("submittable",
228 String.format("Setting amount hint to %1.2f", -balance));
229 item.setAmountHint(String.format("%1.2f", -balance));
232 item.setAmountHint(null);
236 // 5) a row with an empty account name or empty amount is guaranteed to exist
237 if ((empty_rows == 0) &&
238 ((this.items.size() == accounts) || (this.items.size() == amounts)))
244 debug("submittable", submittable ? "YES" : "NO");
245 isSubmittable.setValue(submittable);
247 if (BuildConfig.DEBUG) {
248 debug("submittable", "== Dump of all items");
249 for (int i = 0; i < items.size(); i++) {
250 Item item = items.get(i);
251 LedgerTransactionAccount acc = item.getAccount();
252 debug("submittable", String.format("Item %2d: [%4.2f] %s", i,
253 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
257 catch (NumberFormatException e) {
258 debug("submittable", "NO (because of NumberFormatException)");
259 isSubmittable.setValue(false);
261 catch (Exception e) {
263 debug("submittable", "NO (because of an Exception)");
264 isSubmittable.setValue(false);
267 public void removeItem(int pos) {
269 accountCount.setValue(getAccountCount());
271 public void sendCountNotifications() {
272 accountCount.setValue(getAccountCount());
274 public void sendFocusedNotification() {
275 focusedItem.setValue(focusedItem.getValue());
277 public void updateFocusedItem(int position) {
278 focusedItem.setValue(position);
280 public void noteFocusIsOnAccount(int position) {
281 getItem(position).setFocusIsOnAmount(false);
283 public void noteFocusIsOnAmount(int position) {
284 getItem(position).setFocusIsOnAmount(true);
286 enum ItemType {generalData, transactionRow, bottomFiller}
288 //==========================================================================================
290 class Item extends Object {
291 private ItemType type;
292 private MutableLiveData<Date> date = new MutableLiveData<>();
293 private MutableLiveData<String> description = new MutableLiveData<>();
294 private LedgerTransactionAccount account;
295 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
296 private NewTransactionModel model;
297 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
298 private boolean focusIsOnAmount = false;
299 public Item(NewTransactionModel model) {
301 type = ItemType.bottomFiller;
302 editable.setValue(false);
304 public Item(NewTransactionModel model, Date date, String description) {
306 this.type = ItemType.generalData;
307 this.date.setValue(date);
308 this.description.setValue(description);
309 this.editable.setValue(true);
311 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
313 this.type = ItemType.transactionRow;
314 this.account = account;
315 this.editable.setValue(true);
317 public boolean focusIsOnAmount() {
318 return focusIsOnAmount;
320 public NewTransactionModel getModel() {
323 public void setEditable(boolean editable) {
324 ensureType(ItemType.generalData, ItemType.transactionRow);
325 this.editable.setValue(editable);
327 private void ensureType(ItemType type1, ItemType type2) {
328 if ((type != type1) && (type != type2)) {
329 throw new RuntimeException(
330 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
334 public String getAmountHint() {
335 ensureType(ItemType.transactionRow);
336 return amountHint.getValue();
338 public void setAmountHint(String amountHint) {
339 ensureType(ItemType.transactionRow);
341 // avoid unnecessary triggers
342 if (amountHint == null) {
343 if (this.amountHint.getValue() == null)
347 if (amountHint.equals(this.amountHint.getValue()))
351 this.amountHint.setValue(amountHint);
353 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
355 androidx.lifecycle.Observer<? super String> observer) {
356 this.amountHint.observe(owner, observer);
358 public void stopObservingAmountHint(
359 @NonNull androidx.lifecycle.Observer<? super String> observer) {
360 this.amountHint.removeObserver(observer);
362 public ItemType getType() {
365 public void ensureType(ItemType wantedType) {
366 if (type != wantedType) {
367 throw new RuntimeException(
368 String.format("Actual type (%s) differs from wanted (%s)", type,
372 public Date getDate() {
373 ensureType(ItemType.generalData);
374 return date.getValue();
376 public void setDate(Date date) {
377 ensureType(ItemType.generalData);
378 this.date.setValue(date);
380 public void setDate(String text) {
381 if ((text == null) || text.trim()
384 setDate((Date) null);
388 int year, month, day;
389 final Calendar c = GregorianCalendar.getInstance();
390 Matcher m = reYMD.matcher(text);
392 year = Integer.parseInt(m.group(1));
393 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
394 day = Integer.parseInt(m.group(3));
397 year = c.get(Calendar.YEAR);
398 m = reMD.matcher(text);
400 month = Integer.parseInt(m.group(1)) - 1;
401 day = Integer.parseInt(m.group(2));
404 month = c.get(Calendar.MONTH);
405 m = reD.matcher(text);
407 day = Integer.parseInt(m.group(1));
410 day = c.get(Calendar.DAY_OF_MONTH);
415 c.set(year, month, day);
417 this.setDate(c.getTime());
419 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
420 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
421 this.date.observe(owner, observer);
423 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
424 this.date.removeObserver(observer);
426 public String getDescription() {
427 ensureType(ItemType.generalData);
428 return description.getValue();
430 public void setDescription(String description) {
431 ensureType(ItemType.generalData);
432 this.description.setValue(description);
434 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
436 androidx.lifecycle.Observer<? super String> observer) {
437 this.description.observe(owner, observer);
439 public void stopObservingDescription(
440 @NonNull androidx.lifecycle.Observer<? super String> observer) {
441 this.description.removeObserver(observer);
443 public LedgerTransactionAccount getAccount() {
444 ensureType(ItemType.transactionRow);
447 public void setAccountName(String name) {
448 account.setAccountName(name);
453 * @return nicely formatted, shortest available date representation
455 public String getFormattedDate() {
458 Date time = date.getValue();
462 Calendar c = GregorianCalendar.getInstance();
464 Calendar today = GregorianCalendar.getInstance();
466 final int myYear = c.get(Calendar.YEAR);
467 final int myMonth = c.get(Calendar.MONTH);
468 final int myDay = c.get(Calendar.DAY_OF_MONTH);
470 if (today.get(Calendar.YEAR) != myYear) {
471 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
474 if (today.get(Calendar.MONTH) != myMonth) {
475 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
478 return String.valueOf(myDay);
480 public void observeEditableFlag(NewTransactionActivity activity,
481 Observer<Boolean> observer) {
482 editable.observe(activity, observer);
484 public void stopObservingEditableFlag(Observer<Boolean> observer) {
485 editable.removeObserver(observer);
487 public void setFocusIsOnAmount(boolean flag) {
488 focusIsOnAmount = flag;