From 865334093695e52f99d93ad9a255252b9d575053 Mon Sep 17 00:00:00 2001 From: Damyan Ivanov Date: Sun, 10 Nov 2019 00:51:00 +0200 Subject: [PATCH] rework new transaction activity with a RecyclerView in the process: - decouple DatePickerFragment from the new transaction activity - fix ImeAction setting - fix focusing next element when date/old transaction/account name is picked --- .../mobileledger/ui/DatePickerFragment.java | 33 +- .../ui/activity/NewTransactionActivity.java | 518 +++--------------- .../ui/activity/NewTransactionItemHolder.java | 287 ++++++++++ .../activity/NewTransactionItemsAdapter.java | 184 +++++++ .../ui/activity/NewTransactionModel.java | 377 +++++++++++++ .../net/ktnx/mobileledger/utils/Misc.java | 24 + .../res/layout/activity_new_transaction.xml | 42 +- .../res/layout/content_new_transaction.xml | 104 ---- .../nav_profile_list_new_profile_row.xml | 37 -- .../main/res/layout/new_transaction_row.xml | 118 ++++ 10 files changed, 1113 insertions(+), 611 deletions(-) create mode 100644 app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java create mode 100644 app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java create mode 100644 app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java create mode 100644 app/src/main/java/net/ktnx/mobileledger/utils/Misc.java delete mode 100644 app/src/main/res/layout/content_new_transaction.xml delete mode 100644 app/src/main/res/layout/nav_profile_list_new_profile_row.xml create mode 100644 app/src/main/res/layout/new_transaction_row.xml diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java index 65794630..6f71acee 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java @@ -17,10 +17,8 @@ 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; @@ -31,7 +29,6 @@ import net.ktnx.mobileledger.R; 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; @@ -41,6 +38,7 @@ public class DatePickerFragment extends AppCompatDialogFragment 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) { @@ -87,30 +85,17 @@ public class DatePickerFragment extends AppCompatDialogFragment 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); + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java index cd3576ad..cef99101 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java @@ -17,57 +17,37 @@ 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; /* @@ -77,17 +57,13 @@ 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); @@ -95,29 +71,68 @@ public class NewTransactionActivity extends ProfileThemedActivity 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() { + @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() { @@ -134,7 +149,6 @@ public class NewTransactionActivity extends ProfileThemedActivity super.finish(); overridePendingTransition(R.anim.dummy, R.anim.slide_out_right); } - @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -147,131 +161,49 @@ public class NewTransactionActivity extends ProfileThemedActivity @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(); @@ -283,313 +215,32 @@ public class NewTransactionActivity extends ProfileThemedActivity 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 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 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 { @Override @@ -597,4 +248,5 @@ public class NewTransactionActivity extends ProfileThemedActivity throw new RuntimeException("Simulated crash"); } } + } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java new file mode 100644 index 00000000..2e4c0e6c --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java @@ -0,0 +1,287 @@ +/* + * 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 . + */ + +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 dateObserver; + private Observer descriptionObserver; + private Observer hintObserver; + private Observer focusedAccountObserver; + private Observer 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() + *

+ * 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); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java new file mode 100644 index 00000000..1585f1d8 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java @@ -0,0 +1,184 @@ +/* + * 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 . + */ + +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 + 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 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 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); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java new file mode 100644 index 00000000..0f46d8ae --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java @@ -0,0 +1,377 @@ +/* + * 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 . + */ + +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 items = new ArrayList<>(); + private final MutableLiveData isSubmittable = new MutableLiveData<>(false); + private final MutableLiveData focusedItem = new MutableLiveData<>(null); + private final MutableLiveData 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 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 observer) { + this.focusedItem.observe(owner, observer); + } + public void stopObservingFocusedItem( + @NonNull androidx.lifecycle.Observer observer) { + this.focusedItem.removeObserver(observer); + } + public void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, + @NonNull + androidx.lifecycle.Observer observer) { + this.accountCount.observe(owner, observer); + } + public void stopObservingAccountCount( + @NonNull androidx.lifecycle.Observer 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 = new MutableLiveData<>(); + private MutableLiveData description = new MutableLiveData<>(); + private LedgerTransactionAccount account; + private MutableLiveData 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 observer) { + this.amountHint.observe(owner, observer); + } + public void stopObservingAmountHint( + @NonNull androidx.lifecycle.Observer 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 observer) { + this.date.observe(owner, observer); + } + public void stopObservingDate(@NonNull androidx.lifecycle.Observer 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 observer) { + this.description.observe(owner, observer); + } + public void stopObservingDescription( + @NonNull androidx.lifecycle.Observer 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); + } + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/Misc.java b/app/src/main/java/net/ktnx/mobileledger/utils/Misc.java new file mode 100644 index 00000000..ee6f5a74 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/utils/Misc.java @@ -0,0 +1,24 @@ +/* + * 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 . + */ + +package net.ktnx.mobileledger.utils; + +public class Misc { + public static boolean isZero(float f) { + return (f < 0.005) && (f > -0.005); + } +} diff --git a/app/src/main/res/layout/activity_new_transaction.xml b/app/src/main/res/layout/activity_new_transaction.xml index 15c292d6..9320a748 100644 --- a/app/src/main/res/layout/activity_new_transaction.xml +++ b/app/src/main/res/layout/activity_new_transaction.xml @@ -22,25 +22,41 @@ android:layout_height="match_parent" tools:context=".ui.activity.NewTransactionActivity"> - + android:layout_height="match_parent"> - + 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" + > - + - + - + + + - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/nav_profile_list_new_profile_row.xml b/app/src/main/res/layout/nav_profile_list_new_profile_row.xml deleted file mode 100644 index 0db8d6b5..00000000 --- a/app/src/main/res/layout/nav_profile_list_new_profile_row.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/new_transaction_row.xml b/app/src/main/res/layout/new_transaction_row.xml new file mode 100644 index 00000000..2356e5ca --- /dev/null +++ b/app/src/main/res/layout/new_transaction_row.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file -- 2.39.5