rework new transaction activity with a RecyclerView
authorDamyan Ivanov <dam+mobileledger@ktnx.net>
Sat, 9 Nov 2019 22:51:00 +0000 (00:51 +0200)
committerDamyan Ivanov <dam+mobileledger@ktnx.net>
Sat, 9 Nov 2019 22:51:00 +0000 (00:51 +0200)
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

app/src/main/java/net/ktnx/mobileledger/ui/DatePickerFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java [new file with mode: 0644]
app/src/main/java/net/ktnx/mobileledger/utils/Misc.java [new file with mode: 0644]
app/src/main/res/layout/activity_new_transaction.xml
app/src/main/res/layout/content_new_transaction.xml [deleted file]
app/src/main/res/layout/nav_profile_list_new_profile_row.xml [deleted file]
app/src/main/res/layout/new_transaction_row.xml [new file with mode: 0644]

index 6579463..6f71ace 100644 (file)
 
 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);
+    }
 }
index cd3576a..cef9910 100644 (file)
 
 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<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() {
@@ -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<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
@@ -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 (file)
index 0000000..2e4c0e6
--- /dev/null
@@ -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 <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);
+    }
+}
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 (file)
index 0000000..1585f1d
--- /dev/null
@@ -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 <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);
+    }
+}
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 (file)
index 0000000..0f46d8a
--- /dev/null
@@ -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 <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);
+        }
+    }
+}
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 (file)
index 0000000..ee6f5a7
--- /dev/null
@@ -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 <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);
+    }
+}
index 15c292d..9320a74 100644 (file)
     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"
diff --git a/app/src/main/res/layout/content_new_transaction.xml b/app/src/main/res/layout/content_new_transaction.xml
deleted file mode 100644 (file)
index b4ff8c6..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-<?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
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 (file)
index 0db8d6b..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?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
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 (file)
index 0000000..2356e5c
--- /dev/null
@@ -0,0 +1,118 @@
+<?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