package net.ktnx.mobileledger.ui;
-import android.app.Activity;
import android.app.Dialog;
import android.os.Bundle;
-import android.view.WindowManager;
import android.widget.CalendarView;
import android.widget.TextView;
import java.util.Calendar;
import java.util.GregorianCalendar;
-import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
static final Pattern reYMD = Pattern.compile("^\\s*(\\d+)\\d*/\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$");
+ private DatePickedListener onDatePickedListener;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return dpd;
}
- private void updateDateInput(int year, int month, int day) {
- TextView date =
- Objects.requireNonNull(getActivity()).findViewById(R.id.new_transaction_date);
-
- final Calendar c = GregorianCalendar.getInstance();
- if (c.get(GregorianCalendar.YEAR) == year) {
- if (c.get(GregorianCalendar.MONTH) == month)
- date.setText(String.format(Locale.US, "%d", day));
- else date.setText(String.format(Locale.US, "%d/%d", month + 1, day));
- }
- else date.setText(String.format(Locale.US, "%d/%d/%d", year, month + 1, day));
-
- Activity activity = getActivity();
- if (activity == null) return;
-
- TextView description = activity.findViewById(R.id.new_transaction_description);
- boolean tookFocus = description.requestFocus();
- if (tookFocus) activity.getWindow()
- .setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
- }
@Override
public void onSelectedDayChange(@NonNull CalendarView view, int year, int month,
int dayOfMonth) {
- updateDateInput(year, month, dayOfMonth);
+ if (onDatePickedListener != null)
+ onDatePickedListener.onDatePicked(year, month, dayOfMonth);
this.dismiss();
}
+ public void setOnDatePickedListener(DatePickedListener listener) {
+ onDatePickedListener = listener;
+ }
+ public interface DatePickedListener {
+ void onDatePicked(int year, int month, int day);
+ }
}
package net.ktnx.mobileledger.ui.activity;
-import android.annotation.SuppressLint;
-import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
-import android.text.Editable;
-import android.text.InputType;
-import android.text.TextWatcher;
import android.util.TypedValue;
-import android.view.Gravity;
import android.view.Menu;
import android.view.MenuItem;
-import android.view.MotionEvent;
import android.view.View;
-import android.view.inputmethod.EditorInfo;
-import android.widget.AutoCompleteTextView;
-import android.widget.EditText;
import android.widget.ProgressBar;
-import android.widget.TableLayout;
-import android.widget.TableRow;
-import android.widget.TextView;
-import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.BaseTransientBottomBar;
import com.google.android.material.snackbar.Snackbar;
-import net.ktnx.mobileledger.App;
import net.ktnx.mobileledger.BuildConfig;
import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
import net.ktnx.mobileledger.async.SendTransactionTask;
import net.ktnx.mobileledger.async.TaskCallback;
import net.ktnx.mobileledger.model.Data;
import net.ktnx.mobileledger.model.LedgerTransaction;
import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear;
-import net.ktnx.mobileledger.ui.DatePickerFragment;
-import net.ktnx.mobileledger.ui.OnSwipeTouchListener;
-import net.ktnx.mobileledger.utils.Globals;
-import net.ktnx.mobileledger.utils.MLDB;
-import java.text.ParseException;
-import java.util.ArrayList;
import java.util.Date;
-import java.util.Locale;
import java.util.Objects;
-import androidx.appcompat.widget.Toolbar;
-import androidx.fragment.app.DialogFragment;
-
import static net.ktnx.mobileledger.utils.Logger.debug;
/*
* (the last problem with the POST was the missing content-length header)
* */
-public class NewTransactionActivity extends ProfileThemedActivity
- implements TaskCallback, DescriptionSelectedCallback {
+public class NewTransactionActivity extends ProfileThemedActivity implements TaskCallback {
private static SendTransactionTask saver;
- private TableLayout table;
private ProgressBar progress;
private FloatingActionButton fab;
- private TextView tvDate;
- private AutoCompleteTextView tvDescription;
- private static boolean isZero(float f) {
- return (f < 0.005) && (f > -0.005);
- }
+ private NewTransactionItemsAdapter listAdapter;
+ private NewTransactionModel viewModel;
+ private RecyclerView list;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_new_transaction);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
- toolbar.setSubtitle(mProfile.getName());
-
- tvDate = findViewById(R.id.new_transaction_date);
- tvDate.setOnFocusChangeListener((v, hasFocus) -> {
- if (hasFocus) pickTransactionDate(v);
- });
- tvDescription = findViewById(R.id.new_transaction_description);
- MLDB.hookAutocompletionAdapter(this, tvDescription, MLDB.DESCRIPTION_HISTORY_TABLE,
- "description", false, this, mProfile);
- hookTextChangeListener(tvDescription);
+ Data.profile.observe(this,
+ mobileLedgerProfile -> toolbar.setSubtitle(mobileLedgerProfile.getName()));
progress = findViewById(R.id.save_transaction_progress);
fab = findViewById(R.id.fab);
fab.setOnClickListener(v -> saveTransaction());
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
- table = findViewById(R.id.new_transaction_accounts_table);
-
- while (table.getChildCount() < 2) {
- doAddAccountRow(false);
- }
+ list = findViewById(R.id.new_transaction_accounts);
+ viewModel = ViewModelProviders.of(this).get(NewTransactionModel.class);
+ listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
+ list.setAdapter(listAdapter);
+ list.setLayoutManager(new LinearLayoutManager(this));
+ Data.profile.observe(this, profile -> listAdapter.setProfile(profile));
+ listAdapter.notifyDataSetChanged();
+ new ItemTouchHelper(new ItemTouchHelper.Callback() {
+ @Override
+ public int getMovementFlags(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder) {
+ int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
+ if (viewModel.getAccountCount() > 2) flags |=
+ makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
+ ItemTouchHelper.START | ItemTouchHelper.END);
+ return flags;
+ }
+ @Override
+ public boolean onMove(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ @NonNull RecyclerView.ViewHolder target) {
+ return false;
+ }
+ @Override
+ public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+ if (viewModel.getAccountCount() == 2)
+ Snackbar.make(list, R.string.msg_at_least_two_accounts_are_required,
+ Snackbar.LENGTH_LONG).setAction("Action", null).show();
+ else {
+ int pos = viewHolder.getAdapterPosition();
+ listAdapter.removeItem(pos);
+ // FIXME hook next/prev links somehow
+ throw new RuntimeException("TODO");
+ }
+ }
+ }).attachToRecyclerView(list);
- check_transaction_submittable();
+ viewModel.isSubmittable().observe(this, new Observer<Boolean>() {
+ @Override
+ public void onChanged(Boolean isSubmittable) {
+ if (isSubmittable) {
+ if (fab != null) {
+ fab.show();
+ fab.setEnabled(true);
+ }
+ }
+ else {
+ if (fab != null) {
+ fab.hide();
+ }
+ }
+ }
+ });
+ viewModel.checkTransactionSubmittable(listAdapter);
}
@Override
protected void initProfile() {
super.finish();
overridePendingTransition(R.anim.dummy, R.anim.slide_out_right);
}
-
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
@Override
protected void onStart() {
super.onStart();
- if (tvDescription.getText().toString().isEmpty()) tvDescription.requestFocus();
+ // FIXME if (tvDescription.getText().toString().isEmpty()) tvDescription.requestFocus();
}
public void saveTransaction() {
if (fab != null) fab.setEnabled(false);
- toggleAllEditing(false);
+ listAdapter.toggleAllEditing(false);
progress.setVisibility(View.VISIBLE);
try {
saver = new SendTransactionTask(this, mProfile);
- String dateString = tvDate.getText().toString();
- Date date;
- if (dateString.isEmpty()) date = new Date();
- else date = Globals.parseLedgerDate(dateString);
+ Date date = viewModel.getDate();
LedgerTransaction tr =
- new LedgerTransaction(null, date, tvDescription.getText().toString(), mProfile);
+ new LedgerTransaction(null, date, viewModel.getDescription(),
+ mProfile);
- TableLayout table = findViewById(R.id.new_transaction_accounts_table);
LedgerTransactionAccount emptyAmountAccount = null;
float emptyAmountAccountBalance = 0;
- for (int i = 0; i < table.getChildCount(); i++) {
- TableRow row = (TableRow) table.getChildAt(i);
- String acc = ((TextView) row.getChildAt(0)).getText().toString();
- if (acc.isEmpty()) continue;
+ for (int i = 0; i < viewModel.getAccountCount(); i++) {
+ LedgerTransactionAccount acc = viewModel.getAccount(i);
+ if (acc.getAccountName().trim().isEmpty()) continue;
- String amt = ((TextView) row.getChildAt(1)).getText().toString();
- LedgerTransactionAccount item;
- if (amt.length() > 0) {
- final float amount = Float.parseFloat(amt);
- item = new LedgerTransactionAccount(acc, amount);
- emptyAmountAccountBalance += amount;
+ if (acc.isAmountSet()) {
+ emptyAmountAccountBalance += acc.getAmount();
}
else {
- item = new LedgerTransactionAccount(acc);
- emptyAmountAccount = item;
+ emptyAmountAccount = acc;
}
- tr.addAccount(item);
+ tr.addAccount(acc);
}
if (emptyAmountAccount != null)
emptyAmountAccount.setAmount(-emptyAmountAccountBalance);
saver.execute(tr);
}
- catch (ParseException e) {
- debug("new-transaction", "Parse error", e);
- Toast.makeText(this, getResources().getString(R.string.error_invalid_date),
- Toast.LENGTH_LONG).show();
- tvDate.requestFocus();
-
- progress.setVisibility(View.GONE);
- toggleAllEditing(true);
- if (fab != null) fab.setEnabled(true);
- }
catch (Exception e) {
debug("new-transaction", "Unknown error", e);
progress.setVisibility(View.GONE);
- toggleAllEditing(true);
+ listAdapter.toggleAllEditing(true);
if (fab != null) fab.setEnabled(true);
}
}
- private void toggleAllEditing(boolean enabled) {
- tvDate.setEnabled(enabled);
- tvDescription.setEnabled(enabled);
- TableLayout table = findViewById(R.id.new_transaction_accounts_table);
- for (int i = 0; i < table.getChildCount(); i++) {
- TableRow row = (TableRow) table.getChildAt(i);
- for (int j = 0; j < row.getChildCount(); j++) {
- row.getChildAt(j).setEnabled(enabled);
- }
- }
- }
- private void hookSwipeListener(final TableRow row) {
- row.getChildAt(0).setOnTouchListener(new OnSwipeTouchListener(this) {
- private void onSwipeAside() {
- if (table.getChildCount() > 2) {
- TableRow prev_row = (TableRow) table.getChildAt(table.indexOfChild(row) - 1);
- TableRow next_row = (TableRow) table.getChildAt(table.indexOfChild(row) + 1);
- TextView prev_amt =
- (prev_row != null) ? (TextView) prev_row.getChildAt(1) : tvDescription;
- TextView next_acc =
- (next_row != null) ? (TextView) next_row.getChildAt(0) : null;
-
- if (next_acc == null) {
- prev_amt.setNextFocusRightId(R.id.none);
- prev_amt.setNextFocusForwardId(R.id.none);
- prev_amt.setImeOptions(EditorInfo.IME_ACTION_DONE);
- }
- else {
- prev_amt.setNextFocusRightId(next_acc.getId());
- prev_amt.setNextFocusForwardId(next_acc.getId());
- prev_amt.setImeOptions(EditorInfo.IME_ACTION_NEXT);
- }
-
- if (row.hasFocus()) {
- if (next_acc != null) next_acc.requestFocus();
- else prev_amt.requestFocus();
- }
-
- table.removeView(row);
- check_transaction_submittable();
-// Toast.makeText(NewTransactionActivity.this, "LEFT", Toast.LENGTH_LONG).show();
- }
- else {
- Snackbar.make(table, R.string.msg_at_least_two_accounts_are_required,
- Snackbar.LENGTH_LONG).setAction("Action", null).show();
- }
- }
- public void onSwipeLeft() {
- onSwipeAside();
- }
- public void onSwipeRight() {
- onSwipeAside();
- }
- // @Override
-// public boolean performClick(View view, MotionEvent m) {
-// return true;
-// }
- public boolean onTouch(View view, MotionEvent m) {
- return gestureDetector.onTouchEvent(m);
- }
- });
- }
-
public void simulateCrash(MenuItem item) {
debug("crash", "Will crash intentionally");
new AsyncCrasher().execute();
if (BuildConfig.DEBUG) {
menu.findItem(R.id.action_simulate_crash).setVisible(true);
}
- check_transaction_submittable();
return true;
}
- public void pickTransactionDate(View view) {
- DialogFragment picker = new DatePickerFragment();
- picker.show(getSupportFragmentManager(), "datePicker");
- }
public int dp2px(float dp) {
return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
getResources().getDisplayMetrics()));
}
- private void hookTextChangeListener(final TextView view) {
- view.addTextChangedListener(new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence s, int start, int count, int after) {
-
- }
-
- @Override
- public void onTextChanged(CharSequence s, int start, int before, int count) {
-
- }
-
- @Override
- public void afterTextChanged(Editable s) {
-// debug("input", "text changed");
- check_transaction_submittable();
- }
- });
-
- }
- private TableRow doAddAccountRow(boolean focus) {
- final AutoCompleteTextView acc = new AutoCompleteTextViewWithClear(this);
- acc.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT,
- TableRow.LayoutParams.WRAP_CONTENT, 9f));
- acc.setHint(R.string.new_transaction_account_hint);
- acc.setWidth(0);
- acc.setImeOptions(EditorInfo.IME_ACTION_NEXT | EditorInfo.IME_FLAG_NO_ENTER_ACTION |
- EditorInfo.IME_FLAG_NAVIGATE_NEXT);
- acc.setSingleLine(true);
-
- final EditText amt = new EditText(this);
- amt.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.WRAP_CONTENT,
- TableRow.LayoutParams.MATCH_PARENT, 1f));
- amt.setHint(R.string.new_transaction_amount_hint);
- amt.setWidth(0);
- amt.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED |
- InputType.TYPE_NUMBER_FLAG_DECIMAL);
- amt.setMinWidth(dp2px(40));
- amt.setTextAlignment(EditText.TEXT_ALIGNMENT_VIEW_END);
- amt.setImeOptions(EditorInfo.IME_ACTION_DONE);
- amt.setSelectAllOnFocus(true);
-
- // forward navigation support
- TextView last_amt;
- int rows = table.getChildCount();
- if (rows > 0) {
- final TableRow last_row = (TableRow) table.getChildAt(rows - 1);
- last_amt = (TextView) last_row.getChildAt(1);
- }
- else {
- last_amt = tvDescription;
- }
- last_amt.setNextFocusForwardId(acc.getId());
- last_amt.setNextFocusRightId(acc.getId());
- last_amt.setImeOptions(EditorInfo.IME_ACTION_NEXT);
- acc.setNextFocusForwardId(amt.getId());
- acc.setNextFocusRightId(amt.getId());
-
- final TableRow row = new TableRow(this);
- row.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT,
- TableRow.LayoutParams.MATCH_PARENT));
- row.setGravity(Gravity.BOTTOM);
- row.addView(acc);
- row.addView(amt);
- table.addView(row);
-
- if (focus) acc.requestFocus();
-
- hookSwipeListener(row);
- MLDB.hookAutocompletionAdapter(this, acc, MLDB.ACCOUNTS_TABLE, "name", true,
- description -> amt.requestFocus(), mProfile);
- hookTextChangeListener(acc);
- hookTextChangeListener(amt);
-
- return row;
- }
- public void addTransactionAccountFromMenu(MenuItem item) {
- doAddAccountRow(true);
- }
public void resetTransactionFromMenu(MenuItem item) {
resetForm();
}
- public void saveTransactionFromMenu(MenuItem item) {
- saveTransaction();
- }
- // 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
- @SuppressLint("DefaultLocale")
- private void check_transaction_submittable() {
- TableLayout table = findViewById(R.id.new_transaction_accounts_table);
- int accounts = 0;
- int accounts_with_values = 0;
- int amounts = 0;
- int amounts_with_accounts = 0;
- int empty_rows = 0;
- TextView empty_amount = null;
- boolean single_empty_amount = false;
- boolean single_empty_amount_has_account = false;
- float running_total = 0f;
- boolean have_description =
- !((TextView) findViewById(R.id.new_transaction_description)).getText().toString()
- .isEmpty();
-
- try {
- for (int i = 0; i < table.getChildCount(); i++) {
- TableRow row = (TableRow) table.getChildAt(i);
-
- TextView acc_name_v = (TextView) row.getChildAt(0);
- TextView amount_v = (TextView) row.getChildAt(1);
- String amt = String.valueOf(amount_v.getText());
- String acc_name = String.valueOf(acc_name_v.getText());
- acc_name = acc_name.trim();
-
- if (!acc_name.isEmpty()) {
- accounts++;
-
- if (!amt.isEmpty()) {
- accounts_with_values++;
- }
- }
- else empty_rows++;
-
- if (amt.isEmpty()) {
- amount_v.setHint(String.format("%1.2f", 0f));
- if (empty_amount == null) {
- empty_amount = amount_v;
- single_empty_amount = true;
- single_empty_amount_has_account = !acc_name.isEmpty();
- }
- else if (!acc_name.isEmpty()) single_empty_amount = false;
- }
- else {
- amounts++;
- if (!acc_name.isEmpty()) amounts_with_accounts++;
- running_total += Float.valueOf(amt);
- }
- }
-
- if ((empty_rows == 0) &&
- ((table.getChildCount() == accounts) || (table.getChildCount() == amounts)))
- {
- doAddAccountRow(false);
- }
-
- debug("submittable", String.format("accounts=%d, accounts_with_values=%s, " +
- "amounts_with_accounts=%d, amounts=%d, running_total=%1.2f, " +
- "single_empty_with_acc=%s", accounts,
- accounts_with_values, amounts_with_accounts, amounts, running_total,
- (single_empty_amount && single_empty_amount_has_account) ? "true" : "false"));
-
- 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)))
- {
- if (fab != null) {
- fab.show();
- fab.setEnabled(true);
- }
- }
- else {
- if (fab != null) fab.hide();
- }
-
- if (single_empty_amount) {
- empty_amount.setHint(String.format("%1.2f",
- (Math.abs(running_total) > 0.005) ? -running_total : 0f));
- }
-
- }
- catch (NumberFormatException e) {
- if (fab != null) fab.hide();
- }
- catch (Exception e) {
- e.printStackTrace();
- if (fab != null) fab.hide();
- }
- }
-
@Override
public void done(String error) {
progress.setVisibility(View.INVISIBLE);
debug("visuals", "hiding progress");
if (error == null) resetForm();
- else Snackbar.make(findViewById(R.id.new_transaction_accounts_table), error,
- BaseTransientBottomBar.LENGTH_LONG).show();
+ else Snackbar.make(list, error, BaseTransientBottomBar.LENGTH_LONG).show();
- toggleAllEditing(true);
- check_transaction_submittable();
+ listAdapter.toggleAllEditing(true);
+ viewModel.checkTransactionSubmittable(listAdapter);
}
private void resetForm() {
- tvDate.setText("");
- tvDescription.setText("");
-
- tvDescription.requestFocus();
-
- while (table.getChildCount() > 2) {
- table.removeViewAt(2);
- }
- for (int i = 0; i < 2; i++) {
- TableRow tr = (TableRow) table.getChildAt(i);
- if (tr == null) break;
-
- ((TextView) tr.getChildAt(0)).setText("");
- ((TextView) tr.getChildAt(1)).setText("");
- }
- }
- @Override
- public void descriptionSelected(String description) {
- debug("descr selected", description);
- if (!inputStateIsInitial()) return;
-
- String accFilter = mProfile.getPreferredAccountsFilter();
-
- ArrayList<String> params = new ArrayList<>();
- StringBuilder sb = new StringBuilder(
- "select t.profile, t.id from transactions t where t.description=?");
- params.add(description);
-
- if (accFilter != null) {
- sb.append(" AND EXISTS (").append("SELECT 1 FROM transaction_accounts ta ")
- .append("WHERE ta.profile = t.profile").append(" AND ta.transaction_id = t.id")
- .append(" AND UPPER(ta.account_name) LIKE '%'||?||'%')");
- params.add(accFilter.toUpperCase());
- }
-
- sb.append(" ORDER BY date desc limit 1");
-
- final String sql = sb.toString();
- debug("descr", sql);
- debug("descr", params.toString());
-
- try (Cursor c = App.getDatabase().rawQuery(sql, params.toArray(new String[]{}))) {
- if (!c.moveToNext()) return;
-
- String profileUUID = c.getString(0);
- int transactionId = c.getInt(1);
- LedgerTransaction tr;
- MobileLedgerProfile profile = Data.getProfile(profileUUID);
- if (profile == null) throw new RuntimeException(String.format(
- "Unable to find profile %s, which is supposed to contain " +
- "transaction %d with description %s", profileUUID, transactionId, description));
-
- tr = profile.loadTransaction(transactionId);
- ArrayList<LedgerTransactionAccount> accounts = tr.getAccounts();
- TableRow firstNegative = null;
- int negativeCount = 0;
- for (int i = 0; i < accounts.size(); i++) {
- LedgerTransactionAccount acc = accounts.get(i);
- TableRow row = (TableRow) table.getChildAt(i);
- if (row == null) row = doAddAccountRow(false);
-
- ((TextView) row.getChildAt(0)).setText(acc.getAccountName());
- ((TextView) row.getChildAt(1))
- .setText(String.format(Locale.US, "%1.2f", acc.getAmount()));
-
- if (acc.getAmount() < 0.005) {
- if (firstNegative == null) firstNegative = row;
- negativeCount++;
- }
- }
-
- if (negativeCount == 1) {
- ((TextView) firstNegative.getChildAt(1)).setText(null);
- }
-
- check_transaction_submittable();
-
- EditText firstAmount = (EditText) ((TableRow) table.getChildAt(0)).getChildAt(1);
- String amtString = String.valueOf(firstAmount.getText());
- firstAmount.requestFocus();
- firstAmount.setSelection(0, amtString.length());
- }
-
- }
- private boolean inputStateIsInitial() {
- table = findViewById(R.id.new_transaction_accounts_table);
-
- if (table.getChildCount() != 2) return false;
-
- for (int i = 0; i < 2; i++) {
- TableRow row = (TableRow) table.getChildAt(i);
- if (((TextView) row.getChildAt(0)).getText().length() > 0) return false;
- if (((TextView) row.getChildAt(1)).getText().length() > 0) return false;
- }
-
- return true;
+ listAdapter.reset();
}
private class AsyncCrasher extends AsyncTask<Void, Void, Void> {
@Override
throw new RuntimeException("Simulated crash");
}
}
+
}
--- /dev/null
+/*
+ * Copyright © 2019 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.activity;
+
+import android.content.Context;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AutoCompleteTextView;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.lifecycle.Observer;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.ui.DatePickerFragment;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.MLDB;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+
+class NewTransactionItemHolder extends RecyclerView.ViewHolder
+ implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback {
+ private NewTransactionModel.Item item;
+ private TextView tvDate;
+ private AutoCompleteTextView tvDescription;
+ private AutoCompleteTextView tvAccount;
+ private TextView tvAmount;
+ private ConstraintLayout lHead;
+ private LinearLayout lAccount;
+ private FrameLayout lPadding;
+ private MobileLedgerProfile mProfile;
+ private Date date;
+ private Observer<Date> dateObserver;
+ private Observer<String> descriptionObserver;
+ private Observer<String> hintObserver;
+ private Observer<Integer> focusedAccountObserver;
+ private Observer<Integer> accountCountObserver;
+ private boolean inUpdate = false;
+ private boolean syncingData = false;
+ NewTransactionItemHolder(@NonNull View itemView, NewTransactionItemsAdapter adapter) {
+ super(itemView);
+ tvAccount = itemView.findViewById(R.id.account_row_acc_name);
+ tvAmount = itemView.findViewById(R.id.account_row_acc_amounts);
+ tvDate = itemView.findViewById(R.id.new_transaction_date);
+ tvDescription = itemView.findViewById(R.id.new_transaction_description);
+ lHead = itemView.findViewById(R.id.ntr_data);
+ lAccount = itemView.findViewById(R.id.ntr_account);
+ lPadding = itemView.findViewById(R.id.ntr_padding);
+
+ tvDescription.setNextFocusForwardId(View.NO_ID);
+ tvAccount.setNextFocusForwardId(View.NO_ID);
+ tvAmount.setNextFocusForwardId(View.NO_ID); // magic!
+
+ tvDate.setOnFocusChangeListener((v, hasFocus) -> {
+ if (hasFocus) pickTransactionDate();
+ });
+ tvDate.setOnClickListener(v -> pickTransactionDate());
+
+ MLDB.hookAutocompletionAdapter(tvDescription.getContext(), tvDescription,
+ MLDB.DESCRIPTION_HISTORY_TABLE, "description", false, adapter, mProfile);
+ MLDB.hookAutocompletionAdapter(tvAccount.getContext(), tvAccount, MLDB.ACCOUNTS_TABLE,
+ "name", true, this, mProfile);
+
+ final TextWatcher tw = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+// debug("input", "text changed");
+ if (inUpdate) return;
+
+ Logger.debug("textWatcher", "calling syncData()");
+ syncData();
+ Logger.debug("textWatcher",
+ "syncData() returned, checking if transaction is submittable");
+ adapter.model.checkTransactionSubmittable(adapter);
+ Logger.debug("textWatcher", "done");
+ }
+ };
+ tvDescription.addTextChangedListener(tw);
+ tvAccount.addTextChangedListener(tw);
+ tvAmount.addTextChangedListener(tw);
+
+ dateObserver = date -> {
+ if (syncingData) return;
+ tvDate.setText(item.getFormattedDate());
+ };
+ descriptionObserver = description -> {
+ if (syncingData) return;
+ tvDescription.setText(description);
+ };
+ hintObserver = hint -> {
+ if (syncingData) return;
+ tvAmount.setHint(hint);
+ };
+ focusedAccountObserver = index -> {
+ if ((index != null) && index.equals(getAdapterPosition())) {
+ switch (item.getType()) {
+ case generalData:
+ tvDate.requestFocus();
+ break;
+ case transactionRow:
+ tvAccount.requestFocus();
+ tvAccount.dismissDropDown();
+ tvAccount.selectAll();
+ break;
+ }
+ }
+ };
+ accountCountObserver = count -> {
+ if (getAdapterPosition() == count) tvAmount.setImeOptions(EditorInfo.IME_ACTION_DONE);
+ else tvAmount.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ };
+ }
+ private void beginUpdates() {
+ if (inUpdate) throw new RuntimeException("Already in update mode");
+ inUpdate = true;
+ }
+ private void endUpdates() {
+ if (!inUpdate) throw new RuntimeException("Not in update mode");
+ inUpdate = false;
+ }
+ /**
+ * syncData()
+ * <p>
+ * Stores the data from the UI elements into the model item
+ */
+ private void syncData() {
+ if (item == null) return;
+
+ if (syncingData) {
+ Logger.debug("new-trans", "skipping syncData() loop");
+ return;
+ }
+
+ syncingData = true;
+
+ try {
+ switch (item.getType()) {
+ case generalData:
+ item.setDate(String.valueOf(tvDate.getText()));
+ item.setDescription(String.valueOf(tvDescription.getText()));
+ break;
+ case transactionRow:
+ item.getAccount()
+ .setAccountName(String.valueOf(tvAccount.getText()));
+
+ // TODO: handle multiple amounts
+ String amount = String.valueOf(tvAmount.getText());
+ amount = amount.trim();
+
+ if (!amount.isEmpty()) item.getAccount()
+ .setAmount(Float.parseFloat(amount));
+ else item.getAccount()
+ .resetAmount();
+
+ break;
+ case bottomFiller:
+ throw new RuntimeException("Should not happen");
+ }
+ }
+ finally {
+ syncingData = false;
+ }
+ }
+ private void pickTransactionDate() {
+ DatePickerFragment picker = new DatePickerFragment();
+ picker.setOnDatePickedListener(this);
+ picker.show(((NewTransactionActivity) tvDate.getContext()).getSupportFragmentManager(),
+ "datePicker");
+ }
+ /**
+ * setData
+ *
+ * @param item updates the UI elements with the data from the model item
+ */
+ public void setData(NewTransactionModel.Item item) {
+ beginUpdates();
+ try {
+ if (this.item != null && !this.item.equals(item)) {
+ this.item.stopObservingDate(dateObserver);
+ this.item.stopObservingDescription(descriptionObserver);
+ this.item.stopObservingAmountHint(hintObserver);
+ this.item.getModel()
+ .stopObservingFocusedItem(focusedAccountObserver);
+ this.item.getModel()
+ .stopObservingAccountCount(accountCountObserver);
+
+ this.item = null;
+ }
+
+ switch (item.getType()) {
+ case generalData:
+ tvDate.setText(item.getFormattedDate());
+ tvDescription.setText(item.getDescription());
+ lHead.setVisibility(View.VISIBLE);
+ lAccount.setVisibility(View.GONE);
+ lPadding.setVisibility(View.GONE);
+ break;
+ case transactionRow:
+ LedgerTransactionAccount acc = item.getAccount();
+ tvAccount.setText(acc.getAccountName());
+ tvAmount.setText(
+ acc.isAmountSet() ? String.format(Locale.US, "%1.2f", acc.getAmount())
+ : "");
+ lHead.setVisibility(View.GONE);
+ lAccount.setVisibility(View.VISIBLE);
+ lPadding.setVisibility(View.GONE);
+ break;
+ case bottomFiller:
+ lHead.setVisibility(View.GONE);
+ lAccount.setVisibility(View.GONE);
+ lPadding.setVisibility(View.VISIBLE);
+ break;
+ }
+
+ if (this.item == null) { // was null or has changed
+ this.item = item;
+ final NewTransactionActivity activity =
+ (NewTransactionActivity) tvDescription.getContext();
+ item.observeDate(activity, dateObserver);
+ item.observeDescription(activity, descriptionObserver);
+ item.observeAmountHint(activity, hintObserver);
+ item.getModel()
+ .observeFocusedItem(activity, focusedAccountObserver);
+ item.getModel()
+ .observeAccountCount(activity, accountCountObserver);
+ }
+ }
+ finally {
+ endUpdates();
+ }
+ }
+ @Override
+ public void onDatePicked(int year, int month, int day) {
+ final Calendar c = GregorianCalendar.getInstance();
+ c.set(year, month, day);
+ item.setDate(c.getTime());
+ boolean tookFocus = tvDescription.requestFocus();
+ if (tookFocus) {
+ // make the keyboard appear
+ InputMethodManager imm = (InputMethodManager) tvDate.getContext()
+ .getSystemService(
+ Context.INPUT_METHOD_SERVICE);
+ imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0);
+ }
+ }
+ @Override
+ public void descriptionSelected(String description) {
+ tvAccount.setText(description);
+ tvAmount.requestFocus(View.FOCUS_FORWARD);
+ }
+}
--- /dev/null
+/*
+ * Copyright © 2019 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.activity;
+
+import android.database.Cursor;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TableRow;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.ktnx.mobileledger.App;
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.Locale;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemHolder>
+ implements DescriptionSelectedCallback {
+ NewTransactionModel model;
+ private MobileLedgerProfile mProfile;
+ NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) {
+ super();
+ model = viewModel;
+ mProfile = profile;
+ int size = model.getAccountCount();
+ while (size < 2) {
+ Logger.debug("new-transaction",
+ String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
+ size = addRow();
+ }
+ }
+ public void setProfile(MobileLedgerProfile profile) {
+ mProfile = profile;
+ }
+ int addRow() {
+ final int newAccountCount = model.addAccount(new LedgerTransactionAccount(""));
+ Logger.debug("new-transaction",
+ String.format(Locale.US, "invoking notifyItemInserted(%d)", newAccountCount));
+ // the header is at position 0
+ notifyItemInserted(newAccountCount);
+ return newAccountCount;
+ }
+ @NonNull
+ @Override
+ public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LinearLayout row = (LinearLayout) LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.new_transaction_row,
+ parent, false);
+ return new NewTransactionItemHolder(row, this);
+ }
+ @Override
+ public void onBindViewHolder(@NonNull NewTransactionItemHolder holder, int position) {
+ Logger.debug("bind", String.format(Locale.US, "Binding item at position %d", position));
+ holder.setData(model.getItem(position));
+ Logger.debug("bind", String.format(Locale.US, "Bound item at position %d", position));
+ }
+ @Override
+ public int getItemCount() {
+ final int itemCount = model.getAccountCount() + 2;
+ Logger.debug("new-transaction",
+ String.format(Locale.US, "getItemCount() returning %d", itemCount));
+ return itemCount;
+ }
+ boolean accountListIsEmpty() {
+ for (int i = 0; i < model.getAccountCount(); i++) {
+ LedgerTransactionAccount acc = model.getAccount(i);
+ if (!acc.getAccountName()
+ .isEmpty()) return false;
+ if (acc.isAmountSet()) return false;
+ }
+
+ return true;
+ }
+ public void descriptionSelected(String description) {
+ debug("descr selected", description);
+ if (!accountListIsEmpty()) return;
+
+ String accFilter = mProfile.getPreferredAccountsFilter();
+
+ ArrayList<String> params = new ArrayList<>();
+ StringBuilder sb = new StringBuilder(
+ "select t.profile, t.id from transactions t where t.description=?");
+ params.add(description);
+
+ if (accFilter != null) {
+ sb.append(" AND EXISTS (")
+ .append("SELECT 1 FROM transaction_accounts ta ")
+ .append("WHERE ta.profile = t.profile")
+ .append(" AND ta.transaction_id = t.id")
+ .append(" AND UPPER(ta.account_name) LIKE '%'||?||'%')");
+ params.add(accFilter.toUpperCase());
+ }
+
+ sb.append(" ORDER BY date desc limit 1");
+
+ final String sql = sb.toString();
+ debug("descr", sql);
+ debug("descr", params.toString());
+
+ try (Cursor c = App.getDatabase()
+ .rawQuery(sql, params.toArray(new String[]{})))
+ {
+ if (!c.moveToNext()) return;
+
+ String profileUUID = c.getString(0);
+ int transactionId = c.getInt(1);
+ LedgerTransaction tr;
+ MobileLedgerProfile profile = Data.getProfile(profileUUID);
+ if (profile == null) throw new RuntimeException(String.format(
+ "Unable to find profile %s, which is supposed to contain " +
+ "transaction %d with description %s", profileUUID, transactionId, description));
+
+ tr = profile.loadTransaction(transactionId);
+ ArrayList<LedgerTransactionAccount> accounts = tr.getAccounts();
+ TableRow firstNegative = null;
+ int negativeCount = 0;
+ for (int i = 0; i < accounts.size(); i++) {
+ LedgerTransactionAccount acc = accounts.get(i);
+ NewTransactionModel.Item item;
+ if (model.getAccountCount() < i) {
+ model.addAccount(acc);
+ notifyItemInserted(i + 1);
+ }
+ item = model.getItem(i + 1);
+
+ item.getAccount()
+ .setAccountName(acc.getAccountName());
+ if (acc.isAmountSet()) item.getAccount()
+ .setAmount(acc.getAmount());
+ else item.getAccount()
+ .resetAmount();
+ notifyItemChanged(i + 1);
+ }
+ }
+ model.checkTransactionSubmittable(this);
+ model.setFocusedItem(1);
+ }
+ public void toggleAllEditing(boolean editable) {
+ for (int i = 0; i < model.getAccountCount(); i++) {
+ model.getItem(i + 1)
+ .setEditable(editable);
+ notifyItemChanged(i + 1);
+ // TODO perhaps do only one notification about the whole range [1…count]?
+ }
+ }
+ public void reset() {
+ int presentItemCount = model.getAccountCount();
+ model.reset();
+ notifyItemChanged(0); // header changed
+ notifyItemRangeChanged(1, 2); // the two empty rows
+ if (presentItemCount > 2)
+ notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone
+ }
+ public void removeItem(int pos) {
+ model.removeItem(pos - 1, this);
+ notifyItemRemoved(pos);
+ }
+}
--- /dev/null
+/*
+ * Copyright © 2019 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.activity;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+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*$");
+ static final Pattern reMD = Pattern.compile("^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$");
+ static final Pattern reD = Pattern.compile("\\s*(\\d+)\\s*$");
+ private final Item header = new Item(this, null, "");
+ 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> accountCount = new MutableLiveData<>(0);
+ public int getAccountCount() {
+ return items.size();
+ }
+ public Date getDate() {
+ return header.date.getValue();
+ }
+ public String getDescription() {
+ return header.description.getValue();
+ }
+ public LiveData<Boolean> isSubmittable() {
+ return this.isSubmittable;
+ }
+ void reset() {
+ header.date.setValue(null);
+ header.description.setValue(null);
+ items.clear();
+ items.add(new Item(this, new LedgerTransactionAccount("")));
+ items.add(new Item(this, new LedgerTransactionAccount("")));
+ focusedItem.setValue(0);
+ }
+ public void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
+ this.focusedItem.observe(owner, observer);
+ }
+ public void stopObservingFocusedItem(
+ @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
+ this.focusedItem.removeObserver(observer);
+ }
+ public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull
+ androidx.lifecycle.Observer<? super Integer> observer) {
+ this.accountCount.observe(owner, observer);
+ }
+ public void stopObservingAccountCount(
+ @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
+ this.accountCount.removeObserver(observer);
+ }
+ public void setFocusedItem(int position) {
+ focusedItem.setValue(position);
+ }
+ public int addAccount(LedgerTransactionAccount acc) {
+ items.add(new Item(this, acc));
+ accountCount.setValue(getAccountCount());
+ return items.size();
+ }
+ boolean accountsInInitialState() {
+ for (Item item : items) {
+ LedgerTransactionAccount acc = item.getAccount();
+ if (acc.isAmountSet()) return false;
+ if (!acc.getAccountName()
+ .trim()
+ .isEmpty()) return false;
+ }
+
+ return true;
+ }
+ LedgerTransactionAccount getAccount(int index) {
+ return items.get(index)
+ .getAccount();
+ }
+ public Item getItem(int index) {
+ if (index == 0) {
+ return header;
+ }
+ else if (index <= items.size()) return items.get(index - 1);
+ else 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
+ 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;
+ final String descriptionText = getDescription();
+ final boolean have_description = ((descriptionText != null) && !descriptionText.isEmpty());
+
+ try {
+ 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.isAmountSet()) {
+ accounts_with_values++;
+ }
+ }
+ else empty_rows++;
+
+ 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;
+ }
+ else {
+ amounts++;
+ if (!acc_name.isEmpty()) amounts_with_accounts++;
+ running_total += acc.getAmount();
+ }
+ }
+
+ 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",
+ (Math.abs(running_total) > 0.005) ? -running_total : 0f));
+ }
+
+ 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"));
+
+ 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);
+ }
+
+ }
+ catch (NumberFormatException e) {
+ debug("submittable", "NO (because of NumberFormatException)");
+ isSubmittable.setValue(false);
+ }
+ catch (Exception e) {
+ e.printStackTrace();
+ debug("submittable", "NO (because of an Exception)");
+ isSubmittable.setValue(false);
+ }
+ }
+ public void removeItem(int pos, NewTransactionItemsAdapter adapter) {
+ items.remove(pos);
+ accountCount.setValue(getAccountCount());
+ checkTransactionSubmittable(adapter);
+ }
+ 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 NewTransactionModel model;
+ private boolean editable = true;
+ public Item(NewTransactionModel model) {
+ this.model = model;
+ type = ItemType.bottomFiller;
+ }
+ public Item(NewTransactionModel model, Date date, String description) {
+ this.model = model;
+ this.type = ItemType.generalData;
+ this.date.setValue(date);
+ this.description.setValue(description);
+ }
+ public Item(NewTransactionModel model, LedgerTransactionAccount account) {
+ this.model = model;
+ this.type = ItemType.transactionRow;
+ this.account = account;
+ }
+ public NewTransactionModel getModel() {
+ return model;
+ }
+ public boolean isEditable() {
+ ensureType(ItemType.transactionRow);
+ return editable;
+ }
+ public void setEditable(boolean editable) {
+ ensureType(ItemType.transactionRow);
+ this.editable = editable;
+ }
+ public String getAmountHint() {
+ ensureType(ItemType.transactionRow);
+ return amountHint.getValue();
+ }
+ public void setAmountHint(String amountHint) {
+ ensureType(ItemType.transactionRow);
+ this.amountHint.setValue(amountHint);
+ }
+ public void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull
+ androidx.lifecycle.Observer<? super String> observer) {
+ this.amountHint.observe(owner, observer);
+ }
+ public void stopObservingAmountHint(
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.amountHint.removeObserver(observer);
+ }
+ public ItemType getType() {
+ return type;
+ }
+ public void ensureType(ItemType wantedType) {
+ if (type != wantedType) {
+ throw new RuntimeException(
+ String.format("Actual type (%d) differs from wanted (%s)", type,
+ wantedType));
+ }
+ }
+ public Date getDate() {
+ ensureType(ItemType.generalData);
+ return date.getValue();
+ }
+ public void setDate(Date date) {
+ ensureType(ItemType.generalData);
+ this.date.setValue(date);
+ }
+ public void setDate(String text) {
+ int year, month, day;
+ final Calendar c = GregorianCalendar.getInstance();
+ Matcher m = reYMD.matcher(text);
+ if (m.matches()) {
+ year = Integer.parseInt(m.group(1));
+ month = Integer.parseInt(m.group(2)) - 1; // month is 0-based
+ day = Integer.parseInt(m.group(3));
+ }
+ else {
+ year = c.get(Calendar.YEAR);
+ m = reMD.matcher(text);
+ if (m.matches()) {
+ month = Integer.parseInt(m.group(1)) - 1;
+ day = Integer.parseInt(m.group(2));
+ }
+ else {
+ month = c.get(Calendar.MONTH);
+ m = reD.matcher(text);
+ if (m.matches()) {
+ day = Integer.parseInt(m.group(1));
+ }
+ else {
+ day = c.get(Calendar.DAY_OF_MONTH);
+ }
+ }
+ }
+
+ c.set(year, month, day);
+
+ this.setDate(c.getTime());
+ }
+ public void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super Date> observer) {
+ this.date.observe(owner, observer);
+ }
+ public void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super Date> observer) {
+ this.date.removeObserver(observer);
+ }
+ public String getDescription() {
+ ensureType(ItemType.generalData);
+ return description.getValue();
+ }
+ public void setDescription(String description) {
+ ensureType(ItemType.generalData);
+ this.description.setValue(description);
+ }
+ public void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull
+ androidx.lifecycle.Observer<? super String> observer) {
+ this.description.observe(owner, observer);
+ }
+ public void stopObservingDescription(
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.description.removeObserver(observer);
+ }
+ public LedgerTransactionAccount getAccount() {
+ ensureType(ItemType.transactionRow);
+ return account;
+ }
+ public void setAccountName(String name) {
+ account.setAccountName(name);
+ }
+ /**
+ * getFormattedDate()
+ *
+ * @return nicely formatted, shortest available date representation
+ */
+ public String getFormattedDate() {
+ if (date == null) return null;
+ Date time = date.getValue();
+ if (time == null) return null;
+
+ Calendar c = GregorianCalendar.getInstance();
+ c.setTime(time);
+ Calendar today = GregorianCalendar.getInstance();
+
+ final int myYear = c.get(Calendar.YEAR);
+ final int myMonth = c.get(Calendar.MONTH);
+ 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);
+ }
+
+ if (today.get(Calendar.MONTH) != myMonth) {
+ return String.format(Locale.US, "%d/%02d", myMonth, myDay);
+ }
+
+ return String.valueOf(myDay);
+ }
+ }
+}
--- /dev/null
+/*
+ * Copyright © 2019 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.utils;
+
+public class Misc {
+ public static boolean isZero(float f) {
+ return (f < 0.005) && (f > -0.005);
+ }
+}
android:layout_height="match_parent"
tools:context=".ui.activity.NewTransactionActivity">
- <com.google.android.material.appbar.AppBarLayout
+ <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:theme="@style/AppTheme.AppBarOverlay">
+ android:layout_height="match_parent">
- <androidx.appcompat.widget.Toolbar
- android:id="@+id/toolbar"
+ <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
- android:layout_height="?attr/actionBarSize"
- android:background="?attr/colorPrimary"
- app:popupTheme="@style/AppTheme.PopupOverlay" />
+ android:layout_height="wrap_content"
+ android:id="@+id/toolbar_layout"
+ android:theme="@style/AppTheme.AppBarOverlay"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ >
- </com.google.android.material.appbar.AppBarLayout>
+ <androidx.appcompat.widget.Toolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ android:background="?attr/colorPrimary"
+ app:popupTheme="@style/AppTheme.PopupOverlay" />
- <include layout="@layout/content_new_transaction" />
+ </com.google.android.material.appbar.AppBarLayout>
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent">
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/new_transaction_accounts"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:paddingStart="@dimen/activity_horizontal_margin"
+ android:paddingEnd="@dimen/activity_horizontal_margin"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/toolbar_layout">
+
+ </androidx.recyclerview.widget.RecyclerView>
<ProgressBar
android:id="@+id/save_transaction_progress"
+++ /dev/null
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright © 2019 Damyan Ivanov.
- ~ This file is part of MoLe.
- ~ MoLe is free software: you can distribute it and/or modify it
- ~ under the term of the GNU General Public License as published by
- ~ the Free Software Foundation, either version 3 of the License, or
- ~ (at your opinion), any later version.
- ~
- ~ MoLe is distributed in the hope that it will be useful,
- ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
- ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- ~ GNU General Public License terms for details.
- ~
- ~ You should have received a copy of the GNU General Public License
- ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- app:layout_behavior="@string/appbar_scrolling_view_behavior"
- tools:context=".ui.activity.NewTransactionActivity"
- tools:showIn="@layout/activity_new_transaction">
-
- <ScrollView
- android:id="@+id/transaction_details"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical">
-
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <EditText
- android:id="@+id/new_transaction_date"
- android:layout_width="94dp"
- android:layout_height="0dp"
- android:accessibilityTraversalBefore="@+id/new_transaction_description"
- android:ems="10"
- android:foregroundGravity="bottom"
- android:gravity="bottom"
- android:hint="@string/new_transaction_date_hint"
- android:imeOptions="actionNext"
- android:inputType="date"
- android:nextFocusDown="@+id/new_transaction_acc_1"
- android:nextFocusForward="@+id/new_transaction_description"
- android:onClick="pickTransactionDate"
- android:textAlignment="center"
- app:layout_constrainedHeight="true"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintHorizontal_weight="8"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
- android:id="@+id/new_transaction_description"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:accessibilityTraversalAfter="@+id/new_transaction_date"
- android:accessibilityTraversalBefore="@+id/new_transaction_acc_1"
- android:ems="10"
- android:hint="@string/new_transaction_description_hint"
- android:imeOptions="actionNext"
- android:nextFocusLeft="@+id/new_transaction_date"
- android:nextFocusRight="@+id/new_transaction_acc_1"
- android:nextFocusUp="@+id/new_transaction_date"
- android:nextFocusDown="@+id/new_transaction_acc_1"
- android:nextFocusForward="@+id/new_transaction_acc_1"
- android:singleLine="true"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_weight="30"
- app:layout_constraintStart_toEndOf="@+id/new_transaction_date"
- app:layout_constraintTop_toTopOf="parent" />
- </androidx.constraintlayout.widget.ConstraintLayout>
-
- <TableLayout
- android:id="@+id/new_transaction_accounts_table"
- android:animateLayoutChanges="true"
- android:layout_width="match_parent"
- android:layout_height="match_parent"/>
-
- <FrameLayout
- android:layout_width="match_parent"
- android:layout_height="80dp"
- android:background="@android:color/transparent">
-
- </FrameLayout>
-
- </LinearLayout>
- </ScrollView>
-
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
+++ /dev/null
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright © 2019 Damyan Ivanov.
- ~ This file is part of MoLe.
- ~ MoLe is free software: you can distribute it and/or modify it
- ~ under the term of the GNU General Public License as published by
- ~ the Free Software Foundation, either version 3 of the License, or
- ~ (at your opinion), any later version.
- ~
- ~ MoLe is distributed in the hope that it will be useful,
- ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
- ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- ~ GNU General Public License terms for details.
- ~
- ~ You should have received a copy of the GNU General Public License
- ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- xmlns:app="http://schemas.android.com/apk/res-auto">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/nav_new_profile_button"
- android:layout_width="wrap_content"
- android:layout_height="@dimen/thumb_row_height"
- android:drawableStart="@drawable/ic_add_circle_white_24dp"
- android:gravity="center_horizontal|center_vertical"
- android:paddingStart="@dimen/activity_horizontal_margin"
- android:paddingEnd="@dimen/activity_horizontal_margin"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright © 2019 Damyan Ivanov.
+ ~ This file is part of MoLe.
+ ~ MoLe is free software: you can distribute it and/or modify it
+ ~ under the term of the GNU General Public License as published by
+ ~ the Free Software Foundation, either version 3 of the License, or
+ ~ (at your opinion), any later version.
+ ~
+ ~ MoLe is distributed in the hope that it will be useful,
+ ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ ~ GNU General Public License terms for details.
+ ~
+ ~ You should have received a copy of the GNU General Public License
+ ~ along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:animateLayoutChanges="false"
+ android:orientation="vertical">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:id="@+id/ntr_data"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <EditText
+ android:id="@+id/new_transaction_date"
+ android:layout_width="94dp"
+ android:layout_height="0dp"
+ android:accessibilityTraversalBefore="@+id/new_transaction_description"
+ android:ems="10"
+ android:foregroundGravity="bottom"
+ android:gravity="bottom"
+ android:hint="@string/new_transaction_date_hint"
+ android:imeOptions="actionNext"
+ android:cursorVisible="false"
+ android:textCursorDrawable="@android:color/transparent"
+ android:inputType="date"
+ android:nextFocusDown="@+id/new_transaction_acc_1"
+ android:nextFocusForward="@+id/new_transaction_description"
+ android:textAlignment="center"
+ android:enabled="true"
+ app:layout_constrainedHeight="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintHorizontal_weight="8"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
+ android:id="@+id/new_transaction_description"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="8dp"
+ android:accessibilityTraversalAfter="@+id/new_transaction_date"
+ android:ems="10"
+ android:hint="@string/new_transaction_description_hint"
+ android:imeOptions="actionNext"
+ android:inputType="text"
+ android:nextFocusLeft="@+id/new_transaction_date"
+ android:nextFocusUp="@+id/new_transaction_date"
+ android:singleLine="true"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_weight="30"
+ app:layout_constraintStart_toEndOf="@+id/new_transaction_date"
+ app:layout_constraintTop_toTopOf="parent" />
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <LinearLayout
+ android:id="@+id/ntr_account"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+
+ <net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear
+ android:id="@+id/account_row_acc_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="9"
+ android:hint="@string/new_transaction_account_hint"
+ android:inputType="text"
+ android:imeOptions="actionNext"
+ android:singleLine="true"
+ android:width="0dp" />
+
+ <EditText
+ android:id="@+id/account_row_acc_amounts"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom|end"
+ android:layout_weight="0"
+ android:width="0dp"
+ android:foregroundGravity="bottom"
+ android:gravity="bottom|end"
+ android:hint="0.00"
+ android:inputType="numberSigned|numberDecimal"
+ android:minWidth="60sp"
+ android:textAlignment="viewEnd"
+ android:selectAllOnFocus="true"
+ tools:ignore="HardcodedText"
+ android:imeOptions="actionNext"/>
+
+ </LinearLayout>
+
+ <FrameLayout
+ android:id="@+id/ntr_padding"
+ android:layout_width="match_parent"
+ android:layout_height="80dp"
+ android:minHeight="80dp">
+
+ </FrameLayout>
+
+</LinearLayout>
\ No newline at end of file