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<>(null);
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 void setFocusedItem(int position) {
94 focusedItem.setValue(position);
96 public int addAccount(LedgerTransactionAccount acc) {
97 items.add(new Item(this, acc));
98 accountCount.setValue(getAccountCount());
101 boolean accountsInInitialState() {
102 for (Item item : items) {
103 LedgerTransactionAccount acc = item.getAccount();
104 if (acc.isAmountSet())
106 if (!acc.getAccountName()
114 LedgerTransactionAccount getAccount(int index) {
115 return items.get(index)
118 public Item getItem(int index) {
123 if (index <= items.size())
124 return items.get(index - 1);
129 A transaction is submittable if:
131 1) has at least two account names
132 2) each amount has account name
133 3) amounts must balance to 0, or
134 3a) there must be exactly one empty amount (with account)
135 4) empty accounts with empty amounts are ignored
136 5) a row with an empty account name or empty amount is guaranteed to exist
138 @SuppressLint("DefaultLocale")
139 public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
144 final String descriptionText = getDescription();
145 boolean submittable = true;
146 List<Item> itemsWithEmptyAmount = new ArrayList<>();
147 List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
150 if ((descriptionText == null) || descriptionText.trim()
153 Logger.debug("submittable", "Transaction not submittable: missing description");
157 for (int i = 0; i < this.items.size(); i++) {
158 Item item = this.items.get(i);
160 LedgerTransactionAccount acc = item.getAccount();
161 String acc_name = acc.getAccountName()
163 if (acc_name.isEmpty()) {
166 if (acc.isAmountSet()) {
167 // 2) each amount has account name
168 Logger.debug("submittable", String.format(
169 "Transaction not submittable: row %d has no account name, but has" +
170 " amount %1.2f", i + 1, acc.getAmount()));
178 if (acc.isAmountSet()) {
180 balance += acc.getAmount();
183 itemsWithEmptyAmount.add(item);
185 if (!acc_name.isEmpty()) {
186 itemsWithAccountAndEmptyAmount.add(item);
191 // 1) has at least two account names
193 Logger.debug("submittable",
194 String.format("Transaction not submittable: only %d account names",
199 // 3) amount must balance to 0, or
200 // 3a) there must be exactly one empty amount (with account)
201 if (Misc.isZero(balance)) {
202 for (Item item : items) {
203 item.setAmountHint(null);
207 int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
208 if (balanceReceiversCount != 1) {
209 Logger.debug("submittable", (balanceReceiversCount == 0) ?
210 "Transaction not submittable: non-zero balance " +
211 "with no empty amounts with accounts" :
212 "Transaction not submittable: non-zero balance " +
213 "with multiple empty amounts with accounts");
217 // suggest off-balance amount to a row and remove hints on other rows
218 Item receiver = null;
219 if (!itemsWithAccountAndEmptyAmount.isEmpty())
220 receiver = itemsWithAccountAndEmptyAmount.get(0);
221 else if (!itemsWithEmptyAmount.isEmpty())
222 receiver = itemsWithEmptyAmount.get(0);
224 for (Item item : items) {
225 if (item.equals(receiver)) {
226 Logger.debug("submittable",
227 String.format("Setting amount hint to %1.2f", -balance));
228 item.setAmountHint(String.format("%1.2f", -balance));
231 item.setAmountHint(null);
235 // 5) a row with an empty account name or empty amount is guaranteed to exist
236 if ((empty_rows == 0) &&
237 ((this.items.size() == accounts) || (this.items.size() == amounts)))
243 debug("submittable", submittable ? "YES" : "NO");
244 isSubmittable.setValue(submittable);
246 if (BuildConfig.DEBUG) {
247 debug("submittable", "== Dump of all items");
248 for (int i = 0; i < items.size(); i++) {
249 Item item = items.get(i);
250 LedgerTransactionAccount acc = item.getAccount();
251 debug("submittable", String.format("Item %2d: [%4.2f] %s", i,
252 acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
256 catch (NumberFormatException e) {
257 debug("submittable", "NO (because of NumberFormatException)");
258 isSubmittable.setValue(false);
260 catch (Exception e) {
262 debug("submittable", "NO (because of an Exception)");
263 isSubmittable.setValue(false);
266 public void removeItem(int pos) {
268 accountCount.setValue(getAccountCount());
270 public void sendCountNotifications() {
271 accountCount.setValue(getAccountCount());
273 enum ItemType {generalData, transactionRow, bottomFiller}
275 //==========================================================================================
277 class Item extends Object {
278 private ItemType type;
279 private MutableLiveData<Date> date = new MutableLiveData<>();
280 private MutableLiveData<String> description = new MutableLiveData<>();
281 private LedgerTransactionAccount account;
282 private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
283 private NewTransactionModel model;
284 private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
285 public Item(NewTransactionModel model) {
287 type = ItemType.bottomFiller;
288 editable.setValue(false);
290 public Item(NewTransactionModel model, Date date, String description) {
292 this.type = ItemType.generalData;
293 this.date.setValue(date);
294 this.description.setValue(description);
295 this.editable.setValue(true);
297 public Item(NewTransactionModel model, LedgerTransactionAccount account) {
299 this.type = ItemType.transactionRow;
300 this.account = account;
301 this.editable.setValue(true);
303 public NewTransactionModel getModel() {
306 public void setEditable(boolean editable) {
307 ensureType(ItemType.generalData, ItemType.transactionRow);
308 this.editable.setValue(editable);
310 private void ensureType(ItemType type1, ItemType type2) {
311 if ((type != type1) && (type != type2)) {
312 throw new RuntimeException(
313 String.format("Actual type (%s) differs from wanted (%s or %s)", type,
317 public String getAmountHint() {
318 ensureType(ItemType.transactionRow);
319 return amountHint.getValue();
321 public void setAmountHint(String amountHint) {
322 ensureType(ItemType.transactionRow);
324 // avoid unnecessary triggers
325 if (amountHint == null) {
326 if (this.amountHint.getValue() == null)
330 if (amountHint.equals(this.amountHint.getValue()))
334 this.amountHint.setValue(amountHint);
336 public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
338 androidx.lifecycle.Observer<? super String> observer) {
339 this.amountHint.observe(owner, observer);
341 public void stopObservingAmountHint(
342 @NonNull androidx.lifecycle.Observer<? super String> observer) {
343 this.amountHint.removeObserver(observer);
345 public ItemType getType() {
348 public void ensureType(ItemType wantedType) {
349 if (type != wantedType) {
350 throw new RuntimeException(
351 String.format("Actual type (%s) differs from wanted (%s)", type,
355 public Date getDate() {
356 ensureType(ItemType.generalData);
357 return date.getValue();
359 public void setDate(Date date) {
360 ensureType(ItemType.generalData);
361 this.date.setValue(date);
363 public void setDate(String text) {
364 int year, month, day;
365 final Calendar c = GregorianCalendar.getInstance();
366 Matcher m = reYMD.matcher(text);
368 year = Integer.parseInt(m.group(1));
369 month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
370 day = Integer.parseInt(m.group(3));
373 year = c.get(Calendar.YEAR);
374 m = reMD.matcher(text);
376 month = Integer.parseInt(m.group(1)) - 1;
377 day = Integer.parseInt(m.group(2));
380 month = c.get(Calendar.MONTH);
381 m = reD.matcher(text);
383 day = Integer.parseInt(m.group(1));
386 day = c.get(Calendar.DAY_OF_MONTH);
391 c.set(year, month, day);
393 this.setDate(c.getTime());
395 public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
396 @NonNull androidx.lifecycle.Observer<? super Date> observer) {
397 this.date.observe(owner, observer);
399 public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
400 this.date.removeObserver(observer);
402 public String getDescription() {
403 ensureType(ItemType.generalData);
404 return description.getValue();
406 public void setDescription(String description) {
407 ensureType(ItemType.generalData);
408 this.description.setValue(description);
410 public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
412 androidx.lifecycle.Observer<? super String> observer) {
413 this.description.observe(owner, observer);
415 public void stopObservingDescription(
416 @NonNull androidx.lifecycle.Observer<? super String> observer) {
417 this.description.removeObserver(observer);
419 public LedgerTransactionAccount getAccount() {
420 ensureType(ItemType.transactionRow);
423 public void setAccountName(String name) {
424 account.setAccountName(name);
429 * @return nicely formatted, shortest available date representation
431 public String getFormattedDate() {
434 Date time = date.getValue();
438 Calendar c = GregorianCalendar.getInstance();
440 Calendar today = GregorianCalendar.getInstance();
442 final int myYear = c.get(Calendar.YEAR);
443 final int myMonth = c.get(Calendar.MONTH);
444 final int myDay = c.get(Calendar.DAY_OF_MONTH);
446 if (today.get(Calendar.YEAR) != myYear) {
447 return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
450 if (today.get(Calendar.MONTH) != myMonth) {
451 return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
454 return String.valueOf(myDay);
456 public void observeEditableFlag(NewTransactionActivity activity,
457 Observer<Boolean> observer) {
458 editable.observe(activity, observer);
460 public void stopObservingEditableFlag(Observer<Boolean> observer) {
461 editable.removeObserver(observer);