package net.ktnx.mobileledger.ui.activity;
+import android.annotation.SuppressLint;
+
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModel;
+import net.ktnx.mobileledger.BuildConfig;
import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.utils.Logger;
import net.ktnx.mobileledger.utils.Misc;
import org.jetbrains.annotations.NotNull;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
+import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static net.ktnx.mobileledger.utils.Logger.debug;
-import static net.ktnx.mobileledger.utils.Misc.isZero;
public class NewTransactionModel extends ViewModel {
static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
private final Item trailer = new Item(this);
private final ArrayList<Item> items = new ArrayList<>();
private final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
- private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(null);
+ private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
+ private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
+ public boolean getSimulateSave() {
+ return simulateSave.getValue();
+ }
+ public void setSimulateSave(boolean simulateSave) {
+ this.simulateSave.setValue(simulateSave);
+ }
+ public void toggleSimulateSave() {
+ simulateSave.setValue(!simulateSave.getValue());
+ }
+ public void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull
+ androidx.lifecycle.Observer<? super Boolean> observer) {
+ this.simulateSave.observe(owner, observer);
+ }
public int getAccountCount() {
return items.size();
}
@NonNull androidx.lifecycle.Observer<? super Integer> observer) {
this.accountCount.removeObserver(observer);
}
+ public int getFocusedItem() { return focusedItem.getValue(); }
public void setFocusedItem(int position) {
focusedItem.setValue(position);
}
boolean accountsInInitialState() {
for (Item item : items) {
LedgerTransactionAccount acc = item.getAccount();
- if (acc.isAmountSet()) return false;
+ if (acc.isAmountSet())
+ return false;
if (!acc.getAccountName()
.trim()
- .isEmpty()) return false;
+ .isEmpty())
+ return false;
}
return true;
return header;
}
- if (index <= items.size()) return items.get(index - 1);
+ if (index <= items.size())
+ return items.get(index - 1);
return trailer;
}
- // rules:
- // 1) at least two account names
- // 2) each amount must have account name
- // 3) amounts must balance to 0, or
- // 3a) there must be exactly one empty amount
- // 4) empty accounts with empty amounts are ignored
- // 5) a row with an empty account name or empty amount is guaranteed to exist
+ /*
+ A transaction is submittable if:
+ 0) has description
+ 1) has at least two account names
+ 2) each amount has account name
+ 3) amounts must balance to 0, or
+ 3a) there must be exactly one empty amount (with account)
+ 4) empty accounts with empty amounts are ignored
+ 5) a row with an empty account name or empty amount is guaranteed to exist
+ */
+ @SuppressLint("DefaultLocale")
public void checkTransactionSubmittable(NewTransactionItemsAdapter adapter) {
int accounts = 0;
- int accounts_with_values = 0;
int amounts = 0;
- int amounts_with_accounts = 0;
int empty_rows = 0;
- Item empty_amount = null;
- boolean single_empty_amount = false;
- boolean single_empty_amount_has_account = false;
- float running_total = 0f;
+ float balance = 0f;
final String descriptionText = getDescription();
- final boolean have_description = ((descriptionText != null) && !descriptionText.isEmpty());
+ boolean submittable = true;
+ List<Item> itemsWithEmptyAmount = new ArrayList<>();
+ List<Item> itemsWithAccountAndEmptyAmount = new ArrayList<>();
try {
+ if ((descriptionText == null) || descriptionText.trim()
+ .isEmpty())
+ {
+ Logger.debug("submittable", "Transaction not submittable: missing description");
+ submittable = false;
+ }
+
for (int i = 0; i < this.items.size(); i++) {
Item item = this.items.get(i);
LedgerTransactionAccount acc = item.getAccount();
String acc_name = acc.getAccountName()
.trim();
- if (!acc_name.isEmpty()) {
- accounts++;
+ if (acc_name.isEmpty()) {
+ empty_rows++;
if (acc.isAmountSet()) {
- accounts_with_values++;
+ // 2) each amount has account name
+ Logger.debug("submittable", String.format(
+ "Transaction not submittable: row %d has no account name, but has" +
+ " amount %1.2f", i + 1, acc.getAmount()));
+ submittable = false;
}
}
- else empty_rows++;
+ else {
+ accounts++;
+ }
- if (!acc.isAmountSet()) {
- if (empty_amount == null) {
- empty_amount = item;
- single_empty_amount = true;
- single_empty_amount_has_account = !acc_name.isEmpty();
- }
- else if (!acc_name.isEmpty()) single_empty_amount = false;
+ if (acc.isAmountSet()) {
+ amounts++;
+ balance += acc.getAmount();
}
else {
- amounts++;
- if (!acc_name.isEmpty()) amounts_with_accounts++;
- running_total += acc.getAmount();
+ itemsWithEmptyAmount.add(item);
+
+ if (!acc_name.isEmpty()) {
+ itemsWithAccountAndEmptyAmount.add(item);
+ }
+ }
+ }
+
+ // 1) has at least two account names
+ if (accounts < 2) {
+ Logger.debug("submittable",
+ String.format("Transaction not submittable: only %d account names",
+ accounts));
+ submittable = false;
+ }
+
+ // 3) amount must balance to 0, or
+ // 3a) there must be exactly one empty amount (with account)
+ if (Misc.isZero(balance)) {
+ for (Item item : items) {
+ item.setAmountHint(null);
+ }
+ }
+ else {
+ int balanceReceiversCount = itemsWithAccountAndEmptyAmount.size();
+ if (balanceReceiversCount != 1) {
+ Logger.debug("submittable", (balanceReceiversCount == 0) ?
+ "Transaction not submittable: non-zero balance " +
+ "with no empty amounts with accounts" :
+ "Transaction not submittable: non-zero balance " +
+ "with multiple empty amounts with accounts");
+ submittable = false;
+ }
+
+ // suggest off-balance amount to a row and remove hints on other rows
+ Item receiver = null;
+ if (!itemsWithAccountAndEmptyAmount.isEmpty())
+ receiver = itemsWithAccountAndEmptyAmount.get(0);
+ else if (!itemsWithEmptyAmount.isEmpty())
+ receiver = itemsWithEmptyAmount.get(0);
+
+ for (Item item : items) {
+ if (item.equals(receiver)) {
+ Logger.debug("submittable",
+ String.format("Setting amount hint to %1.2f", -balance));
+ item.setAmountHint(String.format("%1.2f", -balance));
+ }
+ else
+ item.setAmountHint(null);
}
}
+ // 5) a row with an empty account name or empty amount is guaranteed to exist
if ((empty_rows == 0) &&
((this.items.size() == accounts) || (this.items.size() == amounts)))
{
adapter.addRow();
}
- if (single_empty_amount) {
- empty_amount.setAmountHint(String.format(Locale.US, "%1.2f",
- Misc.isZero(running_total) ? 0f : -running_total));
- }
- debug("submittable", String.format(Locale.US,
- "%s, accounts=%d, accounts_with_values=%s, " +
- "amounts_with_accounts=%d, amounts=%d, running_total=%1.2f, " +
- "single_empty_with_acc=%s", have_description ? "description" : "NO description",
- accounts, accounts_with_values, amounts_with_accounts, amounts, running_total,
- (single_empty_amount && single_empty_amount_has_account) ? "true" : "false"));
+ debug("submittable", submittable ? "YES" : "NO");
+ isSubmittable.setValue(submittable);
- if (have_description && (accounts >= 2) && (accounts_with_values >= (accounts - 1)) &&
- (amounts_with_accounts == amounts) &&
- (single_empty_amount && single_empty_amount_has_account || isZero(running_total)))
- {
- debug("submittable", "YES");
- isSubmittable.setValue(true);
- }
- else {
- debug("submittable", "NO");
- isSubmittable.setValue(false);
+ if (BuildConfig.DEBUG) {
+ debug("submittable", "== Dump of all items");
+ for (int i = 0; i < items.size(); i++) {
+ Item item = items.get(i);
+ LedgerTransactionAccount acc = item.getAccount();
+ debug("submittable", String.format("Item %2d: [%4.2f] %s", i,
+ acc.isAmountSet() ? acc.getAmount() : 0, acc.getAccountName()));
+ }
}
-
}
catch (NumberFormatException e) {
debug("submittable", "NO (because of NumberFormatException)");
public void sendCountNotifications() {
accountCount.setValue(getAccountCount());
}
+ public void sendFocusedNotification() {
+ focusedItem.setValue(focusedItem.getValue());
+ }
+ public void updateFocusedItem(int position) {
+ focusedItem.setValue(position);
+ }
+ public void noteFocusIsOnAccount(int position) {
+ getItem(position).setFocusIsOnAmount(false);
+ }
+ public void noteFocusIsOnAmount(int position) {
+ getItem(position).setFocusIsOnAmount(true);
+ }
enum ItemType {generalData, transactionRow, bottomFiller}
+ //==========================================================================================
+
class Item extends Object {
private ItemType type;
private MutableLiveData<Date> date = new MutableLiveData<>();
private MutableLiveData<String> description = new MutableLiveData<>();
private LedgerTransactionAccount account;
- private MutableLiveData<String> amountHint = new MutableLiveData<>();
+ private MutableLiveData<String> amountHint = new MutableLiveData<>(null);
private NewTransactionModel model;
private MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
+ private boolean focusIsOnAmount = false;
public Item(NewTransactionModel model) {
this.model = model;
type = ItemType.bottomFiller;
this.account = account;
this.editable.setValue(true);
}
+ public boolean focusIsOnAmount() {
+ return focusIsOnAmount;
+ }
public NewTransactionModel getModel() {
return model;
}
- public boolean isEditable() {
- ensureType(ItemType.transactionRow);
- return this.editable.getValue();
- }
public void setEditable(boolean editable) {
- ensureType(ItemType.transactionRow);
+ ensureType(ItemType.generalData, ItemType.transactionRow);
this.editable.setValue(editable);
}
+ private void ensureType(ItemType type1, ItemType type2) {
+ if ((type != type1) && (type != type2)) {
+ throw new RuntimeException(
+ String.format("Actual type (%s) differs from wanted (%s or %s)", type,
+ type1, type2));
+ }
+ }
public String getAmountHint() {
ensureType(ItemType.transactionRow);
return amountHint.getValue();
}
public void setAmountHint(String amountHint) {
ensureType(ItemType.transactionRow);
+
+ // avoid unnecessary triggers
+ if (amountHint == null) {
+ if (this.amountHint.getValue() == null)
+ return;
+ }
+ else {
+ if (amountHint.equals(this.amountHint.getValue()))
+ return;
+ }
+
this.amountHint.setValue(amountHint);
}
public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
this.date.setValue(date);
}
public void setDate(String text) {
+ if ((text == null) || text.trim()
+ .isEmpty())
+ {
+ setDate((Date) null);
+ return;
+ }
+
int year, month, day;
final Calendar c = GregorianCalendar.getInstance();
Matcher m = reYMD.matcher(text);
* @return nicely formatted, shortest available date representation
*/
public String getFormattedDate() {
- if (date == null) return null;
+ if (date == null)
+ return null;
Date time = date.getValue();
- if (time == null) return null;
+ if (time == null)
+ return null;
Calendar c = GregorianCalendar.getInstance();
c.setTime(time);
final int myDay = c.get(Calendar.DAY_OF_MONTH);
if (today.get(Calendar.YEAR) != myYear) {
- return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth, myDay);
+ return String.format(Locale.US, "%d/%02d/%02d", myYear, myMonth + 1, myDay);
}
if (today.get(Calendar.MONTH) != myMonth) {
- return String.format(Locale.US, "%d/%02d", myMonth, myDay);
+ return String.format(Locale.US, "%d/%02d", myMonth + 1, myDay);
}
return String.valueOf(myDay);
public void stopObservingEditableFlag(Observer<Boolean> observer) {
editable.removeObserver(observer);
}
+ public void setFocusIsOnAmount(boolean flag) {
+ focusIsOnAmount = flag;
+ }
}
}