From 346b3c8e74a12b1822239481f807479fa81fc706 Mon Sep 17 00:00:00 2001 From: Damyan Ivanov Date: Sat, 27 Feb 2021 20:06:18 +0000 Subject: [PATCH] rework new transaction activity/model/etc with proper concept separation activity monitors the model and sends changes to adapter; view holder propagates changes to the model this makes it possible to properly handle QR scan results in the activity without dirty tricks --- .../mobileledger/dao/TemplateHeaderDAO.java | 2 +- .../db/AccountAutocompleteAdapter.java | 5 + .../mobileledger/model/MatchedTemplate.java | 31 + .../NewTransactionActivity.java | 296 +++- .../NewTransactionFragment.java | 417 +----- .../NewTransactionItemHolder.java | 626 ++++---- .../NewTransactionItemsAdapter.java | 603 +------- .../new_transaction/NewTransactionModel.java | 1328 +++++++++++++---- .../ui/templates/TemplatesActivity.java | 2 +- .../res/layout/activity_new_transaction.xml | 17 +- .../res/layout/fragment_new_transaction.xml | 14 - 11 files changed, 1734 insertions(+), 1607 deletions(-) create mode 100644 app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java diff --git a/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java b/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java index 2d3d7ee4..bfc2a562 100644 --- a/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java +++ b/app/src/main/java/net/ktnx/mobileledger/dao/TemplateHeaderDAO.java @@ -106,7 +106,7 @@ public abstract class TemplateHeaderDAO { } } - public void getTemplateWitAccountsAsync(@NonNull Long id, @NonNull + public void getTemplateWithAccountsAsync(@NonNull Long id, @NonNull AsyncResultCallback callback) { LiveData resultReceiver = getTemplateWithAccounts(id); resultReceiver.observeForever(new Observer() { diff --git a/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java b/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java index ecc21db7..6cf1dc8c 100644 --- a/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/db/AccountAutocompleteAdapter.java @@ -24,6 +24,7 @@ import android.widget.Filter; import androidx.annotation.NonNull; import net.ktnx.mobileledger.dao.AccountDAO; +import net.ktnx.mobileledger.model.MobileLedgerProfile; import net.ktnx.mobileledger.utils.Logger; import java.util.ArrayList; @@ -37,6 +38,10 @@ public class AccountAutocompleteAdapter extends ArrayAdapter { public AccountAutocompleteAdapter(Context context) { super(context, android.R.layout.simple_dropdown_item_1line, new ArrayList<>()); } + public AccountAutocompleteAdapter(Context context, @NonNull MobileLedgerProfile profile) { + this(context); + profileUUID = profile.getUuid(); + } public void setProfileUUID(String profileUUID) { this.profileUUID = profileUUID; } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java b/app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java new file mode 100644 index 00000000..607d6c77 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/model/MatchedTemplate.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2021 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.model; + +import net.ktnx.mobileledger.db.TemplateHeader; + +import java.util.regex.MatchResult; + +public class MatchedTemplate { + public TemplateHeader templateHead; + public MatchResult matchResult; + public MatchedTemplate(TemplateHeader templateHead, MatchResult matchResult) { + this.templateHead = templateHead; + this.matchResult = matchResult; + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java index 413365db..ab28ca16 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java @@ -17,43 +17,70 @@ package net.ktnx.mobileledger.ui.new_transaction; +import android.content.Intent; +import android.database.AbstractCursor; +import android.database.Cursor; import android.os.Bundle; +import android.os.ParcelFormatException; +import android.text.TextUtils; import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import androidx.appcompat.widget.Toolbar; +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; + import net.ktnx.mobileledger.BuildConfig; import net.ktnx.mobileledger.R; import net.ktnx.mobileledger.async.AsyncCrasher; +import net.ktnx.mobileledger.async.DescriptionSelectedCallback; import net.ktnx.mobileledger.async.SendTransactionTask; import net.ktnx.mobileledger.async.TaskCallback; +import net.ktnx.mobileledger.databinding.ActivityNewTransactionBinding; +import net.ktnx.mobileledger.db.DB; +import net.ktnx.mobileledger.db.TemplateHeader; import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.LedgerTransaction; +import net.ktnx.mobileledger.model.MatchedTemplate; +import net.ktnx.mobileledger.ui.QR; import net.ktnx.mobileledger.ui.activity.ProfileThemedActivity; +import net.ktnx.mobileledger.ui.templates.TemplatesActivity; +import net.ktnx.mobileledger.utils.Logger; +import net.ktnx.mobileledger.utils.MLDB; +import net.ktnx.mobileledger.utils.Misc; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static net.ktnx.mobileledger.utils.Logger.debug; -public class NewTransactionActivity extends ProfileThemedActivity implements TaskCallback, - NewTransactionFragment.OnNewTransactionFragmentInteractionListener { +public class NewTransactionActivity extends ProfileThemedActivity + implements TaskCallback, NewTransactionFragment.OnNewTransactionFragmentInteractionListener, + QR.QRScanTrigger, QR.QRScanResultReceiver, DescriptionSelectedCallback { private NavController navController; private NewTransactionModel model; + private ActivityResultLauncher qrScanLauncher; + private ActivityNewTransactionBinding b; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_new_transaction); - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); + b = ActivityNewTransactionBinding.inflate(getLayoutInflater(), null, false); + setContentView(b.getRoot()); + setSupportActionBar(b.toolbar); Data.observeProfile(this, - mobileLedgerProfile -> toolbar.setSubtitle(mobileLedgerProfile.getName())); + mobileLedgerProfile -> b.toolbar.setSubtitle(mobileLedgerProfile.getName())); NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull( getSupportFragmentManager().findFragmentById(R.id.new_transaction_nav)); @@ -63,6 +90,23 @@ public class NewTransactionActivity extends ProfileThemedActivity implements Tas .setDisplayHomeAsUpEnabled(true); model = new ViewModelProvider(this).get(NewTransactionModel.class); + + qrScanLauncher = QR.registerLauncher(this, this); + + model.isSubmittable() + .observe(this, isSubmittable -> { + if (isSubmittable) { + b.fabAdd.show(); + } + else { + b.fabAdd.hide(); + } + }); +// viewModel.checkTransactionSubmittable(listAdapter); + + b.fabAdd.setOnClickListener(v -> onFabPressed()); + + } @Override protected void initProfile() { @@ -95,7 +139,7 @@ public class NewTransactionActivity extends ProfileThemedActivity implements Tas try { SendTransactionTask saver = - new SendTransactionTask(this, mProfile, model.getSimulateSave()); + new SendTransactionTask(this, mProfile, model.getSimulateSaveFlag()); saver.execute(tr); } catch (Exception e) { @@ -121,11 +165,12 @@ public class NewTransactionActivity extends ProfileThemedActivity implements Tas .setVisible(true); } - model.observeSimulateSave(this, state -> { - menu.findItem(R.id.action_simulate_save) - .setChecked(state); - findViewById(R.id.simulationLabel).setVisibility(state ? View.VISIBLE : View.GONE); - }); + model.getSimulateSave() + .observe(this, state -> { + menu.findItem(R.id.action_simulate_save) + .setChecked(state); + b.simulationLabel.setVisibility(state ? View.VISIBLE : View.GONE); + }); return true; } @@ -149,4 +194,229 @@ public class NewTransactionActivity extends ProfileThemedActivity implements Tas model.toggleSimulateSave(); } + @Override + public void triggerQRScan() { + qrScanLauncher.launch(null); + } + private void startNewPatternActivity(String scanned) { + Intent intent = new Intent(this, TemplatesActivity.class); + Bundle args = new Bundle(); + args.putString(TemplatesActivity.ARG_ADD_TEMPLATE, scanned); + startActivity(intent, args); + } + private void alertNoTemplateMatch(String scanned) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this); + builder.setCancelable(true) + .setMessage(R.string.no_template_matches) + .setPositiveButton(R.string.add_button, + (dialog, which) -> startNewPatternActivity(scanned)) + .create() + .show(); + } + public void onQRScanResult(String text) { + Logger.debug("qr", String.format("Got QR scan result [%s]", text)); + + if (Misc.emptyIsNull(text) == null) + return; + + LiveData> allTemplates = DB.get() + .getTemplateDAO() + .getTemplates(); + allTemplates.observe(this, templateHeaders -> { + ArrayList matchingFallbackTemplates = new ArrayList<>(); + ArrayList matchingTemplates = new ArrayList<>(); + + for (TemplateHeader ph : templateHeaders) { + String patternSource = ph.getRegularExpression(); + if (Misc.emptyIsNull(patternSource) == null) + continue; + try { + Pattern pattern = Pattern.compile(patternSource); + Matcher matcher = pattern.matcher(text); + if (!matcher.matches()) + continue; + + Logger.debug("pattern", + String.format("Pattern '%s' [%s] matches '%s'", ph.getName(), + patternSource, text)); + if (ph.isFallback()) + matchingFallbackTemplates.add( + new MatchedTemplate(ph, matcher.toMatchResult())); + else + matchingTemplates.add(new MatchedTemplate(ph, matcher.toMatchResult())); + } + catch (ParcelFormatException e) { + // ignored + Logger.debug("pattern", + String.format("Error compiling regular expression '%s'", patternSource), + e); + } + } + + if (matchingTemplates.isEmpty()) + matchingTemplates = matchingFallbackTemplates; + + if (matchingTemplates.isEmpty()) + alertNoTemplateMatch(text); + else if (matchingTemplates.size() == 1) + model.applyTemplate(matchingTemplates.get(0), text); + else + chooseTemplate(matchingTemplates, text); + }); + } + private void chooseTemplate(ArrayList matchingTemplates, String matchedText) { + final String templateNameColumn = "name"; + AbstractCursor cursor = new AbstractCursor() { + @Override + public int getCount() { + return matchingTemplates.size(); + } + @Override + public String[] getColumnNames() { + return new String[]{"_id", templateNameColumn}; + } + @Override + public String getString(int column) { + if (column == 0) + return String.valueOf(getPosition()); + return matchingTemplates.get(getPosition()).templateHead.getName(); + } + @Override + public short getShort(int column) { + if (column == 0) + return (short) getPosition(); + return -1; + } + @Override + public int getInt(int column) { + return getShort(column); + } + @Override + public long getLong(int column) { + return getShort(column); + } + @Override + public float getFloat(int column) { + return getShort(column); + } + @Override + public double getDouble(int column) { + return getShort(column); + } + @Override + public boolean isNull(int column) { + return false; + } + @Override + public int getColumnCount() { + return 2; + } + }; + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this); + builder.setCancelable(true) + .setTitle(R.string.choose_template_to_apply) + .setIcon(R.drawable.ic_baseline_auto_graph_24) + .setSingleChoiceItems(cursor, -1, templateNameColumn, (dialog, which) -> { + model.applyTemplate(matchingTemplates.get(which), matchedText); + dialog.dismiss(); + }) + .create() + .show(); + } + public void descriptionSelected(String description) { + debug("description selected", description); + if (!model.accountListIsEmpty()) + return; + + String accFilter = mProfile.getPreferredAccountsFilter(); + + ArrayList params = new ArrayList<>(); + StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t"); + + if (!TextUtils.isEmpty(accFilter)) { + sb.append(" JOIN transaction_accounts ta") + .append(" ON ta.profile = t.profile") + .append(" AND ta.transaction_id = t.id"); + } + + sb.append(" WHERE t.description=?"); + params.add(description); + + if (!TextUtils.isEmpty(accFilter)) { + sb.append(" AND ta.account_name LIKE '%'||?||'%'"); + params.add(accFilter); + } + + sb.append(" ORDER BY t.year desc, t.month desc, t.day desc LIMIT 1"); + + final String sql = sb.toString(); + debug("description", sql); + debug("description", params.toString()); + + // FIXME: handle exceptions? + MLDB.queryInBackground(sql, params.toArray(new String[]{}), new MLDB.CallbackHelper() { + @Override + public void onStart() { + model.incrementBusyCounter(); + } + @Override + public void onDone() { + model.decrementBusyCounter(); + } + @Override + public boolean onRow(@NonNull Cursor cursor) { + final String profileUUID = cursor.getString(0); + final int transactionId = cursor.getInt(1); + runOnUiThread(() -> model.loadTransactionIntoModel(profileUUID, transactionId)); + return false; // limit 1, by the way + } + @Override + public void onNoRows() { + if (TextUtils.isEmpty(accFilter)) + return; + + debug("description", "Trying transaction search without preferred account filter"); + + final String broaderSql = + "select t.profile, t.id from transactions t where t.description=?" + + " ORDER BY year desc, month desc, day desc LIMIT 1"; + params.remove(1); + debug("description", broaderSql); + debug("description", description); + + runOnUiThread(() -> Snackbar.make(b.newTransactionNav, + R.string.ignoring_preferred_account, Snackbar.LENGTH_INDEFINITE) + .show()); + + MLDB.queryInBackground(broaderSql, new String[]{description}, + new MLDB.CallbackHelper() { + @Override + public void onStart() { + model.incrementBusyCounter(); + } + @Override + public boolean onRow(@NonNull Cursor cursor) { + final String profileUUID = cursor.getString(0); + final int transactionId = cursor.getInt(1); + runOnUiThread(() -> model.loadTransactionIntoModel(profileUUID, + transactionId)); + return false; + } + @Override + public void onDone() { + model.decrementBusyCounter(); + } + }); + } + }); + } + private void onFabPressed() { + b.fabAdd.hide(); + Misc.hideSoftKeyboard(this); + + LedgerTransaction tr = model.constructLedgerTransaction(); + + onTransactionSave(tr); + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java index 5edf252d..d1f19c46 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java @@ -18,11 +18,8 @@ package net.ktnx.mobileledger.ui.new_transaction; import android.content.Context; -import android.content.Intent; import android.content.res.Resources; -import android.database.AbstractCursor; import android.os.Bundle; -import android.os.ParcelFormatException; import android.renderscript.RSInvalidStateException; import android.view.LayoutInflater; import android.view.Menu; @@ -37,39 +34,22 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -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.R; -import net.ktnx.mobileledger.db.DB; -import net.ktnx.mobileledger.db.TemplateAccount; -import net.ktnx.mobileledger.db.TemplateHeader; import net.ktnx.mobileledger.json.API; 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.QRScanCapableFragment; -import net.ktnx.mobileledger.ui.templates.TemplatesActivity; +import net.ktnx.mobileledger.ui.QR; import net.ktnx.mobileledger.utils.Logger; -import net.ktnx.mobileledger.utils.Misc; -import net.ktnx.mobileledger.utils.SimpleDate; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - /** * A simple {@link Fragment} subclass. * Activities that contain this fragment must implement the @@ -79,310 +59,15 @@ import java.util.regex.Pattern; // TODO: offer to undo account remove-on-swipe -public class NewTransactionFragment extends QRScanCapableFragment { +public class NewTransactionFragment extends Fragment { private NewTransactionItemsAdapter listAdapter; private NewTransactionModel viewModel; - private FloatingActionButton fab; private OnNewTransactionFragmentInteractionListener mListener; private MobileLedgerProfile mProfile; public NewTransactionFragment() { // Required empty public constructor setHasOptionsMenu(true); } - private void startNewPatternActivity(String scanned) { - Intent intent = new Intent(requireContext(), TemplatesActivity.class); - Bundle args = new Bundle(); - args.putString(TemplatesActivity.ARG_ADD_TEMPLATE, scanned); - requireContext().startActivity(intent, args); - } - private void alertNoTemplateMatch(String scanned) { - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); - builder.setCancelable(true) - .setMessage(R.string.no_template_matches) - .setPositiveButton(R.string.add_button, - (dialog, which) -> startNewPatternActivity(scanned)) - .create() - .show(); - } - protected void onQrScanned(String text) { - Logger.debug("qr", String.format("Got QR scan result [%s]", text)); - - if (Misc.emptyIsNull(text) == null) - return; - - LiveData> allTemplates = DB.get() - .getTemplateDAO() - .getTemplates(); - allTemplates.observe(getViewLifecycleOwner(), templateHeaders -> { - ArrayList matchingFallbackTemplates = new ArrayList<>(); - ArrayList matchingTemplates = new ArrayList<>(); - - for (TemplateHeader ph : templateHeaders) { - String patternSource = ph.getRegularExpression(); - if (Misc.emptyIsNull(patternSource) == null) - continue; - try { - Pattern pattern = Pattern.compile(patternSource); - Matcher matcher = pattern.matcher(text); - if (!matcher.matches()) - continue; - - Logger.debug("pattern", - String.format("Pattern '%s' [%s] matches '%s'", ph.getName(), - patternSource, text)); - if (ph.isFallback()) - matchingFallbackTemplates.add(ph); - else - matchingTemplates.add(ph); - } - catch (ParcelFormatException e) { - // ignored - Logger.debug("pattern", - String.format("Error compiling regular expression '%s'", patternSource), - e); - } - } - - if (matchingTemplates.isEmpty()) - matchingTemplates = matchingFallbackTemplates; - - if (matchingTemplates.isEmpty()) - alertNoTemplateMatch(text); - else if (matchingTemplates.size() == 1) - applyTemplate(matchingTemplates.get(0), text); - else - chooseTemplate(matchingTemplates, text); - }); - } - private void chooseTemplate(ArrayList matchingTemplates, String matchedText) { - final String templateNameColumn = "name"; - AbstractCursor cursor = new AbstractCursor() { - @Override - public int getCount() { - return matchingTemplates.size(); - } - @Override - public String[] getColumnNames() { - return new String[]{"_id", templateNameColumn}; - } - @Override - public String getString(int column) { - if (column == 0) - return String.valueOf(getPosition()); - return matchingTemplates.get(getPosition()) - .getName(); - } - @Override - public short getShort(int column) { - if (column == 0) - return (short) getPosition(); - return -1; - } - @Override - public int getInt(int column) { - return getShort(column); - } - @Override - public long getLong(int column) { - return getShort(column); - } - @Override - public float getFloat(int column) { - return getShort(column); - } - @Override - public double getDouble(int column) { - return getShort(column); - } - @Override - public boolean isNull(int column) { - return false; - } - @Override - public int getColumnCount() { - return 2; - } - }; - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); - builder.setCancelable(true) - .setTitle(R.string.choose_template_to_apply) - .setIcon(R.drawable.ic_baseline_auto_graph_24) - .setSingleChoiceItems(cursor, -1, templateNameColumn, (dialog, which) -> { - applyTemplate(matchingTemplates.get(which), matchedText); - dialog.dismiss(); - }) - .create() - .show(); - } - private void applyTemplate(TemplateHeader patternHeader, String text) { - Pattern pattern = Pattern.compile(patternHeader.getRegularExpression()); - - Matcher m = pattern.matcher(text); - - if (!m.matches()) { - Snackbar.make(requireView(), R.string.pattern_does_not_match, - BaseTransientBottomBar.LENGTH_INDEFINITE) - .show(); - return; - } - - SimpleDate transactionDate; - { - int day = extractIntFromMatches(m, patternHeader.getDateDayMatchGroup(), - patternHeader.getDateDay()); - int month = extractIntFromMatches(m, patternHeader.getDateMonthMatchGroup(), - patternHeader.getDateMonth()); - int year = extractIntFromMatches(m, patternHeader.getDateYearMatchGroup(), - patternHeader.getDateYear()); - - SimpleDate today = SimpleDate.today(); - if (year <= 0) - year = today.year; - if (month <= 0) - month = today.month; - if (day <= 0) - day = today.day; - - transactionDate = new SimpleDate(year, month, day); - - Logger.debug("pattern", "setting transaction date to " + transactionDate); - } - - NewTransactionModel.Item head = viewModel.getItem(0); - head.ensureType(NewTransactionModel.ItemType.generalData); - final String transactionDescription = - extractStringFromMatches(m, patternHeader.getTransactionDescriptionMatchGroup(), - patternHeader.getTransactionDescription()); - head.setDescription(transactionDescription); - Logger.debug("pattern", "Setting transaction description to " + transactionDescription); - final String transactionComment = - extractStringFromMatches(m, patternHeader.getTransactionCommentMatchGroup(), - patternHeader.getTransactionComment()); - head.setTransactionComment(transactionComment); - Logger.debug("pattern", "Setting transaction comment to " + transactionComment); - head.setDate(transactionDate); - listAdapter.notifyItemChanged(0); - - DB.get() - .getTemplateDAO() - .getTemplateWithAccounts(patternHeader.getId()) - .observe(getViewLifecycleOwner(), entry -> { - int rowIndex = 0; - final boolean accountsInInitialState = viewModel.accountsInInitialState(); - for (TemplateAccount acc : entry.accounts) { - rowIndex++; - - String accountName = extractStringFromMatches(m, acc.getAccountNameMatchGroup(), - acc.getAccountName()); - String accountComment = - extractStringFromMatches(m, acc.getAccountCommentMatchGroup(), - acc.getAccountComment()); - Float amount = - extractFloatFromMatches(m, acc.getAmountMatchGroup(), acc.getAmount()); - if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount()) - amount = -amount; - - if (accountsInInitialState) { - NewTransactionModel.Item item = viewModel.getItem(rowIndex); - if (item == null) { - Logger.debug("pattern", String.format(Locale.US, - "Adding new account item [%s][c:%s][a:%s]", accountName, - accountComment, amount)); - final LedgerTransactionAccount ledgerAccount = - new LedgerTransactionAccount(accountName); - ledgerAccount.setComment(accountComment); - if (amount != null) - ledgerAccount.setAmount(amount); - // TODO currency - viewModel.addAccount(ledgerAccount); - listAdapter.notifyItemInserted(viewModel.items.size() - 1); - } - else { - Logger.debug("pattern", String.format(Locale.US, - "Stamping account item #%d [%s][c:%s][a:%s]", rowIndex, - accountName, accountComment, amount)); - - item.setAccountName(accountName); - item.setComment(accountComment); - if (amount != null) - item.getAccount() - .setAmount(amount); - - listAdapter.notifyItemChanged(rowIndex); - } - } - else { - final LedgerTransactionAccount transactionAccount = - new LedgerTransactionAccount(accountName); - transactionAccount.setComment(accountComment); - if (amount != null) - transactionAccount.setAmount(amount); - // TODO currency - Logger.debug("pattern", String.format(Locale.US, - "Adding trailing account item [%s][c:%s][a:%s]", accountName, - accountComment, amount)); - - viewModel.addAccount(transactionAccount); - listAdapter.notifyItemInserted(viewModel.items.size() - 1); - } - } - - listAdapter.checkTransactionSubmittable(); - }); - } - private int extractIntFromMatches(Matcher m, Integer group, Integer literal) { - if (literal != null) - return literal; - - if (group != null) { - int grp = group; - if (grp > 0 & grp <= m.groupCount()) - try { - return Integer.parseInt(m.group(grp)); - } - catch (NumberFormatException e) { - Snackbar.make(requireView(), - "Error extracting transaction date: " + e.getMessage(), - BaseTransientBottomBar.LENGTH_INDEFINITE) - .show(); - } - } - - return 0; - } - private String extractStringFromMatches(Matcher m, Integer group, String literal) { - if (literal != null) - return literal; - - if (group != null) { - int grp = group; - if (grp > 0 & grp <= m.groupCount()) - return m.group(grp); - } - - return null; - } - private Float extractFloatFromMatches(Matcher m, Integer group, Float literal) { - if (literal != null) - return literal; - - if (group != null) { - int grp = group; - if (grp > 0 & grp <= m.groupCount()) - try { - return Float.valueOf(m.group(grp)); - } - catch (NumberFormatException e) { - Snackbar.make(requireView(), - "Error extracting transaction amount: " + e.getMessage(), - BaseTransientBottomBar.LENGTH_INDEFINITE) - .show(); - } - } - - return null; - } @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); @@ -395,7 +80,7 @@ public class NewTransactionFragment extends QRScanCapableFragment { menu.findItem(R.id.action_reset_new_transaction_activity) .setOnMenuItemClickListener(item -> { - listAdapter.reset(); + viewModel.reset(); return true; }); @@ -405,7 +90,8 @@ public class NewTransactionFragment extends QRScanCapableFragment { return true; }); if (activity != null) - viewModel.showCurrency.observe(activity, toggleCurrencyItem::setChecked); + viewModel.getShowCurrency() + .observe(activity, toggleCurrencyItem::setChecked); final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments); toggleCommentsItem.setOnMenuItemClickListener(item -> { @@ -413,11 +99,14 @@ public class NewTransactionFragment extends QRScanCapableFragment { return true; }); if (activity != null) - viewModel.showComments.observe(activity, toggleCommentsItem::setChecked); + viewModel.getShowComments() + .observe(activity, toggleCommentsItem::setChecked); } private boolean onScanQrAction(MenuItem item) { try { - scanQrLauncher.launch(null); + Context ctx = requireContext(); + if (ctx instanceof QR.QRScanTrigger) + ((QR.QRScanTrigger) ctx).triggerQRScan(); } catch (Exception e) { Logger.debug("qr", "Error launching QR scanner", e); @@ -445,6 +134,9 @@ public class NewTransactionFragment extends QRScanCapableFragment { mProfile = Data.getProfile(); listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile); + viewModel.getItems() + .observe(getViewLifecycleOwner(), newList -> listAdapter.setItems(newList)); + RecyclerView list = activity.findViewById(R.id.new_transaction_accounts); list.setAdapter(listAdapter); list.setLayoutManager(new LinearLayoutManager(activity)); @@ -453,25 +145,6 @@ public class NewTransactionFragment extends QRScanCapableFragment { mProfile = profile; listAdapter.setProfile(profile); }); - listAdapter.notifyDataSetChanged(); - viewModel.isSubmittable() - .observe(getViewLifecycleOwner(), isSubmittable -> { - if (isSubmittable) { - if (fab != null) { - fab.show(); - } - } - else { - if (fab != null) { - fab.hide(); - } - } - }); -// viewModel.checkTransactionSubmittable(listAdapter); - - fab = activity.findViewById(R.id.fabAdd); - fab.setOnClickListener(v -> onFabPressed()); - boolean keep = false; Bundle args = getArguments(); @@ -512,73 +185,45 @@ public class NewTransactionFragment extends QRScanCapableFragment { } int focused = 0; + FocusedElement element = null; if (savedInstanceState != null) { keep |= savedInstanceState.getBoolean("keep", true); - focused = savedInstanceState.getInt("focused", 0); + focused = savedInstanceState.getInt("focused-item", 0); + element = FocusedElement.valueOf(savedInstanceState.getString("focused-element")); } if (!keep) viewModel.reset(); else { - viewModel.setFocusedItem(focused); + viewModel.noteFocusChanged(focused, element); } ProgressBar p = activity.findViewById(R.id.progressBar); - viewModel.observeBusyFlag(getViewLifecycleOwner(), isBusy -> { - if (isBusy) { + viewModel.getBusyFlag() + .observe(getViewLifecycleOwner(), isBusy -> { + if (isBusy) { // Handler h = new Handler(); // h.postDelayed(() -> { // if (viewModel.getBusyFlag()) // p.setVisibility(View.VISIBLE); // // }, 10); - p.setVisibility(View.VISIBLE); - } - else - p.setVisibility(View.INVISIBLE); - }); + p.setVisibility(View.VISIBLE); + } + else + p.setVisibility(View.INVISIBLE); + }); } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean("keep", true); - final int focusedItem = viewModel.getFocusedItem(); - outState.putInt("focused", focusedItem); - } - private void onFabPressed() { - fab.hide(); - Misc.hideSoftKeyboard(this); - if (mListener != null) { - SimpleDate date = viewModel.getDate(); - LedgerTransaction tr = - new LedgerTransaction(null, date, viewModel.getDescription(), mProfile); - - tr.setComment(viewModel.getComment()); - LedgerTransactionAccount emptyAmountAccount = null; - float emptyAmountAccountBalance = 0; - for (int i = 0; i < viewModel.getAccountCount(); i++) { - LedgerTransactionAccount acc = - new LedgerTransactionAccount(viewModel.getAccount(i)); - if (acc.getAccountName() - .trim() - .isEmpty()) - continue; - - if (acc.isAmountSet()) { - emptyAmountAccountBalance += acc.getAmount(); - } - else { - emptyAmountAccount = acc; - } - - tr.addAccount(acc); - } - - if (emptyAmountAccount != null) - emptyAmountAccount.setAmount(-emptyAmountAccountBalance); - - mListener.onTransactionSave(tr); - } + final NewTransactionModel.FocusInfo focusInfo = viewModel.getFocusInfo() + .getValue(); + final int focusedItem = focusInfo.position; + if (focusedItem >= 0) + outState.putInt("focused-item", focusedItem); + outState.putString("focused-element", focusInfo.element.toString()); } @Override diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java index 3bf09d27..a69a65af 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java @@ -25,21 +25,20 @@ import android.text.TextWatcher; import android.view.Gravity; import android.view.View; import android.view.inputmethod.EditorInfo; +import android.widget.EditText; import android.widget.TextView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; 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.databinding.NewTransactionRowBinding; +import net.ktnx.mobileledger.db.AccountAutocompleteAdapter; import net.ktnx.mobileledger.model.Currency; import net.ktnx.mobileledger.model.Data; -import net.ktnx.mobileledger.model.LedgerTransactionAccount; import net.ktnx.mobileledger.model.MobileLedgerProfile; import net.ktnx.mobileledger.ui.CurrencySelectorFragment; import net.ktnx.mobileledger.ui.DatePickerFragment; @@ -52,41 +51,24 @@ import net.ktnx.mobileledger.utils.SimpleDate; import java.text.DecimalFormatSymbols; import java.text.ParseException; -import java.util.Date; -import java.util.Locale; - -import static net.ktnx.mobileledger.ui.new_transaction.NewTransactionModel.ItemType; +import java.util.Objects; class NewTransactionItemHolder extends RecyclerView.ViewHolder implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback { private final String decimalDot; - private final Observer showCommentsObserver; private final MobileLedgerProfile mProfile; - private final Observer dateObserver; - private final Observer descriptionObserver; - private final Observer transactionCommentObserver; - private final Observer hintObserver; - private final Observer focusedAccountObserver; - private final Observer accountCountObserver; - private final Observer editableObserver; - private final Observer currencyPositionObserver; - private final Observer currencyGapObserver; - private final Observer localeObserver; - private final Observer currencyObserver; - private final Observer showCurrencyObserver; - private final Observer commentObserver; - private final Observer amountValidityObserver; private final NewTransactionRowBinding b; + private final NewTransactionItemsAdapter mAdapter; + private boolean ignoreFocusChanges = false; private String decimalSeparator; - private NewTransactionModel.Item item; - private Date date; private boolean inUpdate = false; private boolean syncingData = false; - //TODO multiple amounts with different currencies per posting + //TODO multiple amounts with different currencies per posting? NewTransactionItemHolder(@NonNull NewTransactionRowBinding b, NewTransactionItemsAdapter adapter) { super(b.getRoot()); this.b = b; + this.mAdapter = adapter; new TextViewClearHelper().attachToTextView(b.comment); b.newTransactionDescription.setNextFocusForwardId(View.NO_ID); @@ -114,7 +96,6 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder syncingData = true; try { final int pos = getAdapterPosition(); - adapter.updateFocusedItem(pos); if (id == R.id.account_row_acc_name) { adapter.noteFocusIsOnAccount(pos); } @@ -130,6 +111,8 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder else if (id == R.id.new_transaction_description) { adapter.noteFocusIsOnDescription(pos); } + else + throw new IllegalStateException("Where is the focus?"); } finally { syncingData = wasSyncing; @@ -150,18 +133,18 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder b.comment.setOnFocusChangeListener(focusMonitor); b.transactionComment.setOnFocusChangeListener(focusMonitor); - MLDB.hookAutocompletionAdapter(b.getRoot() - .getContext(), b.newTransactionDescription, - MLDB.DESCRIPTION_HISTORY_TABLE, "description", false, adapter, mProfile); - MLDB.hookAutocompletionAdapter(b.getRoot() - .getContext(), b.accountRowAccName, MLDB.ACCOUNTS_TABLE, - "name", true, this, mProfile); + NewTransactionActivity activity = (NewTransactionActivity) b.getRoot() + .getContext(); + + MLDB.hookAutocompletionAdapter(activity, b.newTransactionDescription, + MLDB.DESCRIPTION_HISTORY_TABLE, "description", false, activity, mProfile); + b.accountRowAccName.setAdapter(new AccountAutocompleteAdapter(b.getRoot() + .getContext(), mProfile)); - decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance() - .getMonetaryDecimalSeparator()); - localeObserver = locale -> decimalSeparator = String.valueOf( + decimalSeparator = ""; + Data.locale.observe(activity, locale -> decimalSeparator = String.valueOf( DecimalFormatSymbols.getInstance(locale) - .getMonetaryDecimalSeparator()); + .getMonetaryDecimalSeparator())); decimalDot = "."; @@ -184,229 +167,207 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder syncData(); Logger.debug("textWatcher", "syncData() returned, checking if transaction is submittable"); - adapter.checkTransactionSubmittable(); + adapter.model.checkTransactionSubmittable(null); Logger.debug("textWatcher", "done"); } }; final TextWatcher amountWatcher = new TextWatcher() { @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - Logger.debug("num", - String.format(Locale.US, "beforeTextChanged: start=%d, count=%d, after=%d", - start, count, after)); - } + 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) { + checkAmountValid(s.toString()); if (syncData()) - adapter.checkTransactionSubmittable(); + adapter.model.checkTransactionSubmittable(null); } }; b.newTransactionDescription.addTextChangedListener(tw); - b.transactionComment.addTextChangedListener(tw); + monitorComment(b.transactionComment); b.accountRowAccName.addTextChangedListener(tw); - b.comment.addTextChangedListener(tw); + monitorComment(b.comment); b.accountRowAccAmounts.addTextChangedListener(amountWatcher); b.currencyButton.setOnClickListener(v -> { CurrencySelectorFragment cpf = new CurrencySelectorFragment(); cpf.showPositionAndPadding(); - cpf.setOnCurrencySelectedListener(c -> item.setCurrency(c)); - final AppCompatActivity activity = (AppCompatActivity) v.getContext(); + cpf.setOnCurrencySelectedListener( + c -> adapter.setItemCurrency(getAdapterPosition(), c.getName())); cpf.show(activity.getSupportFragmentManager(), "currency-selector"); }); - dateObserver = date -> { - if (syncingData) - return; - syncingData = true; - try { - b.newTransactionDate.setText(item.getFormattedDate()); - } - finally { - syncingData = false; - } - }; - descriptionObserver = description -> { - if (syncingData) - return; - syncingData = true; - try { - b.newTransactionDescription.setText(description); - } - finally { - syncingData = false; - } - }; - transactionCommentObserver = transactionComment -> { - final View focusedView = b.transactionComment.findFocus(); - b.transactionComment.setTypeface(null, - (focusedView == b.transactionComment) ? Typeface.NORMAL : Typeface.ITALIC); - b.transactionComment.setVisibility( - ((focusedView != b.transactionComment) && TextUtils.isEmpty(transactionComment)) - ? View.INVISIBLE : View.VISIBLE); - - }; - hintObserver = hint -> { - if (syncingData) - return; - syncingData = true; - try { - if (hint == null) - b.accountRowAccAmounts.setHint(R.string.zero_amount); - else - b.accountRowAccAmounts.setHint(hint); - } - finally { - syncingData = false; - } - }; - editableObserver = this::setEditable; commentFocusChanged(b.transactionComment, false); commentFocusChanged(b.comment, false); - focusedAccountObserver = index -> { - if ((index == null) || !index.equals(getAdapterPosition()) || itemView.hasFocus()) - return; - - switch (item.getType()) { - case generalData: - // bad idea - double pop-up, and not really necessary. - // the user can tap the input to get the calendar - //if (!tvDate.hasFocus()) tvDate.requestFocus(); - switch (item.getFocusedElement()) { - case TransactionComment: - b.transactionComment.setVisibility(View.VISIBLE); - b.transactionComment.requestFocus(); - break; - case Description: - boolean focused = b.newTransactionDescription.requestFocus(); -// tvDescription.dismissDropDown(); - if (focused) - Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot() - .getContext()); - break; - } - break; - case transactionRow: - switch (item.getFocusedElement()) { - case Amount: - b.accountRowAccAmounts.requestFocus(); - break; - case Comment: - b.comment.setVisibility(View.VISIBLE); - b.comment.requestFocus(); - break; - case Account: - boolean focused = b.accountRowAccName.requestFocus(); - b.accountRowAccName.dismissDropDown(); - if (focused) - Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot() - .getContext()); - break; - } - - break; - } - }; - accountCountObserver = count -> { - final int adapterPosition = getAdapterPosition(); - final int layoutPosition = getLayoutPosition(); - Logger.debug("holder", - String.format(Locale.US, "count=%d; pos=%d, layoutPos=%d [%s]", count, - adapterPosition, layoutPosition, item.getType() - .toString() - .concat(item.getType() == - ItemType.transactionRow - ? String.format(Locale.US, - "'%s'=%s", - item.getAccount() - .getAccountName(), - item.getAccount() - .isAmountSet() - ? String.format(Locale.US, - "%.2f", - item.getAccount() - .getAmount()) - : "unset") : ""))); - if (adapterPosition == count) - b.accountRowAccAmounts.setImeOptions(EditorInfo.IME_ACTION_DONE); - else - b.accountRowAccAmounts.setImeOptions(EditorInfo.IME_ACTION_NEXT); - }; - currencyObserver = currency -> { - setCurrency(currency); - adapter.checkTransactionSubmittable(); - }; - - currencyGapObserver = + adapter.model.getFocusInfo() + .observe(activity, focusInfo -> { + if (ignoreFocusChanges) { + Logger.debug("new-trans", "Ignoring focus change"); + return; + } + ignoreFocusChanges = true; + try { + if (((focusInfo == null) || + focusInfo.position != getAdapterPosition()) || + itemView.hasFocus()) + return; + + NewTransactionModel.Item item = getItem(); + if (item instanceof NewTransactionModel.TransactionHead) { + NewTransactionModel.TransactionHead head = + item.toTransactionHead(); + // bad idea - double pop-up, and not really necessary. + // the user can tap the input to get the calendar + //if (!tvDate.hasFocus()) tvDate.requestFocus(); + switch (focusInfo.element) { + case TransactionComment: + b.transactionComment.setVisibility(View.VISIBLE); + b.transactionComment.requestFocus(); + break; + case Description: + boolean focused = + b.newTransactionDescription.requestFocus(); +// tvDescription.dismissDropDown(); + if (focused) + Misc.showSoftKeyboard( + (NewTransactionActivity) b.getRoot() + .getContext()); + break; + } + } + else if (item instanceof NewTransactionModel.TransactionAccount) { + NewTransactionModel.TransactionAccount acc = + item.toTransactionAccount(); + switch (focusInfo.element) { + case Amount: + b.accountRowAccAmounts.requestFocus(); + break; + case Comment: + b.comment.setVisibility(View.VISIBLE); + b.comment.requestFocus(); + break; + case Account: + boolean focused = b.accountRowAccName.requestFocus(); +// b.accountRowAccName.dismissDropDown(); + if (focused) + Misc.showSoftKeyboard( + (NewTransactionActivity) b.getRoot() + .getContext()); + break; + } + } + } + finally { + ignoreFocusChanges = false; + } + }); + adapter.model.getAccountCount() + .observe(activity, count -> { + final int adapterPosition = getAdapterPosition(); + final int layoutPosition = getLayoutPosition(); + + if (adapterPosition == count) + b.accountRowAccAmounts.setImeOptions(EditorInfo.IME_ACTION_DONE); + else + b.accountRowAccAmounts.setImeOptions(EditorInfo.IME_ACTION_NEXT); + }); + + Data.currencyGap.observe(activity, hasGap -> updateCurrencyPositionAndPadding(Data.currencySymbolPosition.getValue(), - hasGap); - - currencyPositionObserver = - position -> updateCurrencyPositionAndPadding(position, Data.currencyGap.getValue()); - - showCurrencyObserver = showCurrency -> { - if (showCurrency) { - b.currency.setVisibility(View.VISIBLE); - b.currencyButton.setVisibility(View.VISIBLE); - String defaultCommodity = mProfile.getDefaultCommodity(); - item.setCurrency( - (defaultCommodity == null) ? null : Currency.loadByName(defaultCommodity)); - } - else { - b.currency.setVisibility(View.GONE); - b.currencyButton.setVisibility(View.GONE); - item.setCurrency(null); + hasGap)); + + Data.currencySymbolPosition.observe(activity, + position -> updateCurrencyPositionAndPadding(position, + Data.currencyGap.getValue())); + + adapter.model.getShowCurrency() + .observe(activity, showCurrency -> { + if (showCurrency) { + b.currency.setVisibility(View.VISIBLE); + b.currencyButton.setVisibility(View.VISIBLE); + b.currency.setText(mProfile.getDefaultCommodity()); + } + else { + b.currency.setVisibility(View.GONE); + b.currencyButton.setVisibility(View.GONE); + b.currency.setText(null); + } + }); + + adapter.model.getShowComments() + .observe(activity, show -> { + ConstraintLayout.LayoutParams amountLayoutParams = + (ConstraintLayout.LayoutParams) b.amountLayout.getLayoutParams(); + ConstraintLayout.LayoutParams accountParams = + (ConstraintLayout.LayoutParams) b.accountRowAccName.getLayoutParams(); + + if (show) { + accountParams.endToStart = ConstraintLayout.LayoutParams.UNSET; + accountParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; + + amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.UNSET; + amountLayoutParams.topToBottom = b.accountRowAccName.getId(); + + b.commentLayout.setVisibility(View.VISIBLE); + } + else { + accountParams.endToStart = b.amountLayout.getId(); + accountParams.endToEnd = ConstraintLayout.LayoutParams.UNSET; + + amountLayoutParams.topToBottom = ConstraintLayout.LayoutParams.UNSET; + amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; + + b.commentLayout.setVisibility(View.GONE); + } + + b.accountRowAccName.setLayoutParams(accountParams); + b.amountLayout.setLayoutParams(amountLayoutParams); + + b.transactionCommentLayout.setVisibility(show ? View.VISIBLE : View.GONE); + }); + } + public void checkAmountValid(String s) { + boolean valid = true; + try { + if (s.length() > 0) { + float ignored = Float.parseFloat(s.replace(decimalSeparator, decimalDot)); } - }; - - commentObserver = comment -> { - final View focusedView = b.comment.findFocus(); - b.comment.setTypeface(null, - (focusedView == b.comment) ? Typeface.NORMAL : Typeface.ITALIC); - b.comment.setVisibility( - ((focusedView != b.comment) && TextUtils.isEmpty(comment)) ? View.INVISIBLE - : View.VISIBLE); - }; - - showCommentsObserver = show -> { - ConstraintLayout.LayoutParams amountLayoutParams = - (ConstraintLayout.LayoutParams) b.amountLayout.getLayoutParams(); - ConstraintLayout.LayoutParams accountParams = - (ConstraintLayout.LayoutParams) b.accountRowAccName.getLayoutParams(); - if (show) { - accountParams.endToStart = ConstraintLayout.LayoutParams.UNSET; - accountParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; - - amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.UNSET; - amountLayoutParams.topToBottom = b.accountRowAccName.getId(); + } + catch (NumberFormatException ex) { + valid = false; + } - b.commentLayout.setVisibility(View.VISIBLE); + displayAmountValidity(valid); + } + private void displayAmountValidity(boolean valid) { + b.accountRowAccAmounts.setCompoundDrawablesRelativeWithIntrinsicBounds( + valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0); + b.accountRowAccAmounts.setMinEms(valid ? 4 : 5); + } + private void monitorComment(EditText editText) { + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } - else { - accountParams.endToStart = b.amountLayout.getId(); - accountParams.endToEnd = ConstraintLayout.LayoutParams.UNSET; - - amountLayoutParams.topToBottom = ConstraintLayout.LayoutParams.UNSET; - amountLayoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; - - b.commentLayout.setVisibility(View.GONE); + @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; - b.accountRowAccName.setLayoutParams(accountParams); - b.amountLayout.setLayoutParams(amountLayoutParams); - - b.transactionCommentLayout.setVisibility(show ? View.VISIBLE : View.GONE); - }; - - amountValidityObserver = valid -> { - b.accountRowAccAmounts.setCompoundDrawablesRelativeWithIntrinsicBounds( - valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0); - b.accountRowAccAmounts.setMinEms(valid ? 4 : 5); - }; + Logger.debug("textWatcher", "calling syncData()"); + syncData(); + Logger.debug("textWatcher", + "syncData() returned, checking if transaction is submittable"); + styleComment(editText, s.toString()); + Logger.debug("textWatcher", "done"); + } + }); } private void commentFocusChanged(TextView textView, boolean hasFocus) { @ColorInt int textColor; @@ -515,62 +476,60 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder * checked for being submittable */ private boolean syncData() { - if (item == null) - return false; - if (syncingData) { Logger.debug("new-trans", "skipping syncData() loop"); return false; } + NewTransactionModel.Item item = getItem(); + syncingData = true; try { - switch (item.getType()) { - case generalData: - item.setDate(String.valueOf(b.newTransactionDate.getText())); - item.setDescription(String.valueOf(b.newTransactionDescription.getText())); - item.setTransactionComment(String.valueOf(b.transactionComment.getText())); - break; - case transactionRow: - final LedgerTransactionAccount account = item.getAccount(); - account.setAccountName(String.valueOf(b.accountRowAccName.getText())); - - item.setComment(String.valueOf(b.comment.getText())); - - String amount = String.valueOf(b.accountRowAccAmounts.getText()); - amount = amount.trim(); - - if (amount.isEmpty()) { - account.resetAmount(); - item.validateAmount(); + if (item instanceof NewTransactionModel.TransactionHead) { + NewTransactionModel.TransactionHead head = item.toTransactionHead(); + + head.setDate(String.valueOf(b.newTransactionDate.getText())); + head.setDescription(String.valueOf(b.newTransactionDescription.getText())); + head.setComment(String.valueOf(b.transactionComment.getText())); + } + else if (item instanceof NewTransactionModel.TransactionAccount) { + NewTransactionModel.TransactionAccount acc = item.toTransactionAccount(); + acc.setAccountName(String.valueOf(b.accountRowAccName.getText())); + + acc.setComment(String.valueOf(b.comment.getText())); + + String amount = String.valueOf(b.accountRowAccAmounts.getText()); + amount = amount.trim(); + + if (amount.isEmpty()) { + acc.resetAmount(); + acc.setAmountValid(true); + } + else { + try { + amount = amount.replace(decimalSeparator, decimalDot); + acc.setAmount(Float.parseFloat(amount)); + acc.setAmountValid(true); } - else { - try { - amount = amount.replace(decimalSeparator, decimalDot); - account.setAmount(Float.parseFloat(amount)); - item.validateAmount(); - } - catch (NumberFormatException e) { - Logger.debug("new-trans", String.format( - "assuming amount is not set due to number format exception. " + - "input was '%s'", amount)); - account.invalidateAmount(); - item.invalidateAmount(); - } - final String curr = String.valueOf(b.currency.getText()); - if (curr.equals(b.currency.getContext() - .getResources() - .getString(R.string.currency_symbol)) || - curr.isEmpty()) - account.setCurrency(null); - else - account.setCurrency(curr); + catch (NumberFormatException e) { + Logger.debug("new-trans", String.format( + "assuming amount is not set due to number format exception. " + + "input was '%s'", amount)); + acc.setAmountValid(false); } - - break; - case bottomFiller: - throw new RuntimeException("Should not happen"); + final String curr = String.valueOf(b.currency.getText()); + if (curr.equals(b.currency.getContext() + .getResources() + .getString(R.string.currency_symbol)) || + curr.isEmpty()) + acc.setCurrency(null); + else + acc.setCurrency(curr); + } + } + else { + throw new RuntimeException("Should not happen"); } return true; @@ -591,117 +550,96 @@ class NewTransactionItemHolder extends RecyclerView.ViewHolder .getContext()).getSupportFragmentManager(), null); } /** - * setData + * bind * * @param item updates the UI elements with the data from the model item */ @SuppressLint("DefaultLocale") - public void setData(NewTransactionModel.Item item) { + public void bind(@NonNull NewTransactionModel.Item item) { beginUpdates(); try { - if (this.item != null && !this.item.equals(item)) { - this.item.stopObservingDate(dateObserver); - this.item.stopObservingDescription(descriptionObserver); - this.item.stopObservingTransactionComment(transactionCommentObserver); - this.item.stopObservingAmountHint(hintObserver); - this.item.stopObservingEditableFlag(editableObserver); - this.item.getModel() - .stopObservingFocusedItem(focusedAccountObserver); - this.item.getModel() - .stopObservingAccountCount(accountCountObserver); - Data.currencySymbolPosition.removeObserver(currencyPositionObserver); - Data.currencyGap.removeObserver(currencyGapObserver); - Data.locale.removeObserver(localeObserver); - this.item.stopObservingCurrency(currencyObserver); - this.item.getModel().showCurrency.removeObserver(showCurrencyObserver); - this.item.stopObservingComment(commentObserver); - this.item.getModel().showComments.removeObserver(showCommentsObserver); - this.item.stopObservingAmountValidity(amountValidityObserver); - - this.item = null; - } + syncingData = true; + try { + if (item instanceof NewTransactionModel.TransactionHead) { + NewTransactionModel.TransactionHead head = item.toTransactionHead(); + b.newTransactionDate.setText(head.getFormattedDate()); + b.newTransactionDescription.setText(head.getDescription()); + + b.transactionComment.setText(head.getComment()); + //styleComment(b.transactionComment, head.getComment()); - switch (item.getType()) { - case generalData: - b.newTransactionDate.setText(item.getFormattedDate()); - b.newTransactionDescription.setText(item.getDescription()); - b.transactionComment.setText(item.getTransactionComment()); b.ntrData.setVisibility(View.VISIBLE); b.ntrAccount.setVisibility(View.GONE); b.ntrPadding.setVisibility(View.GONE); setEditable(true); - break; - case transactionRow: - LedgerTransactionAccount acc = item.getAccount(); + } + else if (item instanceof NewTransactionModel.TransactionAccount) { + NewTransactionModel.TransactionAccount acc = item.toTransactionAccount(); + b.accountRowAccName.setText(acc.getAccountName()); - b.comment.setText(acc.getComment()); - if (acc.isAmountSet()) { - b.accountRowAccAmounts.setText(String.format("%1.2f", acc.getAmount())); + + final String amountHint = acc.getAmountHint(); + if (amountHint == null) { + b.accountRowAccAmounts.setHint(R.string.zero_amount); } else { - b.accountRowAccAmounts.setText(""); -// tvAmount.setHint(R.string.zero_amount); + b.accountRowAccAmounts.setHint(amountHint); } - b.accountRowAccAmounts.setHint(item.getAmountHint()); + setCurrencyString(acc.getCurrency()); + b.accountRowAccAmounts.setText( + acc.isAmountSet() ? String.format("%4.2f", acc.getAmount()) : null); + displayAmountValidity(true); + + b.comment.setText(acc.getComment()); + b.ntrData.setVisibility(View.GONE); b.ntrAccount.setVisibility(View.VISIBLE); b.ntrPadding.setVisibility(View.GONE); setEditable(true); - break; - case bottomFiller: + } + else if (item instanceof NewTransactionModel.BottomFiller) { b.ntrData.setVisibility(View.GONE); b.ntrAccount.setVisibility(View.GONE); b.ntrPadding.setVisibility(View.VISIBLE); setEditable(false); - break; - } - if (this.item == null) { // was null or has changed - this.item = item; - final NewTransactionActivity activity = (NewTransactionActivity) b.getRoot() - .getContext(); - - if (!item.isBottomFiller()) { - item.observeEditableFlag(activity, editableObserver); - item.getModel() - .observeFocusedItem(activity, focusedAccountObserver); - item.getModel() - .observeShowComments(activity, showCommentsObserver); } - switch (item.getType()) { - case generalData: - item.observeDate(activity, dateObserver); - item.observeDescription(activity, descriptionObserver); - item.observeTransactionComment(activity, transactionCommentObserver); - break; - case transactionRow: - item.observeAmountHint(activity, hintObserver); - Data.currencySymbolPosition.observe(activity, currencyPositionObserver); - Data.currencyGap.observe(activity, currencyGapObserver); - Data.locale.observe(activity, localeObserver); - item.observeCurrency(activity, currencyObserver); - item.getModel().showCurrency.observe(activity, showCurrencyObserver); - item.observeComment(activity, commentObserver); - item.getModel() - .observeAccountCount(activity, accountCountObserver); - item.observeAmountValidity(activity, amountValidityObserver); - break; + else { + throw new RuntimeException("Don't know how to handle " + item); } } + finally { + syncingData = false; + } } finally { endUpdates(); } } + private void styleComment(EditText editText, String comment) { + final View focusedView = editText.findFocus(); + editText.setTypeface(null, (focusedView == editText) ? Typeface.NORMAL : Typeface.ITALIC); + editText.setVisibility( + ((focusedView != editText) && TextUtils.isEmpty(comment)) ? View.INVISIBLE + : View.VISIBLE); + } @Override public void onDatePicked(int year, int month, int day) { - item.setDate(new SimpleDate(year, month + 1, day)); + final NewTransactionModel.TransactionHead head = getItem().toTransactionHead(); + head.setDate(new SimpleDate(year, month + 1, day)); + b.newTransactionDate.setText(head.getFormattedDate()); + boolean focused = b.newTransactionDescription.requestFocus(); if (focused) Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot() .getContext()); } + private NewTransactionModel.Item getItem() { + return Objects.requireNonNull(mAdapter.model.getItems() + .getValue()) + .get(getAdapterPosition()); + } @Override public void descriptionSelected(String description) { b.accountRowAccName.setText(description); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java index 29b4e9aa..f8686dcf 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java @@ -17,58 +17,54 @@ package net.ktnx.mobileledger.ui.new_transaction; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.database.Cursor; -import android.text.TextUtils; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.snackbar.Snackbar; - -import net.ktnx.mobileledger.BuildConfig; -import net.ktnx.mobileledger.R; -import net.ktnx.mobileledger.async.DescriptionSelectedCallback; import net.ktnx.mobileledger.databinding.NewTransactionRowBinding; -import net.ktnx.mobileledger.model.Currency; -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 net.ktnx.mobileledger.utils.MLDB; -import net.ktnx.mobileledger.utils.Misc; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.Set; - -import static net.ktnx.mobileledger.utils.Logger.debug; +import java.util.Objects; -class NewTransactionItemsAdapter extends RecyclerView.Adapter - implements DescriptionSelectedCallback { - private final NewTransactionModel model; +class NewTransactionItemsAdapter extends RecyclerView.Adapter { + final NewTransactionModel model; private final ItemTouchHelper touchHelper; + private final AsyncListDiffer differ = + new AsyncListDiffer<>(this, new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull NewTransactionModel.Item oldItem, + @NonNull NewTransactionModel.Item newItem) { +// Logger.debug("new-trans", +// String.format("comparing ids of {%s} and {%s}", oldItem.toString(), +// newItem.toString())); + return oldItem.getId() == newItem.getId(); + } + @Override + public boolean areContentsTheSame(@NonNull NewTransactionModel.Item oldItem, + @NonNull NewTransactionModel.Item newItem) { + +// Logger.debug("new-trans", +// String.format("comparing contents of {%s} and {%s}", oldItem.toString(), +// newItem.toString())); + return oldItem.equalContents(newItem); + } + }); private MobileLedgerProfile mProfile; - private RecyclerView recyclerView; private int checkHoldCounter = 0; NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) { super(); + setHasStableIds(true); 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(); - } + NewTransactionItemsAdapter adapter = this; @@ -111,35 +107,25 @@ class NewTransactionItemsAdapter extends RecyclerView.Adapter params = new ArrayList<>(); - StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t"); - - if (!TextUtils.isEmpty(accFilter)) { - sb.append(" JOIN transaction_accounts ta") - .append(" ON ta.profile = t.profile") - .append(" AND ta.transaction_id = t.id"); - } - - sb.append(" WHERE t.description=?"); - params.add(description); - - if (!TextUtils.isEmpty(accFilter)) { - sb.append(" AND ta.account_name LIKE '%'||?||'%'"); - params.add(accFilter); - } - - sb.append(" ORDER BY t.year desc, t.month desc, t.day desc LIMIT 1"); - - final String sql = sb.toString(); - debug("description", sql); - debug("description", params.toString()); - - Activity activity = (Activity) recyclerView.getContext(); - // FIXME: handle exceptions? - MLDB.queryInBackground(sql, params.toArray(new String[]{}), new MLDB.CallbackHelper() { - @Override - public void onStart() { - model.incrementBusyCounter(); - } - @Override - public void onDone() { - model.decrementBusyCounter(); - } - @Override - public boolean onRow(@NonNull Cursor cursor) { - final String profileUUID = cursor.getString(0); - final int transactionId = cursor.getInt(1); - activity.runOnUiThread(() -> loadTransactionIntoModel(profileUUID, transactionId)); - return false; // limit 1, by the way - } - @Override - public void onNoRows() { - if (TextUtils.isEmpty(accFilter)) - return; - - debug("description", "Trying transaction search without preferred account filter"); - - final String broaderSql = - "select t.profile, t.id from transactions t where t.description=?" + - " ORDER BY year desc, month desc, day desc LIMIT 1"; - params.remove(1); - debug("description", broaderSql); - debug("description", description); - - activity.runOnUiThread( - () -> Snackbar.make(recyclerView, R.string.ignoring_preferred_account, - Snackbar.LENGTH_INDEFINITE) - .show()); - - MLDB.queryInBackground(broaderSql, new String[]{description}, - new MLDB.CallbackHelper() { - @Override - public void onStart() { - model.incrementBusyCounter(); - } - @Override - public boolean onRow(@NonNull Cursor cursor) { - final String profileUUID = cursor.getString(0); - final int transactionId = cursor.getInt(1); - activity.runOnUiThread( - () -> loadTransactionIntoModel(profileUUID, transactionId)); - return false; - } - @Override - public void onDone() { - model.decrementBusyCounter(); - } - }); - } - }); - } - private void loadTransactionIntoModel(String profileUUID, int transactionId) { - 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", - profileUUID, transactionId)); - - tr = profile.loadTransaction(transactionId); - List accounts = tr.getAccounts(); - NewTransactionModel.Item firstNegative = null; - NewTransactionModel.Item firstPositive = null; - int singleNegativeIndex = -1; - int singlePositiveIndex = -1; - int negativeCount = 0; - for (int i = 0; i < accounts.size(); i++) { - LedgerTransactionAccount acc = accounts.get(i); - NewTransactionModel.Item item; - if (model.getAccountCount() < i + 1) { - model.addAccount(acc); - notifyItemInserted(i + 1); - } - item = model.getItem(i + 1); - - item.getAccount() - .setAccountName(acc.getAccountName()); - item.setComment(acc.getComment()); - if (acc.isAmountSet()) { - item.getAccount() - .setAmount(acc.getAmount()); - if (acc.getAmount() < 0) { - if (firstNegative == null) { - firstNegative = item; - singleNegativeIndex = i; - } - else - singleNegativeIndex = -1; - } - else { - if (firstPositive == null) { - firstPositive = item; - singlePositiveIndex = i; - } - else - singlePositiveIndex = -1; - } - } - else - item.getAccount() - .resetAmount(); - notifyItemChanged(i + 1); - } - - if (singleNegativeIndex != -1) { - firstNegative.getAccount() - .resetAmount(); - model.moveItemLast(singleNegativeIndex); - } - else if (singlePositiveIndex != -1) { - firstPositive.getAccount() - .resetAmount(); - model.moveItemLast(singlePositiveIndex); - } - - checkTransactionSubmittable(); - model.setFocusedItem(1); - } - public void toggleAllEditing(boolean editable) { - // item 0 is the header - for (int i = 0; i <= model.getAccountCount(); i++) { - model.getItem(i) - .setEditable(editable); - notifyItemChanged(i); - // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)? - } - } - 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 - } - void updateFocusedItem(int position) { - model.updateFocusedItem(position); } void noteFocusIsOnAccount(int position) { - model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account); + model.noteFocusChanged(position, FocusedElement.Account); } void noteFocusIsOnAmount(int position) { - model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount); + model.noteFocusChanged(position, FocusedElement.Amount); } void noteFocusIsOnComment(int position) { - model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment); + model.noteFocusChanged(position, FocusedElement.Comment); } void noteFocusIsOnTransactionComment(int position) { - model.noteFocusChanged(position, NewTransactionModel.FocusedElement.TransactionComment); + model.noteFocusChanged(position, FocusedElement.TransactionComment); } public void noteFocusIsOnDescription(int pos) { - model.noteFocusChanged(pos, NewTransactionModel.FocusedElement.Description); + model.noteFocusChanged(pos, FocusedElement.Description); } private void holdSubmittableChecks() { checkHoldCounter++; @@ -385,313 +183,12 @@ class NewTransactionItemsAdapter extends RecyclerView.Adapter 0) - return; - - int accounts = 0; - final BalanceForCurrency balance = new BalanceForCurrency(); - final String descriptionText = model.getDescription(); - boolean submittable = true; - final ItemsForCurrency itemsForCurrency = new ItemsForCurrency(); - final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency(); - final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency(); - final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency(); - final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency(); - final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency(); - final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency(); - final List emptyRows = new ArrayList<>(); - - try { - if ((descriptionText == null) || descriptionText.trim() - .isEmpty()) - { - Logger.debug("submittable", "Transaction not submittable: missing description"); - submittable = false; - } - - for (int i = 0; i < model.items.size(); i++) { - NewTransactionModel.Item item = model.items.get(i); - - LedgerTransactionAccount acc = item.getAccount(); - String acc_name = acc.getAccountName() - .trim(); - String currName = acc.getCurrency(); - - itemsForCurrency.add(currName, item); - - if (acc_name.isEmpty()) { - itemsWithEmptyAccountForCurrency.add(currName, item); - - if (acc.isAmountSet()) { - // 2) each amount has account name - Logger.debug("submittable", String.format( - "Transaction not submittable: row %d has no account name, but" + - " has" + " amount %1.2f", i + 1, acc.getAmount())); - submittable = false; - } - else { - emptyRowsForCurrency.add(currName, item); - } - } - else { - accounts++; - itemsWithAccountForCurrency.add(currName, item); - } - - if (!acc.isAmountValid()) { - Logger.debug("submittable", - String.format("Not submittable: row %d has an invalid amount", i + 1)); - submittable = false; - } - else if (acc.isAmountSet()) { - itemsWithAmountForCurrency.add(currName, item); - balance.add(currName, acc.getAmount()); - } - else { - itemsWithEmptyAmountForCurrency.add(currName, item); - - if (!acc_name.isEmpty()) - itemsWithAccountAndEmptyAmountForCurrency.add(currName, item); - } - } - - // 1) has at least two account names - if (accounts < 2) { - if (accounts == 0) - Logger.debug("submittable", - "Transaction not submittable: no account " + "names"); - else if (accounts == 1) - Logger.debug("submittable", - "Transaction not submittable: only one account name"); - else - Logger.debug("submittable", - String.format("Transaction not submittable: only %d account names", - accounts)); - submittable = false; - } - - // 3) for each commodity: - // 3a) amount must balance to 0, or - // 3b) there must be exactly one empty amount (with account) - for (String balCurrency : itemsForCurrency.currencies()) { - float currencyBalance = balance.get(balCurrency); - if (Misc.isZero(currencyBalance)) { - // remove hints from all amount inputs in that currency - for (NewTransactionModel.Item item : model.items) { - if (Currency.equal(item.getCurrency(), balCurrency)) - item.setAmountHint(null); - } - } - else { - List list = - itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency); - int balanceReceiversCount = list.size(); - if (balanceReceiversCount != 1) { - if (BuildConfig.DEBUG) { - if (balanceReceiversCount == 0) - Logger.debug("submittable", String.format( - "Transaction not submittable [%s]: non-zero balance " + - "with no empty amounts with accounts", balCurrency)); - else - Logger.debug("submittable", String.format( - "Transaction not submittable [%s]: non-zero balance " + - "with multiple empty amounts with accounts", balCurrency)); - } - submittable = false; - } - - List emptyAmountList = - itemsWithEmptyAmountForCurrency.getList(balCurrency); - - // suggest off-balance amount to a row and remove hints on other rows - NewTransactionModel.Item receiver = null; - if (!list.isEmpty()) - receiver = list.get(0); - else if (!emptyAmountList.isEmpty()) - receiver = emptyAmountList.get(0); - - for (NewTransactionModel.Item item : model.items) { - if (!Currency.equal(item.getCurrency(), balCurrency)) - continue; - - if (item.equals(receiver)) { - if (BuildConfig.DEBUG) - Logger.debug("submittable", - String.format("Setting amount hint to %1.2f [%s]", - -currencyBalance, balCurrency)); - item.setAmountHint(String.format("%1.2f", -currencyBalance)); - } - else { - if (BuildConfig.DEBUG) - Logger.debug("submittable", - String.format("Resetting hint of '%s' [%s]", - (item.getAccount() == null) ? "" : item.getAccount() - .getAccountName(), - balCurrency)); - item.setAmountHint(null); - } - } - } - } - - // 5) a row with an empty account name or empty amount is guaranteed to exist for - // each commodity - for (String balCurrency : balance.currencies()) { - int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency); - int currRows = itemsForCurrency.size(balCurrency); - int currAccounts = itemsWithAccountForCurrency.size(balCurrency); - int currAmounts = itemsWithAmountForCurrency.size(balCurrency); - if ((currEmptyRows == 0) && - ((currRows == currAccounts) || (currRows == currAmounts))) - { - // perhaps there already is an unused empty row for another currency that - // is not used? -// boolean foundIt = false; -// for (Item item : emptyRows) { -// Currency itemCurrency = item.getCurrency(); -// String itemCurrencyName = -// (itemCurrency == null) ? "" : itemCurrency.getName(); -// if (Misc.isZero(balance.get(itemCurrencyName))) { -// item.setCurrency(Currency.loadByName(balCurrency)); -// item.setAmountHint( -// String.format("%1.2f", -balance.get(balCurrency))); -// foundIt = true; -// break; -// } -// } -// -// if (!foundIt) - addRow(balCurrency); - } - } - - // drop extra empty rows, not needed - for (String currName : emptyRowsForCurrency.currencies()) { - List emptyItems = emptyRowsForCurrency.getList(currName); - while ((model.items.size() > 2) && (emptyItems.size() > 1)) { - NewTransactionModel.Item item = emptyItems.get(1); - emptyItems.remove(1); - model.removeRow(item, this); - } - - // unused currency, remove last item (which is also an empty one) - if ((model.items.size() > 2) && (emptyItems.size() == 1)) { - List currItems = itemsForCurrency.getList(currName); - - if (currItems.size() == 1) { - NewTransactionModel.Item item = emptyItems.get(0); - model.removeRow(item, this); - } - } - } - - // 6) at least two rows need to be present in the ledger - while (model.items.size() < 2) - addRow(); - - - debug("submittable", submittable ? "YES" : "NO"); - model.isSubmittable.setValue(submittable); - - if (BuildConfig.DEBUG) { - debug("submittable", "== Dump of all items"); - for (int i = 0; i < model.items.size(); i++) { - NewTransactionModel.Item item = model.items.get(i); - LedgerTransactionAccount acc = item.getAccount(); - debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i, - acc.isAmountSet() ? acc.getAmount() : 0, - item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(), - acc.getAccountName(), acc.getComment())); - } - } - } - catch (NumberFormatException e) { - debug("submittable", "NO (because of NumberFormatException)"); - model.isSubmittable.setValue(false); - } - catch (Exception e) { - e.printStackTrace(); - debug("submittable", "NO (because of an Exception)"); - model.isSubmittable.setValue(false); - } - } - - private static class BalanceForCurrency { - private final HashMap hashMap = new HashMap<>(); - float get(String currencyName) { - Float f = hashMap.get(currencyName); - if (f == null) { - f = 0f; - hashMap.put(currencyName, f); - } - return f; - } - void add(String currencyName, float amount) { - hashMap.put(currencyName, get(currencyName) + amount); - } - Set currencies() { - return hashMap.keySet(); - } - boolean containsCurrency(String currencyName) { - return hashMap.containsKey(currencyName); - } - } - - private static class ItemsForCurrency { - private final HashMap> hashMap = new HashMap<>(); - @NonNull - List getList(@Nullable String currencyName) { - List list = hashMap.get(currencyName); - if (list == null) { - list = new ArrayList<>(); - hashMap.put(currencyName, list); - } - return list; - } - void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) { - getList(currencyName).add(item); - } - int size(@Nullable String currencyName) { - return this.getList(currencyName) - .size(); - } - Set currencies() { - return hashMap.keySet(); - } + public void setItems(List newList) { + Logger.debug("new-trans", "adapter: submitting new item list"); + differ.submitList(newList); } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java index b96b917a..c84c26d2 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java @@ -17,18 +17,30 @@ package net.ktnx.mobileledger.ui.new_transaction; +import android.annotation.SuppressLint; +import android.text.TextUtils; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModel; -import net.ktnx.mobileledger.model.Currency; +import net.ktnx.mobileledger.BuildConfig; +import net.ktnx.mobileledger.db.DB; +import net.ktnx.mobileledger.db.TemplateAccount; +import net.ktnx.mobileledger.db.TemplateHeader; import net.ktnx.mobileledger.model.Data; +import net.ktnx.mobileledger.model.InertMutableLiveData; +import net.ktnx.mobileledger.model.LedgerTransaction; import net.ktnx.mobileledger.model.LedgerTransactionAccount; +import net.ktnx.mobileledger.model.MatchedTemplate; import net.ktnx.mobileledger.model.MobileLedgerProfile; import net.ktnx.mobileledger.utils.Globals; +import net.ktnx.mobileledger.utils.Logger; +import net.ktnx.mobileledger.utils.Misc; import net.ktnx.mobileledger.utils.SimpleDate; import org.jetbrains.annotations.NotNull; @@ -36,171 +48,301 @@ import org.jetbrains.annotations.NotNull; import java.text.ParseException; import java.util.ArrayList; import java.util.Calendar; -import java.util.Collections; import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.MatchResult; + +enum ItemType {generalData, transactionRow, bottomFiller} + +enum FocusedElement {Account, Comment, Amount, Description, TransactionComment} + public class NewTransactionModel extends ViewModel { - final MutableLiveData showCurrency = new MutableLiveData<>(false); - final ArrayList items = new ArrayList<>(); - final MutableLiveData isSubmittable = new MutableLiveData<>(false); - final MutableLiveData showComments = new MutableLiveData<>(true); - private final Item header = new Item(this, ""); - private final Item trailer = new Item(this); - private final MutableLiveData focusedItem = new MutableLiveData<>(0); - private final MutableLiveData accountCount = new MutableLiveData<>(0); - private final MutableLiveData simulateSave = new MutableLiveData<>(false); + private final MutableLiveData showCurrency = new MutableLiveData<>(false); + private final MutableLiveData isSubmittable = new InertMutableLiveData<>(false); + private final MutableLiveData showComments = new MutableLiveData<>(true); + private final MutableLiveData> items = new MutableLiveData<>(); + private final MutableLiveData accountCount = new InertMutableLiveData<>(0); + private final MutableLiveData simulateSave = new InertMutableLiveData<>(false); private final AtomicInteger busyCounter = new AtomicInteger(0); - private final MutableLiveData busyFlag = new MutableLiveData<>(false); + private final MutableLiveData busyFlag = new InertMutableLiveData<>(false); private final Observer profileObserver = profile -> { showCurrency.postValue(profile.getShowCommodityByDefault()); showComments.postValue(profile.getShowCommentsByDefault()); }; + private final MutableLiveData focusInfo = new MutableLiveData<>(); private boolean observingDataProfile; - void observeShowComments(LifecycleOwner owner, Observer observer) { - showComments.observe(owner, observer); + public NewTransactionModel() { + reset(); } - void observeBusyFlag(@NonNull LifecycleOwner owner, Observer observer) { - busyFlag.observe(owner, observer); + public LiveData getShowCurrency() { + return showCurrency; } - void observeDataProfile(LifecycleOwner activity) { - if (!observingDataProfile) - Data.observeProfile(activity, profileObserver); - observingDataProfile = true; + public LiveData> getItems() { + return items; } - boolean getSimulateSave() { - return simulateSave.getValue(); + private void setItems(@NonNull List newList) { + checkTransactionSubmittable(newList); + setItemsWithoutSubmittableChecks(newList); } - public void setSimulateSave(boolean simulateSave) { - this.simulateSave.setValue(simulateSave); + private void setItemsWithoutSubmittableChecks(@NonNull List list) { + Logger.debug("new-trans", "model: Setting new item list"); + items.setValue(list); + accountCount.setValue(list.size() - 2); } - void toggleSimulateSave() { - simulateSave.setValue(!simulateSave.getValue()); + private List copyList() { + return copyList(null); } - void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.simulateSave.observe(owner, observer); + private List copyList(@Nullable List source) { + List copy = new ArrayList<>(); + List oldList = (source == null) ? items.getValue() : source; + + if (oldList != null) + for (Item item : oldList) { + copy.add(Item.from(item)); + } + + return copy; } - int getAccountCount() { - return items.size(); + private List shallowCopyListWithoutItem(int position) { + List copy = new ArrayList<>(); + List oldList = items.getValue(); + + if (oldList != null) { + int i = 0; + for (Item item : oldList) { + if (i++ == position) + continue; + copy.add(item); + } + } + + return copy; + } + private List shallowCopyList() { + return new ArrayList<>(items.getValue()); } - public SimpleDate getDate() { - return header.date.getValue(); + LiveData getShowComments() { + return showComments; + } + void observeDataProfile(LifecycleOwner activity) { + if (!observingDataProfile) + Data.observeProfile(activity, profileObserver); + observingDataProfile = true; } - public void setDate(SimpleDate date) { - header.date.setValue(date); + boolean getSimulateSaveFlag() { + Boolean value = simulateSave.getValue(); + if (value == null) + return false; + return value; } - public String getDescription() { - return header.description.getValue(); + LiveData getSimulateSave() { + return simulateSave; } - public String getComment() { - return header.comment.getValue(); + void toggleSimulateSave() { + simulateSave.setValue(!getSimulateSaveFlag()); } LiveData isSubmittable() { return this.isSubmittable; } void reset() { - header.date.setValue(null); - header.description.setValue(null); - header.comment.setValue(null); - items.clear(); - items.add(new Item(this, new LedgerTransactionAccount(""))); - items.add(new Item(this, new LedgerTransactionAccount(""))); - focusedItem.setValue(0); - } - void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.observe(owner, observer); - } - void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.removeObserver(observer); - } - void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.accountCount.observe(owner, observer); - } - void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer observer) { - this.accountCount.removeObserver(observer); - } - int getFocusedItem() { return focusedItem.getValue(); } - void setFocusedItem(int position) { - focusedItem.setValue(position); - } - int addAccount(LedgerTransactionAccount acc) { - items.add(new Item(this, acc)); - accountCount.setValue(getAccountCount()); - return items.size(); + List list = new ArrayList<>(); + list.add(new TransactionHead("")); + list.add(new TransactionAccount("")); + list.add(new TransactionAccount("")); + list.add(new BottomFiller()); + items.setValue(list); } boolean accountsInInitialState() { - for (Item item : items) { - LedgerTransactionAccount acc = item.getAccount(); - if (acc.isAmountSet()) - return false; - if (!acc.getAccountName() - .trim() - .isEmpty()) + final List list = items.getValue(); + + if (list == null) + return true; + + for (Item item : list) { + if (!(item instanceof TransactionAccount)) + continue; + + TransactionAccount accRow = (TransactionAccount) item; + if (!accRow.isEmpty()) return false; } return true; } - LedgerTransactionAccount getAccount(int index) { - return items.get(index) - .getAccount(); - } - Item getItem(int index) { - if (index == 0) { - return header; + void applyTemplate(MatchedTemplate matchedTemplate, String text) { + SimpleDate transactionDate = null; + final MatchResult matchResult = matchedTemplate.matchResult; + final TemplateHeader templateHead = matchedTemplate.templateHead; + { + int day = extractIntFromMatches(matchResult, templateHead.getDateDayMatchGroup(), + templateHead.getDateDay()); + int month = extractIntFromMatches(matchResult, templateHead.getDateMonthMatchGroup(), + templateHead.getDateMonth()); + int year = extractIntFromMatches(matchResult, templateHead.getDateYearMatchGroup(), + templateHead.getDateYear()); + + if (year > 0 || month > 0 || day > 0) { + SimpleDate today = SimpleDate.today(); + if (year <= 0) + year = today.year; + if (month <= 0) + month = today.month; + if (day <= 0) + day = today.day; + + transactionDate = new SimpleDate(year, month, day); + + Logger.debug("pattern", "setting transaction date to " + transactionDate); + } } - if (index <= items.size()) - return items.get(index - 1); + List present = copyList(); - return trailer; - } - void removeRow(Item item, NewTransactionItemsAdapter adapter) { - int pos = items.indexOf(item); - items.remove(pos); - if (adapter != null) { - adapter.notifyItemRemoved(pos + 1); - sendCountNotifications(); + TransactionHead head = new TransactionHead(present.get(0) + .toTransactionHead()); + if (transactionDate != null) + head.setDate(transactionDate); + + final String transactionDescription = extractStringFromMatches(matchResult, + templateHead.getTransactionDescriptionMatchGroup(), + templateHead.getTransactionDescription()); + if (Misc.emptyIsNull(transactionDescription) != null) + head.setDescription(transactionDescription); + + final String transactionComment = extractStringFromMatches(matchResult, + templateHead.getTransactionCommentMatchGroup(), + templateHead.getTransactionComment()); + if (Misc.emptyIsNull(transactionComment) != null) + head.setComment(transactionComment); + + List newItems = new ArrayList<>(); + + newItems.add(head); + + for (int i = 1; i < present.size() - 1; i++) { + final TransactionAccount row = present.get(i) + .toTransactionAccount(); + if (!row.isEmpty()) + newItems.add(new TransactionAccount(row)); } + + DB.get() + .getTemplateDAO() + .getTemplateWithAccountsAsync(templateHead.getId(), entry -> { + int rowIndex = 0; + final boolean accountsInInitialState = accountsInInitialState(); + for (TemplateAccount acc : entry.accounts) { + rowIndex++; + + String accountName = + extractStringFromMatches(matchResult, acc.getAccountNameMatchGroup(), + acc.getAccountName()); + String accountComment = + extractStringFromMatches(matchResult, acc.getAccountCommentMatchGroup(), + acc.getAccountComment()); + Float amount = extractFloatFromMatches(matchResult, acc.getAmountMatchGroup(), + acc.getAmount()); + if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount()) + amount = -amount; + + // TODO currency + TransactionAccount accRow = new TransactionAccount(accountName); + accRow.setComment(accountComment); + if (amount != null) + accRow.setAmount(amount); + + newItems.add(accRow); + } + + newItems.add(new BottomFiller()); + + items.postValue(newItems); + }); } - void removeItem(int pos) { - items.remove(pos); - accountCount.setValue(getAccountCount()); + private int extractIntFromMatches(MatchResult m, Integer group, Integer literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + try { + return Integer.parseInt(m.group(grp)); + } + catch (NumberFormatException e) { + Logger.debug("new-trans", "Error extracting matched number", e); + } + } + + return 0; } - void sendCountNotifications() { - accountCount.setValue(getAccountCount()); + private String extractStringFromMatches(MatchResult m, Integer group, String literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + return m.group(grp); + } + + return null; } - public void sendFocusedNotification() { - focusedItem.setValue(focusedItem.getValue()); + private Float extractFloatFromMatches(MatchResult m, Integer group, Float literal) { + if (literal != null) + return literal; + + if (group != null) { + int grp = group; + if (grp > 0 & grp <= m.groupCount()) + try { + return Float.valueOf(m.group(grp)); + } + catch (NumberFormatException e) { + Logger.debug("new-trans", "Error extracting matched number", e); + } + } + + return null; } - void updateFocusedItem(int position) { - focusedItem.setValue(position); + void removeItem(int pos) { + List newList = shallowCopyListWithoutItem(pos); + setItems(newList); } void noteFocusChanged(int position, FocusedElement element) { - getItem(position).setFocusedElement(element); + FocusInfo present = focusInfo.getValue(); + if (present == null || present.position != position || present.element != element) + focusInfo.setValue(new FocusInfo(position, element)); + } + public LiveData getFocusInfo() { + return focusInfo; } - void swapItems(int one, int two) { - Collections.swap(items, one - 1, two - 1); + void moveItem(int fromIndex, int toIndex) { + List newList = shallowCopyList(); + Item item = newList.remove(fromIndex); + newList.add(toIndex, item); + items.setValue(newList); // same count, same submittable state } - void moveItemLast(int index) { + void moveItemLast(List list, int index) { /* 0 1 <-- index 2 3 <-- desired position + (no bottom filler) */ - int itemCount = items.size(); + int itemCount = list.size(); - if (index < itemCount - 1) { - Item acc = items.remove(index); - items.add(itemCount - 1, acc); - } + if (index < itemCount - 1) + list.add(list.remove(index)); } void toggleCurrencyVisible() { - showCurrency.setValue(!showCurrency.getValue()); + showCurrency.setValue(!Objects.requireNonNull(showCurrency.getValue())); } void stopObservingBusyFlag(Observer observer) { busyFlag.removeObserver(observer); @@ -215,178 +357,580 @@ public class NewTransactionModel extends ViewModel { if (newValue == 0) busyFlag.postValue(false); } - public boolean getBusyFlag() { - return busyFlag.getValue(); + public LiveData getBusyFlag() { + return busyFlag; } public void toggleShowComments() { - showComments.setValue(!showComments.getValue()); + showComments.setValue(!Objects.requireNonNull(showComments.getValue())); } - enum ItemType {generalData, transactionRow, bottomFiller} + public LedgerTransaction constructLedgerTransaction() { + List list = Objects.requireNonNull(items.getValue()); + TransactionHead head = list.get(0) + .toTransactionHead(); + SimpleDate date = head.getDate(); + LedgerTransaction tr = head.asLedgerTransaction(); - enum FocusedElement {Account, Comment, Amount, Description, TransactionComment} + tr.setComment(head.getComment()); + LedgerTransactionAccount emptyAmountAccount = null; + float emptyAmountAccountBalance = 0; + for (int i = 1; i < list.size() - 1; i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + LedgerTransactionAccount acc = new LedgerTransactionAccount(item.getAccountName() + .trim(), + item.getCurrency()); + if (acc.getAccountName() + .isEmpty()) + continue; + acc.setComment(item.getComment()); - //========================================================================================== + if (item.isAmountSet()) { + acc.setAmount(item.getAmount()); + emptyAmountAccountBalance += item.getAmount(); + } + else { + emptyAmountAccount = acc; + } + tr.addAccount(acc); + } - static class Item { - private final ItemType type; - private final MutableLiveData date = new MutableLiveData<>(); - private final MutableLiveData description = new MutableLiveData<>(); - private final MutableLiveData amountHint = new MutableLiveData<>(null); - private final NewTransactionModel model; - private final MutableLiveData editable = new MutableLiveData<>(true); - private final MutableLiveData comment = new MutableLiveData<>(null); - private final MutableLiveData currency = new MutableLiveData<>(null); - private final MutableLiveData amountValid = new MutableLiveData<>(true); - private LedgerTransactionAccount account; - private FocusedElement focusedElement = FocusedElement.Account; - private boolean amountHintIsSet = false; - Item(NewTransactionModel model) { - this.model = model; - type = ItemType.bottomFiller; - editable.setValue(false); - } - Item(NewTransactionModel model, String description) { - this.model = model; - this.type = ItemType.generalData; - this.description.setValue(description); - this.editable.setValue(true); - } - Item(NewTransactionModel model, LedgerTransactionAccount account) { - this.model = model; - this.type = ItemType.transactionRow; - this.account = account; - String currName = account.getCurrency(); - Currency curr = null; - if ((currName != null) && !currName.isEmpty()) - curr = Currency.loadByName(currName); - this.currency.setValue(curr); - this.editable.setValue(true); - } - FocusedElement getFocusedElement() { - return focusedElement; + if (emptyAmountAccount != null) + emptyAmountAccount.setAmount(-emptyAmountAccountBalance); + + return tr; + } + void loadTransactionIntoModel(String profileUUID, int transactionId) { + List newList = new ArrayList<>(); + 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", + profileUUID, transactionId)); + + tr = profile.loadTransaction(transactionId); + TransactionHead head = new TransactionHead(tr.getDescription()); + head.setComment(tr.getComment()); + + newList.add(head); + + List accounts = tr.getAccounts(); + + TransactionAccount firstNegative = null; + TransactionAccount firstPositive = null; + int singleNegativeIndex = -1; + int singlePositiveIndex = -1; + int negativeCount = 0; + for (int i = 0; i < accounts.size(); i++) { + LedgerTransactionAccount acc = accounts.get(i); + TransactionAccount item = + new TransactionAccount(acc.getAccountName(), acc.getCurrency()); + newList.add(item); + + item.setAccountName(acc.getAccountName()); + item.setComment(acc.getComment()); + if (acc.isAmountSet()) { + item.setAmount(acc.getAmount()); + if (acc.getAmount() < 0) { + if (firstNegative == null) { + firstNegative = item; + singleNegativeIndex = i + 1; + } + else + singleNegativeIndex = -1; + } + else { + if (firstPositive == null) { + firstPositive = item; + singlePositiveIndex = i + 1; + } + else + singlePositiveIndex = -1; + } + } + else + item.resetAmount(); } - void setFocusedElement(FocusedElement focusedElement) { - this.focusedElement = focusedElement; + + if (singleNegativeIndex != -1) { + firstNegative.resetAmount(); + moveItemLast(newList, singleNegativeIndex); } - public NewTransactionModel getModel() { - return model; + else if (singlePositiveIndex != -1) { + firstPositive.resetAmount(); + moveItemLast(newList, singlePositiveIndex); } - void setEditable(boolean editable) { - ensureTypeIsGeneralDataOrTransactionRow(); - this.editable.setValue(editable); + + noteFocusChanged(1, FocusedElement.Description); + + newList.add(new BottomFiller()); + + setItems(newList); + } + /** + * A transaction is submittable if: + * 0) has description + * 1) has at least two account names + * 2) each row with amount has account name + * 3) for each commodity: + * 3a) amounts must balance to 0, or + * 3b) there must be exactly one empty amount (with account) + * 4) empty accounts with empty amounts are ignored + * Side effects: + * 5) a row with an empty account name or empty amount is guaranteed to exist for each + * commodity + * 6) at least two rows need to be present in the ledger + * + * @param list - the item list to check. Can be the displayed list or a list that will be + * displayed soon + */ + @SuppressLint("DefaultLocale") + void checkTransactionSubmittable(@Nullable List list) { + boolean workingWithLiveList = false; + boolean liveListCopied = false; + if (list == null) { + list = Objects.requireNonNull(items.getValue()); + workingWithLiveList = true; } - private void ensureTypeIsGeneralDataOrTransactionRow() { - if ((type != ItemType.generalData) && (type != ItemType.transactionRow)) { - throw new RuntimeException( - String.format("Actual type (%s) differs from wanted (%s or %s)", type, - ItemType.generalData, ItemType.transactionRow)); + + if (BuildConfig.DEBUG) + dumpItemList("Before submittable checks", list); + + int accounts = 0; + final BalanceForCurrency balance = new BalanceForCurrency(); + final String descriptionText = list.get(0) + .toTransactionHead() + .getDescription(); + boolean submittable = true; + boolean listChanged = false; + final ItemsForCurrency itemsForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency(); + final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency(); + final List emptyRows = new ArrayList<>(); + + try { + if ((descriptionText == null) || descriptionText.trim() + .isEmpty()) + { + Logger.debug("submittable", "Transaction not submittable: missing description"); + submittable = false; } - } - String getAmountHint() { - ensureType(ItemType.transactionRow); - return amountHint.getValue(); - } - void setAmountHint(String amountHint) { - ensureType(ItemType.transactionRow); - // avoid unnecessary triggers - if (amountHint == null) { - if (this.amountHint.getValue() == null) - return; - amountHintIsSet = false; + for (int i = 1; i < list.size() - 1; i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + + String accName = item.getAccountName() + .trim(); + String currName = item.getCurrency(); + + itemsForCurrency.add(currName, item); + + if (accName.isEmpty()) { + itemsWithEmptyAccountForCurrency.add(currName, item); + + if (item.isAmountSet()) { + // 2) each amount has account name + Logger.debug("submittable", String.format( + "Transaction not submittable: row %d has no account name, but" + + " has" + " amount %1.2f", i + 1, item.getAmount())); + submittable = false; + } + else { + emptyRowsForCurrency.add(currName, item); + } + } + else { + accounts++; + itemsWithAccountForCurrency.add(currName, item); + } + + if (!item.isAmountValid()) { + Logger.debug("submittable", + String.format("Not submittable: row %d has an invalid amount", i + 1)); + submittable = false; + } + else if (item.isAmountSet()) { + itemsWithAmountForCurrency.add(currName, item); + balance.add(currName, item.getAmount()); + } + else { + itemsWithEmptyAmountForCurrency.add(currName, item); + + if (!accName.isEmpty()) + itemsWithAccountAndEmptyAmountForCurrency.add(currName, item); + } } - else { - if (amountHint.equals(this.amountHint.getValue())) - return; - amountHintIsSet = true; + + // 1) has at least two account names + if (accounts < 2) { + if (accounts == 0) + Logger.debug("submittable", "Transaction not submittable: no account names"); + else if (accounts == 1) + Logger.debug("submittable", + "Transaction not submittable: only one account name"); + else + Logger.debug("submittable", + String.format("Transaction not submittable: only %d account names", + accounts)); + submittable = false; } - this.amountHint.setValue(amountHint); - } - void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.amountHint.observe(owner, observer); - } - void stopObservingAmountHint( - @NonNull androidx.lifecycle.Observer observer) { - this.amountHint.removeObserver(observer); + // 3) for each commodity: + // 3a) amount must balance to 0, or + // 3b) there must be exactly one empty amount (with account) + for (String balCurrency : itemsForCurrency.currencies()) { + float currencyBalance = balance.get(balCurrency); + if (Misc.isZero(currencyBalance)) { + // remove hints from all amount inputs in that currency + for (int i = 1; i < list.size() - 1; i++) { + TransactionAccount acc = list.get(i) + .toTransactionAccount(); + if (Misc.equalStrings(acc.getCurrency(), balCurrency)) { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format("Resetting hint of '%s' [%s]", + Misc.nullIsEmpty(acc.getAccountName()), + balCurrency)); + if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) { + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + final TransactionAccount newAcc = new TransactionAccount(acc); + newAcc.setAmountHint(null); + if (!liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + list.set(i, newAcc); + listChanged = true; + } + } + } + } + else { + List tmpList = + itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency); + int balanceReceiversCount = tmpList.size(); + if (balanceReceiversCount != 1) { + if (BuildConfig.DEBUG) { + if (balanceReceiversCount == 0) + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with no empty amounts with accounts", balCurrency)); + else + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with multiple empty amounts with accounts", balCurrency)); + } + submittable = false; + } + + List emptyAmountList = + itemsWithEmptyAmountForCurrency.getList(balCurrency); + + // suggest off-balance amount to a row and remove hints on other rows + Item receiver = null; + if (!tmpList.isEmpty()) + receiver = tmpList.get(0); + else if (!emptyAmountList.isEmpty()) + receiver = emptyAmountList.get(0); + + for (int i = 0; i < list.size(); i++) { + Item item = list.get(i); + if (!(item instanceof TransactionAccount)) + continue; + + TransactionAccount acc = item.toTransactionAccount(); + if (!Misc.equalStrings(acc.getCurrency(), balCurrency)) + continue; + + if (item == receiver) { + final String hint = String.format("%1.2f", -currencyBalance); + if (!acc.isAmountHintSet() || + !TextUtils.equals(acc.getAmountHint(), hint)) + { + Logger.debug("submittable", + String.format("Setting amount hint of {%s} to %s [%s]", + acc.toString(), hint, balCurrency)); + if (workingWithLiveList & !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + final TransactionAccount newAcc = new TransactionAccount(acc); + newAcc.setAmountHint(hint); + list.set(i, newAcc); + listChanged = true; + } + } + else { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format("Resetting hint of '%s' [%s]", + Misc.nullIsEmpty(acc.getAccountName()), + balCurrency)); + if (acc.amountHintIsSet && !TextUtils.isEmpty(acc.getAmountHint())) { + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + final TransactionAccount newAcc = new TransactionAccount(acc); + newAcc.setAmountHint(null); + list.set(i, newAcc); + listChanged = true; + } + } + } + } + } + + // 5) a row with an empty account name or empty amount is guaranteed to exist for + // each commodity + for (String balCurrency : balance.currencies()) { + int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency); + int currRows = itemsForCurrency.size(balCurrency); + int currAccounts = itemsWithAccountForCurrency.size(balCurrency); + int currAmounts = itemsWithAmountForCurrency.size(balCurrency); + if ((currEmptyRows == 0) && + ((currRows == currAccounts) || (currRows == currAmounts))) + { + // perhaps there already is an unused empty row for another currency that + // is not used? +// boolean foundIt = false; +// for (Item item : emptyRows) { +// Currency itemCurrency = item.getCurrency(); +// String itemCurrencyName = +// (itemCurrency == null) ? "" : itemCurrency.getName(); +// if (Misc.isZero(balance.get(itemCurrencyName))) { +// item.setCurrency(Currency.loadByName(balCurrency)); +// item.setAmountHint( +// String.format("%1.2f", -balance.get(balCurrency))); +// foundIt = true; +// break; +// } +// } +// +// if (!foundIt) + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + final TransactionAccount newAcc = new TransactionAccount("", balCurrency); + final float bal = balance.get(balCurrency); + if (!Misc.isZero(bal) && currAmounts == currRows) + newAcc.setAmountHint(String.format("%4.2f", -bal)); + Logger.debug("submittable", + String.format("Adding new item with %s for currency %s", + newAcc.getAmountHint(), balCurrency)); + list.add(list.size() - 1, newAcc); + listChanged = true; + } + } + + // drop extra empty rows, not needed + for (String currName : emptyRowsForCurrency.currencies()) { + List emptyItems = emptyRowsForCurrency.getList(currName); + while ((list.size() > 4) && (emptyItems.size() > 1)) { + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + Item item = emptyItems.remove(1); + list.remove(item); + listChanged = true; + } + + // unused currency, remove last item (which is also an empty one) + if ((list.size() > 4) && (emptyItems.size() == 1)) { + List currItems = itemsForCurrency.getList(currName); + + if (currItems.size() == 1) { + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + Item item = emptyItems.get(0); + list.remove(item); + listChanged = true; + } + } + } + + // 6) at least two rows need to be present in the ledger + // (the list also contains header and trailer) + while (list.size() < 4) { + if (workingWithLiveList && !liveListCopied) { + list = copyList(list); + liveListCopied = true; + } + list.add(list.size() - 1, new TransactionAccount("")); + listChanged = true; + } + + + Logger.debug("submittable", submittable ? "YES" : "NO"); + isSubmittable.setValue(submittable); + + if (BuildConfig.DEBUG) + dumpItemList("After submittable checks", list); } - ItemType getType() { - return type; + catch (NumberFormatException e) { + Logger.debug("submittable", "NO (because of NumberFormatException)"); + isSubmittable.setValue(false); } - void ensureType(ItemType wantedType) { - if (type != wantedType) { - throw new RuntimeException( - String.format("Actual type (%s) differs from wanted (%s)", type, - wantedType)); - } + catch (Exception e) { + e.printStackTrace(); + Logger.debug("submittable", "NO (because of an Exception)"); + isSubmittable.setValue(false); } - public SimpleDate getDate() { - ensureType(ItemType.generalData); - return date.getValue(); + + if (listChanged && workingWithLiveList) { + setItemsWithoutSubmittableChecks(list); } - public void setDate(SimpleDate date) { - ensureType(ItemType.generalData); - this.date.setValue(date); + } + @SuppressLint("DefaultLocale") + private void dumpItemList(@NotNull String msg, @NotNull List list) { + Logger.debug("submittable", "== Dump of all items " + msg); + for (int i = 1; i < list.size() - 1; i++) { + TransactionAccount item = list.get(i) + .toTransactionAccount(); + Logger.debug("submittable", String.format("%d:%s", i, item.toString())); } - public void setDate(String text) throws ParseException { - if ((text == null) || text.trim() - .isEmpty()) - { - setDate((SimpleDate) null); - return; - } + } + public void setItemCurrency(int position, String newCurrency) { + TransactionAccount item = Objects.requireNonNull(items.getValue()) + .get(position) + .toTransactionAccount(); + final String oldCurrency = item.getCurrency(); - SimpleDate date = Globals.parseLedgerDate(text); - this.setDate(date); + if (Misc.equalStrings(oldCurrency, newCurrency)) + return; + + List newList = copyList(); + newList.get(position) + .toTransactionAccount() + .setCurrency(newCurrency); + + setItems(newList); + } + public LiveData getAccountCount() { + return accountCount; + } + public boolean accountListIsEmpty() { + List items = Objects.requireNonNull(this.items.getValue()); + + for (Item item : items) { + if (!(item instanceof TransactionAccount)) + continue; + + if (!((TransactionAccount) item).isEmpty()) + return false; } - void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.date.observe(owner, observer); + + return true; + } + + public static class FocusInfo { + int position; + FocusedElement element; + public FocusInfo(int position, FocusedElement element) { + this.position = position; + this.element = element; } - void stopObservingDate(@NonNull androidx.lifecycle.Observer observer) { - this.date.removeObserver(observer); + } + + static abstract class Item { + private static int idDispenser = 0; + protected int id; + private Item() { + synchronized (Item.class) { + id = ++idDispenser; + } } - public String getDescription() { - ensureType(ItemType.generalData); - return description.getValue(); + public static Item from(Item origin) { + if (origin instanceof TransactionHead) + return new TransactionHead((TransactionHead) origin); + if (origin instanceof TransactionAccount) + return new TransactionAccount((TransactionAccount) origin); + if (origin instanceof BottomFiller) + return new BottomFiller((BottomFiller) origin); + throw new RuntimeException("Don't know how to handle " + origin); } - public void setDescription(String description) { - ensureType(ItemType.generalData); - this.description.setValue(description); + public int getId() { + return id; } - void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.description.observe(owner, observer); + public abstract ItemType getType(); + public TransactionHead toTransactionHead() { + if (this instanceof TransactionHead) + return (TransactionHead) this; + + throw new IllegalStateException("Wrong item type " + this); } - void stopObservingDescription( - @NonNull androidx.lifecycle.Observer observer) { - this.description.removeObserver(observer); + public TransactionAccount toTransactionAccount() { + if (this instanceof TransactionAccount) + return (TransactionAccount) this; + + throw new IllegalStateException("Wrong item type " + this); } - public String getTransactionComment() { - ensureType(ItemType.generalData); - return comment.getValue(); + public boolean equalContents(@Nullable Object item) { + if (item == null) + return false; + + if (!getClass().equals(item.getClass())) + return false; + + // shortcut - comparing same instance + if (item == this) + return true; + + if (this instanceof TransactionHead) + return ((TransactionHead) item).equalContents((TransactionHead) this); + if (this instanceof TransactionAccount) + return ((TransactionAccount) item).equalContents((TransactionAccount) this); + if (this instanceof BottomFiller) + return true; + + throw new RuntimeException("Don't know how to handle " + this); } - public void setTransactionComment(String transactionComment) { - ensureType(ItemType.generalData); - this.comment.setValue(transactionComment); + } + + +//========================================================================================== + + public static class TransactionHead extends Item { + private SimpleDate date; + private String description; + private String comment; + TransactionHead(String description) { + super(); + this.description = description; } - void observeTransactionComment(@NonNull @NotNull LifecycleOwner owner, - @NonNull Observer observer) { - ensureType(ItemType.generalData); - this.comment.observe(owner, observer); + public TransactionHead(TransactionHead origin) { + id = origin.id; + date = origin.date; + description = origin.description; + comment = origin.comment; } - void stopObservingTransactionComment(@NonNull Observer observer) { - this.comment.removeObserver(observer); + public SimpleDate getDate() { + return date; } - public LedgerTransactionAccount getAccount() { - ensureType(ItemType.transactionRow); - return account; + public void setDate(SimpleDate date) { + this.date = date; } - public void setAccountName(String name) { - account.setAccountName(name); + public void setDate(String text) throws ParseException { + if (Misc.emptyIsNull(text) == null) { + date = null; + return; + } + + date = Globals.parseLedgerDate(text); } /** * getFormattedDate() @@ -396,75 +940,271 @@ public class NewTransactionModel extends ViewModel { String getFormattedDate() { if (date == null) return null; - SimpleDate d = date.getValue(); - if (d == null) - return null; Calendar today = GregorianCalendar.getInstance(); - if (today.get(Calendar.YEAR) != d.year) { - return String.format(Locale.US, "%d/%02d/%02d", d.year, d.month, d.day); + if (today.get(Calendar.YEAR) != date.year) { + return String.format(Locale.US, "%d/%02d/%02d", date.year, date.month, date.day); } - if (today.get(Calendar.MONTH) != d.month - 1) { - return String.format(Locale.US, "%d/%02d", d.month, d.day); + if (today.get(Calendar.MONTH) + 1 != date.month) { + return String.format(Locale.US, "%d/%02d", date.month, date.day); } - return String.valueOf(d.day); + return String.valueOf(date.day); } - void observeEditableFlag(NewTransactionActivity activity, Observer observer) { - editable.observe(activity, observer); + @NonNull + @Override + public String toString() { + @SuppressLint("DefaultLocale") StringBuilder b = new StringBuilder( + String.format("id:%d/%s", id, Integer.toHexString(hashCode()))); + + if (TextUtils.isEmpty(description)) + b.append(" «no description»"); + else + b.append(String.format(" descr'%s'", description)); + + if (date != null) + b.append(String.format("@%s", date.toString())); + + if (!TextUtils.isEmpty(comment)) + b.append(String.format(" /%s/", comment)); + + return b.toString(); } - void stopObservingEditableFlag(Observer observer) { - editable.removeObserver(observer); + public String getDescription() { + return description; } - void observeComment(NewTransactionActivity activity, Observer observer) { - comment.observe(activity, observer); + public void setDescription(String description) { + this.description = description; } - void stopObservingComment(Observer observer) { - comment.removeObserver(observer); + public String getComment() { + return comment; } public void setComment(String comment) { - getAccount().setComment(comment); - this.comment.postValue(comment); + this.comment = comment; } - public Currency getCurrency() { - return this.currency.getValue(); + @Override + public ItemType getType() { + return ItemType.generalData; } - public void setCurrency(Currency currency) { - Currency present = this.currency.getValue(); - if ((currency == null) && (present != null) || - (currency != null) && !currency.equals(present)) - { - getAccount().setCurrency((currency != null && !currency.getName() - .isEmpty()) - ? currency.getName() : null); - this.currency.setValue(currency); - } + public LedgerTransaction asLedgerTransaction() { + return new LedgerTransaction(null, date, description, Data.getProfile()); + } + public boolean equalContents(TransactionHead other) { + if (other == null) + return false; + + return Objects.equals(date, other.date) && + TextUtils.equals(description, other.description) && + TextUtils.equals(comment, other.comment); + } + } + + public static class BottomFiller extends Item { + public BottomFiller(BottomFiller origin) { + id = origin.id; + // nothing to do + } + public BottomFiller() { + super(); + } + @Override + public ItemType getType() { + return ItemType.bottomFiller; + } + @SuppressLint("DefaultLocale") + @NonNull + @Override + public String toString() { + return String.format("id:%d «bottom filler»", id); + } + } + + public static class TransactionAccount extends Item { + private String accountName; + private String amountHint; + private String comment; + private String currency; + private float amount; + private boolean amountSet; + private boolean amountValid = true; + private FocusedElement focusedElement = FocusedElement.Account; + private boolean amountHintIsSet = false; + public TransactionAccount(TransactionAccount origin) { + id = origin.id; + accountName = origin.accountName; + amount = origin.amount; + amountSet = origin.amountSet; + amountHint = origin.amountHint; + amountHintIsSet = origin.amountHintIsSet; + comment = origin.comment; + currency = origin.currency; + amountValid = origin.amountValid; + focusedElement = origin.focusedElement; + } + public TransactionAccount(LedgerTransactionAccount account) { + super(); + currency = account.getCurrency(); + amount = account.getAmount(); + } + public TransactionAccount(String accountName) { + super(); + this.accountName = accountName; + } + public TransactionAccount(String accountName, String currency) { + super(); + this.accountName = accountName; + this.currency = currency; + } + public boolean isAmountSet() { + return amountSet; + } + public String getAccountName() { + return accountName; + } + public void setAccountName(String accountName) { + this.accountName = accountName; + } + public float getAmount() { + if (!amountSet) + throw new IllegalStateException("Amount is not set"); + return amount; + } + public void setAmount(float amount) { + this.amount = amount; + amountSet = true; + } + public void resetAmount() { + amountSet = false; + } + @Override + public ItemType getType() { + return ItemType.transactionRow; + } + public String getAmountHint() { + return amountHint; + } + public void setAmountHint(String amountHint) { + this.amountHint = amountHint; + amountHintIsSet = !TextUtils.isEmpty(amountHint); } - void observeCurrency(NewTransactionActivity activity, Observer observer) { - currency.observe(activity, observer); + public String getComment() { + return comment; } - void stopObservingCurrency(Observer observer) { - currency.removeObserver(observer); + public void setComment(String comment) { + this.comment = comment; + } + public String getCurrency() { + return currency; + } + public void setCurrency(String currency) { + this.currency = currency; + } + public boolean isAmountValid() { + return amountValid; + } + public void setAmountValid(boolean amountValid) { + this.amountValid = amountValid; + } + public FocusedElement getFocusedElement() { + return focusedElement; } - boolean isBottomFiller() { - return this.type == ItemType.bottomFiller; + public void setFocusedElement(FocusedElement focusedElement) { + this.focusedElement = focusedElement; } - boolean isAmountHintSet() { + public boolean isAmountHintSet() { return amountHintIsSet; } - void validateAmount() { - amountValid.setValue(true); + public void setAmountHintIsSet(boolean amountHintIsSet) { + this.amountHintIsSet = amountHintIsSet; + } + public boolean isEmpty() { + return !amountSet && Misc.emptyIsNull(accountName) == null && + Misc.emptyIsNull(comment) == null; + } + @SuppressLint("DefaultLocale") + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append(String.format("id:%d/%s", id, Integer.toHexString(hashCode()))); + if (!TextUtils.isEmpty(accountName)) + b.append(String.format(" acc'%s'", accountName)); + + if (amountSet) + b.append(String.format(" %4.2f", amount)); + else if (amountHintIsSet) + b.append(String.format(" (%s)", amountHint)); + + if (!TextUtils.isEmpty(currency)) + b.append(" ") + .append(currency); + + if (!TextUtils.isEmpty(comment)) + b.append(String.format(" /%s/", comment)); + + return b.toString(); + } + public boolean equalContents(TransactionAccount other) { + if (other == null) + return false; + + boolean equal = TextUtils.equals(accountName, other.accountName) && + TextUtils.equals(comment, other.comment) && + (amountSet ? other.amountSet && amount == other.amount + : !other.amountSet) && + (amountHintIsSet ? other.amountHintIsSet && + TextUtils.equals(amountHint, other.amountHint) + : !other.amountHintIsSet) && + TextUtils.equals(currency, other.currency); + Logger.debug("new-trans", + String.format("Comparing {%s} and {%s}: %s", this.toString(), other.toString(), + equal)); + return equal; + } + } + + private static class BalanceForCurrency { + private final HashMap hashMap = new HashMap<>(); + float get(String currencyName) { + Float f = hashMap.get(currencyName); + if (f == null) { + f = 0f; + hashMap.put(currencyName, f); + } + return f; + } + void add(String currencyName, float amount) { + hashMap.put(currencyName, get(currencyName) + amount); + } + Set currencies() { + return hashMap.keySet(); + } + boolean containsCurrency(String currencyName) { + return hashMap.containsKey(currencyName); + } + } + + private static class ItemsForCurrency { + private final HashMap> hashMap = new HashMap<>(); + @NonNull + List getList(@Nullable String currencyName) { + List list = hashMap.get(currencyName); + if (list == null) { + list = new ArrayList<>(); + hashMap.put(currencyName, list); + } + return list; } - void invalidateAmount() { - amountValid.setValue(false); + void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) { + getList(currencyName).add(item); } - void observeAmountValidity(NewTransactionActivity activity, Observer observer) { - amountValid.observe(activity, observer); + int size(@Nullable String currencyName) { + return this.getList(currencyName) + .size(); } - void stopObservingAmountValidity(Observer observer) { - amountValid.removeObserver(observer); + Set currencies() { + return hashMap.keySet(); } } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java index b277489e..410b7d62 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplatesActivity.java @@ -145,7 +145,7 @@ public class TemplatesActivity extends CrashReportingActivity TemplateHeaderDAO dao = DB.get() .getTemplateDAO(); - dao.getTemplateWitAccountsAsync(templateId, template -> { + dao.getTemplateWithAccountsAsync(templateId, template -> { TemplateWithAccounts copy = TemplateWithAccounts.from(template); dao.deleteAsync(template.header, () -> { navController.popBackStack(R.id.templateListFragment, false); diff --git a/app/src/main/res/layout/activity_new_transaction.xml b/app/src/main/res/layout/activity_new_transaction.xml index 2461b3be..995f9386 100644 --- a/app/src/main/res/layout/activity_new_transaction.xml +++ b/app/src/main/res/layout/activity_new_transaction.xml @@ -85,5 +85,20 @@ /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_new_transaction.xml b/app/src/main/res/layout/fragment_new_transaction.xml index 0e60c4fc..6ddb6b10 100644 --- a/app/src/main/res/layout/fragment_new_transaction.xml +++ b/app/src/main/res/layout/fragment_new_transaction.xml @@ -58,18 +58,4 @@ - \ No newline at end of file -- 2.39.2