android:supportsRtl="true"
tools:ignore="GoogleAppIndexingWarning">
<activity
- android:name=".ui.activity.PatternsActivity"
+ android:name=".ui.patterns.PatternsActivity"
android:label="@string/title_activity_patterns"
android:theme="@style/AppTheme.default" />
<activity
android:label="@string/app_name"
android:theme="@style/AppTheme.default" />
<activity
- android:name=".ui.activity.NewTransactionActivity"
+ android:name=".ui.new_transaction.NewTransactionActivity"
android:label="@string/title_activity_new_transaction"
android:parentActivityName=".ui.activity.MainActivity"
android:theme="@style/AppTheme.default"
android:windowSoftInputMode="stateVisible" />
<activity
- android:name=".ui.activity.ProfileDetailActivity"
+ android:name=".ui.profiles.ProfileDetailActivity"
android:label="@string/title_profile_details"
android:parentActivityName=".ui.activity.MainActivity"
android:windowSoftInputMode="stateVisible" />
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.async;
+
+import android.os.AsyncTask;
+
+public class AsyncCrasher extends AsyncTask<Void, Void, Void> {
+ @Override
+ protected Void doInBackground(Void... voids) {
+ throw new RuntimeException("Simulated crash");
+ }
+}
/*
- * Copyright © 2020 Damyan Ivanov.
+ * 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
import net.ktnx.mobileledger.R;
import net.ktnx.mobileledger.async.DbOpQueue;
import net.ktnx.mobileledger.json.API;
-import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
+import net.ktnx.mobileledger.ui.profiles.ProfileDetailActivity;
import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
import net.ktnx.mobileledger.utils.Logger;
import net.ktnx.mobileledger.utils.Misc;
+++ /dev/null
-/*
- * Copyright © 2019 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.AsyncTask;
-
-class AsyncCrasher extends AsyncTask<Void, Void, Void> {
- @Override
- protected Void doInBackground(Void... voids) {
- throw new RuntimeException("Simulated crash");
- }
-}
import net.ktnx.mobileledger.model.MobileLedgerProfile;
import net.ktnx.mobileledger.ui.MainModel;
import net.ktnx.mobileledger.ui.account_summary.AccountSummaryFragment;
+import net.ktnx.mobileledger.ui.new_transaction.NewTransactionActivity;
+import net.ktnx.mobileledger.ui.patterns.PatternsActivity;
import net.ktnx.mobileledger.ui.profiles.ProfilesRecyclerViewAdapter;
import net.ktnx.mobileledger.ui.transaction_list.TransactionListFragment;
import net.ktnx.mobileledger.utils.Colors;
+++ /dev/null
-/*
- * Copyright © 2020 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.Bundle;
-import android.util.TypedValue;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-
-import androidx.appcompat.widget.Toolbar;
-import androidx.lifecycle.ViewModelProvider;
-import androidx.navigation.NavController;
-import androidx.navigation.fragment.NavHostFragment;
-
-import net.ktnx.mobileledger.BuildConfig;
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.async.SendTransactionTask;
-import net.ktnx.mobileledger.async.TaskCallback;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransaction;
-
-import java.util.Objects;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-public class NewTransactionActivity extends ProfileThemedActivity implements TaskCallback,
- NewTransactionFragment.OnNewTransactionFragmentInteractionListener {
- private NavController navController;
- private NewTransactionModel model;
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- setContentView(R.layout.activity_new_transaction);
- Toolbar toolbar = findViewById(R.id.toolbar);
- setSupportActionBar(toolbar);
- Data.observeProfile(this,
- mobileLedgerProfile -> toolbar.setSubtitle(mobileLedgerProfile.getName()));
-
- NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull(
- getSupportFragmentManager().findFragmentById(R.id.new_transaction_nav));
- navController = navHostFragment.getNavController();
-
- Objects.requireNonNull(getSupportActionBar())
- .setDisplayHomeAsUpEnabled(true);
-
- model = new ViewModelProvider(this).get(NewTransactionModel.class);
- }
- @Override
- protected void initProfile() {
- String profileUUID = getIntent().getStringExtra("profile_uuid");
-
- if (profileUUID != null) {
- mProfile = Data.getProfile(profileUUID);
- if (mProfile == null)
- finish();
- Data.setCurrentProfile(mProfile);
- }
- else
- super.initProfile();
- }
- @Override
- public void finish() {
- super.finish();
- overridePendingTransition(R.anim.dummy, R.anim.slide_out_down);
- }
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- if (item.getItemId() == android.R.id.home) {
- finish();
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
- public void onTransactionSave(LedgerTransaction tr) {
- navController.navigate(R.id.action_newTransactionFragment_to_newTransactionSavingFragment);
- try {
-
- SendTransactionTask saver =
- new SendTransactionTask(this, mProfile, model.getSimulateSave());
- saver.execute(tr);
- }
- catch (Exception e) {
- debug("new-transaction", "Unknown error", e);
-
- Bundle b = new Bundle();
- b.putString("error", "unknown error");
- navController.navigate(R.id.newTransactionFragment, b);
- }
- }
- public void simulateCrash(MenuItem item) {
- debug("crash", "Will crash intentionally");
- new AsyncCrasher().execute();
- }
- public boolean onCreateOptionsMenu(Menu menu) {
- // Inflate the menu; this adds items to the action bar if it is present.
- getMenuInflater().inflate(R.menu.new_transaction, menu);
-
- if (BuildConfig.DEBUG) {
- menu.findItem(R.id.action_simulate_crash)
- .setVisible(true);
- menu.findItem(R.id.action_simulate_save)
- .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);
- });
-
- return true;
- }
-
-
- public int dp2px(float dp) {
- return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
- getResources().getDisplayMetrics()));
- }
- @Override
- public void done(String error) {
- Bundle b = new Bundle();
- if (error != null) {
- b.putString("error", error);
- navController.navigate(R.id.action_newTransactionSavingFragment_Failure, b);
- }
- else
- navController.navigate(R.id.action_newTransactionSavingFragment_Success, b);
- }
- public void toggleSimulateSave(MenuItem item) {
- model.toggleSimulateSave();
- }
-
-}
+++ /dev/null
-/*
- * 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 <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.content.res.Resources;
-import android.os.Bundle;
-import android.renderscript.RSInvalidStateException;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ProgressBar;
-
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContract;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import com.google.android.material.snackbar.Snackbar;
-
-import net.ktnx.mobileledger.R;
-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.utils.Logger;
-import net.ktnx.mobileledger.utils.Misc;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * A simple {@link Fragment} subclass.
- * Activities that contain this fragment must implement the
- * {@link OnNewTransactionFragmentInteractionListener} interface
- * to handle interaction events.
- */
-
-// TODO: offer to undo account remove-on-swipe
-
-public class NewTransactionFragment extends Fragment {
- private NewTransactionItemsAdapter listAdapter;
- private NewTransactionModel viewModel;
- final ActivityResultLauncher<Void> scanQrLauncher =
- registerForActivityResult(new ActivityResultContract<Void, String>() {
- @NonNull
- @Override
- public Intent createIntent(@NonNull Context context, Void input) {
- final Intent intent = new Intent("com.google.zxing.client.android.SCAN");
- intent.putExtra("SCAN_MODE", "QR_CODE_MODE");
- return intent;
- }
- @Override
- public String parseResult(int resultCode, @Nullable Intent intent) {
- if (resultCode == Activity.RESULT_CANCELED)
- return null;
- return intent.getStringExtra("SCAN_RESULT");
- }
- }, this::onQrScanned);
- private FloatingActionButton fab;
- private OnNewTransactionFragmentInteractionListener mListener;
- private MobileLedgerProfile mProfile;
- public NewTransactionFragment() {
- // Required empty public constructor
- setHasOptionsMenu(true);
- }
- private void onQrScanned(String text) {
- Logger.debug("qr", String.format("Got QR scan result [%s]", text));
- Pattern p =
- Pattern.compile("^(\\d+)\\*(\\d+)\\*(\\d+)-(\\d+)-(\\d+)\\*([:\\d]+)\\*([\\d.]+)$");
- Matcher m = p.matcher(text);
- if (m.matches()) {
- float amount = Float.parseFloat(m.group(7));
- viewModel.setDate(
- new SimpleDate(Integer.parseInt(m.group(3)), Integer.parseInt(m.group(4)),
- Integer.parseInt(m.group(5))));
-
- if (viewModel.accountsInInitialState()) {
- {
- NewTransactionModel.Item firstItem = viewModel.getItem(1);
- if (firstItem == null) {
- viewModel.addAccount(new LedgerTransactionAccount("разход:пазар"));
- listAdapter.notifyItemInserted(viewModel.items.size() - 1);
- }
- else {
- firstItem.setAccountName("разход:пазар");
- firstItem.getAccount()
- .resetAmount();
- listAdapter.notifyItemChanged(1);
- }
- }
- {
- NewTransactionModel.Item secondItem = viewModel.getItem(2);
- if (secondItem == null) {
- viewModel.addAccount(
- new LedgerTransactionAccount("актив:кеш:дам", -amount, null, null));
- listAdapter.notifyItemInserted(viewModel.items.size() - 1);
- }
- else {
- secondItem.setAccountName("актив:кеш:дам");
- secondItem.getAccount()
- .setAmount(-amount);
- listAdapter.notifyItemChanged(2);
- }
- }
- }
- else {
- viewModel.addAccount(new LedgerTransactionAccount("разход:пазар"));
- viewModel.addAccount(
- new LedgerTransactionAccount("актив:кеш:дам", -amount, null, null));
- listAdapter.notifyItemRangeInserted(viewModel.items.size() - 1, 2);
- }
-
- listAdapter.checkTransactionSubmittable();
- }
- }
- @Override
- public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
- final FragmentActivity activity = getActivity();
-
- inflater.inflate(R.menu.new_transaction_fragment, menu);
-
- menu.findItem(R.id.scan_qr)
- .setOnMenuItemClickListener(this::onScanQrAction);
-
- menu.findItem(R.id.action_reset_new_transaction_activity)
- .setOnMenuItemClickListener(item -> {
- listAdapter.reset();
- return true;
- });
-
- final MenuItem toggleCurrencyItem = menu.findItem(R.id.toggle_currency);
- toggleCurrencyItem.setOnMenuItemClickListener(item -> {
- viewModel.toggleCurrencyVisible();
- return true;
- });
- if (activity != null)
- viewModel.showCurrency.observe(activity, toggleCurrencyItem::setChecked);
-
- final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments);
- toggleCommentsItem.setOnMenuItemClickListener(item -> {
- viewModel.toggleShowComments();
- return true;
- });
- if (activity != null)
- viewModel.showComments.observe(activity, toggleCommentsItem::setChecked);
- }
- private boolean onScanQrAction(MenuItem item) {
- try {
- scanQrLauncher.launch(null);
- }
- catch (Exception e) {
- Logger.debug("qr", "Error launching QR scanner", e);
- }
-
- return true;
- }
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container,
- Bundle savedInstanceState) {
- // Inflate the layout for this fragment
- return inflater.inflate(R.layout.fragment_new_transaction, container, false);
- }
-
- @Override
- public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- FragmentActivity activity = getActivity();
- if (activity == null)
- throw new RSInvalidStateException(
- "getActivity() returned null within onActivityCreated()");
-
- viewModel = new ViewModelProvider(activity).get(NewTransactionModel.class);
- viewModel.observeDataProfile(this);
- mProfile = Data.getProfile();
- listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
-
- RecyclerView list = activity.findViewById(R.id.new_transaction_accounts);
- list.setAdapter(listAdapter);
- list.setLayoutManager(new LinearLayoutManager(activity));
-
- Data.observeProfile(getViewLifecycleOwner(), profile -> {
- 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.fab);
- fab.setOnClickListener(v -> onFabPressed());
-
- boolean keep = false;
-
- Bundle args = getArguments();
- if (args != null) {
- String error = args.getString("error");
- if (error != null) {
- Logger.debug("new-trans-f", String.format("Got error: %s", error));
-
- Context context = getContext();
- if (context != null) {
- AlertDialog.Builder builder = new AlertDialog.Builder(context);
- final Resources resources = context.getResources();
- final StringBuilder message = new StringBuilder();
- message.append(resources.getString(R.string.err_json_send_error_head));
- message.append("\n\n");
- message.append(error);
- if (mProfile.getApiVersion()
- .equals(API.auto))
- message.append(
- resources.getString(R.string.err_json_send_error_unsupported));
- else {
- message.append(resources.getString(R.string.err_json_send_error_tail));
- builder.setPositiveButton(R.string.btn_profile_options, (dialog, which) -> {
- Logger.debug("error", "will start profile editor");
- MobileLedgerProfile.startEditProfileActivity(context, mProfile);
- });
- }
- builder.setMessage(message);
- builder.create()
- .show();
- }
- else {
- Snackbar.make(list, error, Snackbar.LENGTH_INDEFINITE)
- .show();
- }
- keep = true;
- }
- }
-
- int focused = 0;
- if (savedInstanceState != null) {
- keep |= savedInstanceState.getBoolean("keep", true);
- focused = savedInstanceState.getInt("focused", 0);
- }
-
- if (!keep)
- viewModel.reset();
- else {
- viewModel.setFocusedItem(focused);
- }
-
- ProgressBar p = activity.findViewById(R.id.progressBar);
- viewModel.observeBusyFlag(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);
- });
- }
- @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);
- }
- }
-
- @Override
- public void onAttach(@NotNull Context context) {
- super.onAttach(context);
- if (context instanceof OnNewTransactionFragmentInteractionListener) {
- mListener = (OnNewTransactionFragmentInteractionListener) context;
- }
- else {
- throw new RuntimeException(
- context.toString() + " must implement OnFragmentInteractionListener");
- }
- }
-
- @Override
- public void onDetach() {
- super.onDetach();
- mListener = null;
- }
-
- /**
- * This interface must be implemented by activities that contain this
- * fragment to allow an interaction in this fragment to be communicated
- * to the activity and potentially other fragments contained in that
- * activity.
- * <p>
- * See the Android Training lesson <a href=
- * "http://developer.android.com/training/basics/fragments/communicating.html"
- * >Communicating with Other Fragments</a> for more information.
- */
- public interface OnNewTransactionFragmentInteractionListener {
- void onTransactionSave(LedgerTransaction tr);
- }
-}
+++ /dev/null
-/*
- * 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 <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.annotation.SuppressLint;
-import android.graphics.Typeface;
-import android.text.Editable;
-import android.text.TextUtils;
-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.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;
-import net.ktnx.mobileledger.ui.TextViewClearHelper;
-import net.ktnx.mobileledger.utils.DimensionUtils;
-import net.ktnx.mobileledger.utils.Logger;
-import net.ktnx.mobileledger.utils.MLDB;
-import net.ktnx.mobileledger.utils.Misc;
-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.activity.NewTransactionModel.ItemType;
-
-class NewTransactionItemHolder extends RecyclerView.ViewHolder
- implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback {
- private final String decimalDot;
- private final Observer<Boolean> showCommentsObserver;
- private final MobileLedgerProfile mProfile;
- private final Observer<SimpleDate> dateObserver;
- private final Observer<String> descriptionObserver;
- private final Observer<String> transactionCommentObserver;
- private final Observer<String> hintObserver;
- private final Observer<Integer> focusedAccountObserver;
- private final Observer<Integer> accountCountObserver;
- private final Observer<Boolean> editableObserver;
- private final Observer<Currency.Position> currencyPositionObserver;
- private final Observer<Boolean> currencyGapObserver;
- private final Observer<Locale> localeObserver;
- private final Observer<Currency> currencyObserver;
- private final Observer<Boolean> showCurrencyObserver;
- private final Observer<String> commentObserver;
- private final Observer<Boolean> amountValidityObserver;
- private final NewTransactionRowBinding b;
- 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
- NewTransactionItemHolder(@NonNull NewTransactionRowBinding b,
- NewTransactionItemsAdapter adapter) {
- super(b.getRoot());
- this.b = b;
- new TextViewClearHelper().attachToTextView((EditText) b.comment);
-
- b.newTransactionDescription.setNextFocusForwardId(View.NO_ID);
- b.accountRowAccName.setNextFocusForwardId(View.NO_ID);
- b.accountRowAccAmounts.setNextFocusForwardId(View.NO_ID); // magic!
-
- b.newTransactionDate.setOnClickListener(v -> pickTransactionDate());
-
- b.accountCommentButton.setOnClickListener(v -> {
- b.comment.setVisibility(View.VISIBLE);
- b.comment.requestFocus();
- });
-
- b.transactionCommentButton.setOnClickListener(v -> {
- b.transactionComment.setVisibility(View.VISIBLE);
- b.transactionComment.requestFocus();
- });
-
- mProfile = Data.getProfile();
-
- View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
- final int id = v.getId();
- if (hasFocus) {
- boolean wasSyncing = syncingData;
- syncingData = true;
- try {
- final int pos = getAdapterPosition();
- adapter.updateFocusedItem(pos);
- if (id == R.id.account_row_acc_name) {
- adapter.noteFocusIsOnAccount(pos);
- }
- else if (id == R.id.account_row_acc_amounts) {
- adapter.noteFocusIsOnAmount(pos);
- }
- else if (id == R.id.comment) {
- adapter.noteFocusIsOnComment(pos);
- }
- else if (id == R.id.transaction_comment) {
- adapter.noteFocusIsOnTransactionComment(pos);
- }
- else if (id == R.id.new_transaction_description) {
- adapter.noteFocusIsOnDescription(pos);
- }
- }
- finally {
- syncingData = wasSyncing;
- }
- }
-
- if (id == R.id.comment) {
- commentFocusChanged(b.comment, hasFocus);
- }
- else if (id == R.id.transaction_comment) {
- commentFocusChanged(b.transactionComment, hasFocus);
- }
- };
-
- b.newTransactionDescription.setOnFocusChangeListener(focusMonitor);
- b.accountRowAccName.setOnFocusChangeListener(focusMonitor);
- b.accountRowAccAmounts.setOnFocusChangeListener(focusMonitor);
- 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);
-
- decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance()
- .getMonetaryDecimalSeparator());
- localeObserver = locale -> decimalSeparator = String.valueOf(
- DecimalFormatSymbols.getInstance(locale)
- .getMonetaryDecimalSeparator());
-
- decimalDot = ".";
-
- final TextWatcher tw = new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence s, int start, int count, int after) {
- }
-
- @Override
- public void onTextChanged(CharSequence s, int start, int before, int count) {
- }
-
- @Override
- public void afterTextChanged(Editable s) {
-// debug("input", "text changed");
- if (inUpdate)
- return;
-
- Logger.debug("textWatcher", "calling syncData()");
- syncData();
- Logger.debug("textWatcher",
- "syncData() returned, checking if transaction is submittable");
- adapter.checkTransactionSubmittable();
- 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));
- }
- @Override
- public void onTextChanged(CharSequence s, int start, int before, int count) {}
- @Override
- public void afterTextChanged(Editable s) {
-
- if (syncData())
- adapter.checkTransactionSubmittable();
- }
- };
- b.newTransactionDescription.addTextChangedListener(tw);
- b.transactionComment.addTextChangedListener(tw);
- b.accountRowAccName.addTextChangedListener(tw);
- b.comment.addTextChangedListener(tw);
- 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.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 =
- 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);
- }
- };
-
- 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();
-
- 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);
- };
-
- amountValidityObserver = valid -> {
- b.accountRowAccAmounts.setCompoundDrawablesRelativeWithIntrinsicBounds(
- valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0);
- b.accountRowAccAmounts.setMinEms(valid ? 4 : 5);
- };
- }
- private void commentFocusChanged(TextView textView, boolean hasFocus) {
- @ColorInt int textColor;
- textColor = b.dummyText.getTextColors()
- .getDefaultColor();
- if (hasFocus) {
- textView.setTypeface(null, Typeface.NORMAL);
- textView.setHint(R.string.transaction_account_comment_hint);
- }
- else {
- int alpha = (textColor >> 24 & 0xff);
- alpha = 3 * alpha / 4;
- textColor = (alpha << 24) | (0x00ffffff & textColor);
- textView.setTypeface(null, Typeface.ITALIC);
- textView.setHint("");
- if (TextUtils.isEmpty(textView.getText())) {
- textView.setVisibility(View.INVISIBLE);
- }
- }
- textView.setTextColor(textColor);
-
- }
- private void updateCurrencyPositionAndPadding(Currency.Position position, boolean hasGap) {
- ConstraintLayout.LayoutParams amountLP =
- (ConstraintLayout.LayoutParams) b.accountRowAccAmounts.getLayoutParams();
- ConstraintLayout.LayoutParams currencyLP =
- (ConstraintLayout.LayoutParams) b.currency.getLayoutParams();
-
- if (position == Currency.Position.before) {
- currencyLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
- currencyLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
-
- amountLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
- amountLP.endToStart = ConstraintLayout.LayoutParams.UNSET;
- amountLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
- amountLP.startToEnd = b.currency.getId();
-
- b.currency.setGravity(Gravity.END);
- }
- else {
- currencyLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
- currencyLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
-
- amountLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
- amountLP.startToEnd = ConstraintLayout.LayoutParams.UNSET;
- amountLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
- amountLP.endToStart = b.currency.getId();
-
- b.currency.setGravity(Gravity.START);
- }
-
- amountLP.resolveLayoutDirection(b.accountRowAccAmounts.getLayoutDirection());
- currencyLP.resolveLayoutDirection(b.currency.getLayoutDirection());
-
- b.accountRowAccAmounts.setLayoutParams(amountLP);
- b.currency.setLayoutParams(currencyLP);
-
- // distance between the amount and the currency symbol
- int gapSize = DimensionUtils.sp2px(b.currency.getContext(), 5);
-
- if (position == Currency.Position.before) {
- b.currency.setPaddingRelative(0, 0, hasGap ? gapSize : 0, 0);
- }
- else {
- b.currency.setPaddingRelative(hasGap ? gapSize : 0, 0, 0, 0);
- }
- }
- private void setCurrencyString(String currency) {
- @ColorInt int textColor = b.dummyText.getTextColors()
- .getDefaultColor();
- if ((currency == null) || currency.isEmpty()) {
- b.currency.setText(R.string.currency_symbol);
- int alpha = (textColor >> 24) & 0xff;
- alpha = alpha * 3 / 4;
- b.currency.setTextColor((alpha << 24) | (0x00ffffff & textColor));
- }
- else {
- b.currency.setText(currency);
- b.currency.setTextColor(textColor);
- }
- }
- private void setCurrency(Currency currency) {
- setCurrencyString((currency == null) ? null : currency.getName());
- }
- private void setEditable(Boolean editable) {
- b.newTransactionDate.setEnabled(editable);
- b.newTransactionDescription.setEnabled(editable);
- b.accountRowAccName.setEnabled(editable);
- b.accountRowAccAmounts.setEnabled(editable);
- }
- private void beginUpdates() {
- if (inUpdate)
- throw new RuntimeException("Already in update mode");
- inUpdate = true;
- }
- private void endUpdates() {
- if (!inUpdate)
- throw new RuntimeException("Not in update mode");
- inUpdate = false;
- }
- /**
- * syncData()
- * <p>
- * Stores the data from the UI elements into the model item
- * Returns true if there were changes made that suggest transaction has to be
- * checked for being submittable
- */
- private boolean syncData() {
- if (item == null)
- return false;
-
- if (syncingData) {
- Logger.debug("new-trans", "skipping syncData() loop");
- return false;
- }
-
- 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();
- }
- 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);
- }
-
- break;
- case bottomFiller:
- throw new RuntimeException("Should not happen");
- }
-
- return true;
- }
- catch (ParseException e) {
- throw new RuntimeException("Should not happen", e);
- }
- finally {
- syncingData = false;
- }
- }
- private void pickTransactionDate() {
- DatePickerFragment picker = new DatePickerFragment();
- picker.setFutureDates(mProfile.getFutureDates());
- picker.setOnDatePickedListener(this);
- picker.setCurrentDateFromText(b.newTransactionDate.getText());
- picker.show(((NewTransactionActivity) b.getRoot()
- .getContext()).getSupportFragmentManager(), null);
- }
- /**
- * setData
- *
- * @param item updates the UI elements with the data from the model item
- */
- @SuppressLint("DefaultLocale")
- public void setData(NewTransactionModel.Item item) {
- beginUpdates();
- try {
- if (this.item != null && !this.item.equals(item)) {
- this.item.stopObservingDate(dateObserver);
- this.item.stopObservingDescription(descriptionObserver);
- this.item.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;
- }
-
- 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();
- b.accountRowAccName.setText(acc.getAccountName());
- b.comment.setText(acc.getComment());
- if (acc.isAmountSet()) {
- b.accountRowAccAmounts.setText(String.format("%1.2f", acc.getAmount()));
- }
- else {
- b.accountRowAccAmounts.setText("");
-// tvAmount.setHint(R.string.zero_amount);
- }
- b.accountRowAccAmounts.setHint(item.getAmountHint());
- setCurrencyString(acc.getCurrency());
- b.ntrData.setVisibility(View.GONE);
- b.ntrAccount.setVisibility(View.VISIBLE);
- b.ntrPadding.setVisibility(View.GONE);
- setEditable(true);
- break;
- case 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;
- }
- }
- }
- finally {
- endUpdates();
- }
- }
- @Override
- public void onDatePicked(int year, int month, int day) {
- item.setDate(new SimpleDate(year, month + 1, day));
- boolean focused = b.newTransactionDescription.requestFocus();
- if (focused)
- Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot()
- .getContext());
-
- }
- @Override
- public void descriptionSelected(String description) {
- b.accountRowAccName.setText(description);
- b.accountRowAccAmounts.requestFocus(View.FOCUS_FORWARD);
- }
-}
+++ /dev/null
-/*
- * 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 <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-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.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;
-
-class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemHolder>
- implements DescriptionSelectedCallback {
- private final NewTransactionModel model;
- private final ItemTouchHelper touchHelper;
- private MobileLedgerProfile mProfile;
- private RecyclerView recyclerView;
- private int checkHoldCounter = 0;
- NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) {
- super();
- model = viewModel;
- mProfile = profile;
- int size = model.getAccountCount();
- while (size < 2) {
- Logger.debug("new-transaction",
- String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
- size = addRow();
- }
-
- NewTransactionItemsAdapter adapter = this;
-
- touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
- @Override
- public boolean isLongPressDragEnabled() {
- return true;
- }
- @Override
- public boolean canDropOver(@NonNull RecyclerView recyclerView,
- @NonNull RecyclerView.ViewHolder current,
- @NonNull RecyclerView.ViewHolder target) {
- final int adapterPosition = target.getAdapterPosition();
-
- // first and last items are immovable
- if (adapterPosition == 0)
- return false;
- if (adapterPosition == adapter.getItemCount() - 1)
- return false;
-
- return super.canDropOver(recyclerView, current, target);
- }
- @Override
- public int getMovementFlags(@NonNull RecyclerView recyclerView,
- @NonNull RecyclerView.ViewHolder viewHolder) {
- int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
- // the top (date and description) and the bottom (padding) items are always there
- final int adapterPosition = viewHolder.getAdapterPosition();
- if ((adapterPosition > 0) && (adapterPosition < adapter.getItemCount() - 1)) {
- flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
- ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
- makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
- ItemTouchHelper.START | ItemTouchHelper.END);
- }
-
- return flags;
- }
- @Override
- public boolean onMove(@NonNull RecyclerView recyclerView,
- @NonNull RecyclerView.ViewHolder viewHolder,
- @NonNull RecyclerView.ViewHolder target) {
-
- model.swapItems(viewHolder.getAdapterPosition(), target.getAdapterPosition());
- notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
- return true;
- }
- @Override
- public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
- int pos = viewHolder.getAdapterPosition();
- viewModel.removeItem(pos - 1);
- notifyItemRemoved(pos);
- viewModel.sendCountNotifications(); // needed after items re-arrangement
- checkTransactionSubmittable();
- }
- });
- }
- public void setProfile(MobileLedgerProfile profile) {
- mProfile = profile;
- }
- private int addRow() {
- return addRow(null);
- }
- private int addRow(String commodity) {
- final int newAccountCount = model.addAccount(new LedgerTransactionAccount("", commodity));
- Logger.debug("new-transaction",
- String.format(Locale.US, "invoking notifyItemInserted(%d)", newAccountCount));
- // the header is at position 0
- notifyItemInserted(newAccountCount);
- model.sendCountNotifications(); // needed after holders' positions have changed
- return newAccountCount;
- }
- @NonNull
- @Override
- public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- NewTransactionRowBinding b =
- NewTransactionRowBinding.inflate(LayoutInflater.from(parent.getContext()), parent,
- false);
-
- return new NewTransactionItemHolder(b, this);
- }
- @Override
- public void onBindViewHolder(@NonNull NewTransactionItemHolder holder, int position) {
- Logger.debug("bind", String.format(Locale.US, "Binding item at position %d", position));
- NewTransactionModel.Item item = model.getItem(position);
- holder.setData(item);
- Logger.debug("bind", String.format(Locale.US, "Bound %s item at position %d", item.getType()
- .toString(),
- position));
- }
- @Override
- public int getItemCount() {
- return model.getAccountCount() + 2;
- }
- private boolean accountListIsEmpty() {
- for (int i = 0; i < model.getAccountCount(); i++) {
- LedgerTransactionAccount acc = model.getAccount(i);
- if (!acc.getAccountName()
- .isEmpty())
- return false;
- if (acc.isAmountSet())
- return false;
- }
-
- return true;
- }
- @Override
- public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
- super.onAttachedToRecyclerView(recyclerView);
- this.recyclerView = recyclerView;
- touchHelper.attachToRecyclerView(recyclerView);
- }
- @Override
- public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
- touchHelper.attachToRecyclerView(null);
- super.onDetachedFromRecyclerView(recyclerView);
- this.recyclerView = null;
- }
- public void descriptionSelected(String description) {
- debug("description selected", description);
- if (!accountListIsEmpty())
- return;
-
- String accFilter = mProfile.getPreferredAccountsFilter();
-
- ArrayList<String> params = new ArrayList<>();
- StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t");
-
- 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<LedgerTransactionAccount> 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);
- }
- void noteFocusIsOnAmount(int position) {
- model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
- }
- void noteFocusIsOnComment(int position) {
- model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
- }
- void noteFocusIsOnTransactionComment(int position) {
- model.noteFocusChanged(position, NewTransactionModel.FocusedElement.TransactionComment);
- }
- public void noteFocusIsOnDescription(int pos) {
- model.noteFocusChanged(pos, NewTransactionModel.FocusedElement.Description);
- }
- private void holdSubmittableChecks() {
- checkHoldCounter++;
- }
- private void releaseSubmittableChecks() {
- if (checkHoldCounter == 0)
- throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
- checkHoldCounter--;
- }
- void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
- Currency oldCurrency = item.getCurrency();
- if (!Currency.equal(newCurrency, oldCurrency)) {
- holdSubmittableChecks();
- try {
- item.setCurrency(newCurrency);
-// for (Item i : items) {
-// if (Currency.equal(i.getCurrency(), oldCurrency))
-// i.setCurrency(newCurrency);
-// }
- }
- finally {
- releaseSubmittableChecks();
- }
-
- checkTransactionSubmittable();
- }
- }
- /*
- 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
-
- */
- @SuppressLint("DefaultLocale")
- void checkTransactionSubmittable() {
- if (checkHoldCounter > 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<String, Float> 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<String> currencies() {
- return hashMap.keySet();
- }
- boolean containsCurrency(String currencyName) {
- return hashMap.containsKey(currencyName);
- }
- }
-
- private static class ItemsForCurrency {
- private final HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
- @NonNull
- List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
- List<NewTransactionModel.Item> 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<String> currencies() {
- return hashMap.keySet();
- }
- }
-}
+++ /dev/null
-/*
- * 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 <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import androidx.annotation.NonNull;
-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.model.Data;
-import net.ktnx.mobileledger.model.LedgerTransactionAccount;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.utils.Globals;
-import net.ktnx.mobileledger.utils.SimpleDate;
-
-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.Locale;
-import java.util.concurrent.atomic.AtomicInteger;
-
-public class NewTransactionModel extends ViewModel {
- final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
- final ArrayList<Item> items = new ArrayList<>();
- final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
- final MutableLiveData<Boolean> showComments = new MutableLiveData<>(true);
- private final Item header = new Item(this, "");
- private final Item trailer = new Item(this);
- private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
- private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
- private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
- private final AtomicInteger busyCounter = new AtomicInteger(0);
- private final MutableLiveData<Boolean> busyFlag = new MutableLiveData<>(false);
- private final Observer<MobileLedgerProfile> profileObserver = profile -> {
- showCurrency.postValue(profile.getShowCommodityByDefault());
- showComments.postValue(profile.getShowCommentsByDefault());
- };
- private boolean observingDataProfile;
- void observeShowComments(LifecycleOwner owner, Observer<? super Boolean> observer) {
- showComments.observe(owner, observer);
- }
- void observeBusyFlag(@NonNull LifecycleOwner owner, Observer<? super Boolean> observer) {
- busyFlag.observe(owner, observer);
- }
- void observeDataProfile(LifecycleOwner activity) {
- if (!observingDataProfile)
- Data.observeProfile(activity, profileObserver);
- observingDataProfile = true;
- }
- boolean getSimulateSave() {
- return simulateSave.getValue();
- }
- public void setSimulateSave(boolean simulateSave) {
- this.simulateSave.setValue(simulateSave);
- }
- void toggleSimulateSave() {
- simulateSave.setValue(!simulateSave.getValue());
- }
- void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
- @NonNull androidx.lifecycle.Observer<? super Boolean> observer) {
- this.simulateSave.observe(owner, observer);
- }
- int getAccountCount() {
- return items.size();
- }
- public SimpleDate getDate() {
- return header.date.getValue();
- }
- public void setDate(SimpleDate date) {
- header.date.setValue(date);
- }
- public String getDescription() {
- return header.description.getValue();
- }
- public String getComment() {
- return header.comment.getValue();
- }
- LiveData<Boolean> 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<? super Integer> observer) {
- this.focusedItem.observe(owner, observer);
- }
- void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer<? super Integer> observer) {
- this.focusedItem.removeObserver(observer);
- }
- void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
- @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
- this.accountCount.observe(owner, observer);
- }
- void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer<? super Integer> 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();
- }
- boolean accountsInInitialState() {
- for (Item item : items) {
- LedgerTransactionAccount acc = item.getAccount();
- if (acc.isAmountSet())
- return false;
- if (!acc.getAccountName()
- .trim()
- .isEmpty())
- return false;
- }
-
- return true;
- }
- LedgerTransactionAccount getAccount(int index) {
- return items.get(index)
- .getAccount();
- }
- Item getItem(int index) {
- if (index == 0) {
- return header;
- }
-
- if (index <= items.size())
- return items.get(index - 1);
-
- return trailer;
- }
- void removeRow(Item item, NewTransactionItemsAdapter adapter) {
- int pos = items.indexOf(item);
- items.remove(pos);
- if (adapter != null) {
- adapter.notifyItemRemoved(pos + 1);
- sendCountNotifications();
- }
- }
- void removeItem(int pos) {
- items.remove(pos);
- accountCount.setValue(getAccountCount());
- }
- void sendCountNotifications() {
- accountCount.setValue(getAccountCount());
- }
- public void sendFocusedNotification() {
- focusedItem.setValue(focusedItem.getValue());
- }
- void updateFocusedItem(int position) {
- focusedItem.setValue(position);
- }
- void noteFocusChanged(int position, FocusedElement element) {
- getItem(position).setFocusedElement(element);
- }
- void swapItems(int one, int two) {
- Collections.swap(items, one - 1, two - 1);
- }
- void moveItemLast(int index) {
- /* 0
- 1 <-- index
- 2
- 3 <-- desired position
- */
- int itemCount = items.size();
-
- if (index < itemCount - 1) {
- Item acc = items.remove(index);
- items.add(itemCount - 1, acc);
- }
- }
- void toggleCurrencyVisible() {
- showCurrency.setValue(!showCurrency.getValue());
- }
- void stopObservingBusyFlag(Observer<Boolean> observer) {
- busyFlag.removeObserver(observer);
- }
- void incrementBusyCounter() {
- int newValue = busyCounter.incrementAndGet();
- if (newValue == 1)
- busyFlag.postValue(true);
- }
- void decrementBusyCounter() {
- int newValue = busyCounter.decrementAndGet();
- if (newValue == 0)
- busyFlag.postValue(false);
- }
- public boolean getBusyFlag() {
- return busyFlag.getValue();
- }
- public void toggleShowComments() {
- showComments.setValue(!showComments.getValue());
- }
- enum ItemType {generalData, transactionRow, bottomFiller}
-
- enum FocusedElement {Account, Comment, Amount, Description, TransactionComment}
-
-
- //==========================================================================================
-
-
- static class Item {
- private final ItemType type;
- private final MutableLiveData<SimpleDate> date = new MutableLiveData<>();
- private final MutableLiveData<String> description = new MutableLiveData<>();
- private final MutableLiveData<String> amountHint = new MutableLiveData<>(null);
- private final NewTransactionModel model;
- private final MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
- private final MutableLiveData<String> comment = new MutableLiveData<>(null);
- private final MutableLiveData<Currency> currency = new MutableLiveData<>(null);
- private final MutableLiveData<Boolean> 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;
- }
- void setFocusedElement(FocusedElement focusedElement) {
- this.focusedElement = focusedElement;
- }
- public NewTransactionModel getModel() {
- return model;
- }
- void setEditable(boolean editable) {
- ensureTypeIsGeneralDataOrTransactionRow();
- this.editable.setValue(editable);
- }
- 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));
- }
- }
- 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;
- }
- else {
- if (amountHint.equals(this.amountHint.getValue()))
- return;
- amountHintIsSet = true;
- }
-
- this.amountHint.setValue(amountHint);
- }
- void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
- @NonNull androidx.lifecycle.Observer<? super String> observer) {
- this.amountHint.observe(owner, observer);
- }
- void stopObservingAmountHint(
- @NonNull androidx.lifecycle.Observer<? super String> observer) {
- this.amountHint.removeObserver(observer);
- }
- ItemType getType() {
- return type;
- }
- void ensureType(ItemType wantedType) {
- if (type != wantedType) {
- throw new RuntimeException(
- String.format("Actual type (%s) differs from wanted (%s)", type,
- wantedType));
- }
- }
- public SimpleDate getDate() {
- ensureType(ItemType.generalData);
- return date.getValue();
- }
- public void setDate(SimpleDate date) {
- ensureType(ItemType.generalData);
- this.date.setValue(date);
- }
- public void setDate(String text) throws ParseException {
- if ((text == null) || text.trim()
- .isEmpty())
- {
- setDate((SimpleDate) null);
- return;
- }
-
- SimpleDate date = Globals.parseLedgerDate(text);
- this.setDate(date);
- }
- void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
- @NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
- this.date.observe(owner, observer);
- }
- void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
- this.date.removeObserver(observer);
- }
- public String getDescription() {
- ensureType(ItemType.generalData);
- return description.getValue();
- }
- public void setDescription(String description) {
- ensureType(ItemType.generalData);
- this.description.setValue(description);
- }
- void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
- @NonNull androidx.lifecycle.Observer<? super String> observer) {
- this.description.observe(owner, observer);
- }
- void stopObservingDescription(
- @NonNull androidx.lifecycle.Observer<? super String> observer) {
- this.description.removeObserver(observer);
- }
- public String getTransactionComment() {
- ensureType(ItemType.generalData);
- return comment.getValue();
- }
- public void setTransactionComment(String transactionComment) {
- ensureType(ItemType.generalData);
- this.comment.setValue(transactionComment);
- }
- void observeTransactionComment(@NonNull @NotNull LifecycleOwner owner,
- @NonNull Observer<? super String> observer) {
- ensureType(ItemType.generalData);
- this.comment.observe(owner, observer);
- }
- void stopObservingTransactionComment(@NonNull Observer<? super String> observer) {
- this.comment.removeObserver(observer);
- }
- public LedgerTransactionAccount getAccount() {
- ensureType(ItemType.transactionRow);
- return account;
- }
- public void setAccountName(String name) {
- account.setAccountName(name);
- }
- /**
- * getFormattedDate()
- *
- * @return nicely formatted, shortest available date representation
- */
- 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.MONTH) != d.month - 1) {
- return String.format(Locale.US, "%d/%02d", d.month, d.day);
- }
-
- return String.valueOf(d.day);
- }
- void observeEditableFlag(NewTransactionActivity activity, Observer<Boolean> observer) {
- editable.observe(activity, observer);
- }
- void stopObservingEditableFlag(Observer<Boolean> observer) {
- editable.removeObserver(observer);
- }
- void observeComment(NewTransactionActivity activity, Observer<String> observer) {
- comment.observe(activity, observer);
- }
- void stopObservingComment(Observer<String> observer) {
- comment.removeObserver(observer);
- }
- public void setComment(String comment) {
- getAccount().setComment(comment);
- this.comment.postValue(comment);
- }
- public Currency getCurrency() {
- return this.currency.getValue();
- }
- 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);
- }
- }
- void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
- currency.observe(activity, observer);
- }
- void stopObservingCurrency(Observer<Currency> observer) {
- currency.removeObserver(observer);
- }
- boolean isBottomFiller() {
- return this.type == ItemType.bottomFiller;
- }
- boolean isAmountHintSet() {
- return amountHintIsSet;
- }
- void validateAmount() {
- amountValid.setValue(true);
- }
- void invalidateAmount() {
- amountValid.setValue(false);
- }
- void observeAmountValidity(NewTransactionActivity activity, Observer<Boolean> observer) {
- amountValid.observe(activity, observer);
- }
- void stopObservingAmountValidity(Observer<Boolean> observer) {
- amountValid.removeObserver(observer);
- }
- }
-}
+++ /dev/null
-/*
- * 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 <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.View;
-
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.snackbar.Snackbar;
-
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.databinding.ActivityPatternsBinding;
-import net.ktnx.mobileledger.ui.patterns.PatternsModel;
-import net.ktnx.mobileledger.ui.patterns.PatternsRecyclerViewAdapter;
-
-public class PatternsActivity extends CrashReportingActivity {
-
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- super.onCreateOptionsMenu(menu);
- getMenuInflater().inflate(R.menu.pattern_list_menu, menu);
-
- return true;
- }
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- ActivityPatternsBinding b = ActivityPatternsBinding.inflate(getLayoutInflater());
- setContentView(b.getRoot());
- setSupportActionBar(b.toolbar);
- b.toolbarLayout.setTitle(getTitle());
-
- b.fab.setOnClickListener(this::fabClicked);
-
- PatternsRecyclerViewAdapter modelAdapter = new PatternsRecyclerViewAdapter();
-
- b.patternList.setAdapter(modelAdapter);
- PatternsModel.retrievePatterns(modelAdapter);
- LinearLayoutManager llm = new LinearLayoutManager(this);
- llm.setOrientation(RecyclerView.VERTICAL);
- b.patternList.setLayoutManager(llm);
- }
- private void fabClicked(View view) {
- Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_INDEFINITE)
- .setAction("Action", null)
- .show();
- }
-}
\ No newline at end of file
+++ /dev/null
-/*
- * Copyright © 2019 Damyan Ivanov.
- * This file is part of MoLe.
- * MoLe is free software: you can distribute it and/or modify it
- * under the term of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your opinion), any later version.
- *
- * MoLe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License terms for details.
- *
- * You should have received a copy of the GNU General Public License
- * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package net.ktnx.mobileledger.ui.activity;
-
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuItem;
-
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.Toolbar;
-import androidx.lifecycle.ViewModelProvider;
-
-import net.ktnx.mobileledger.R;
-import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
-import net.ktnx.mobileledger.ui.profiles.ProfileDetailModel;
-import net.ktnx.mobileledger.utils.Colors;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.util.ArrayList;
-import java.util.Locale;
-
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
-/**
- * An activity representing a single Profile detail screen. This
- * activity is only used on narrow width devices. On tablet-size devices,
- * item details are presented side-by-side with a list of items
- * in a ProfileListActivity (not really).
- */
-public class ProfileDetailActivity extends CrashReportingActivity {
- private MobileLedgerProfile profile = null;
- private ProfileDetailFragment mFragment;
- @NotNull
- private ProfileDetailModel getModel() {
- return new ViewModelProvider(this).get(ProfileDetailModel.class);
- }
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- final int index = getIntent().getIntExtra(ProfileDetailFragment.ARG_ITEM_ID, -1);
-
- if (index != -1) {
- ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
- if (profiles != null) {
- profile = profiles.get(index);
- if (profile == null)
- throw new AssertionError(
- String.format("Can't get profile " + "(index:%d) from the global list",
- index));
-
- debug("profiles", String.format(Locale.ENGLISH, "Editing profile %s (%s); hue=%d",
- profile.getName(), profile.getUuid(), profile.getThemeHue()));
- }
- }
-
- super.onCreate(savedInstanceState);
- int themeHue;
- if (profile != null)
- themeHue = profile.getThemeHue();
- else {
- themeHue = Colors.getNewProfileThemeHue(Data.profiles.getValue());
- }
- Colors.setupTheme(this, themeHue);
- final ProfileDetailModel model = getModel();
- model.initialThemeHue = themeHue;
- setContentView(R.layout.activity_profile_detail);
- Toolbar toolbar = findViewById(R.id.detail_toolbar);
- setSupportActionBar(toolbar);
-
-
- // Show the Up button in the action bar.
- ActionBar actionBar = getSupportActionBar();
- if (actionBar != null) {
- actionBar.setDisplayHomeAsUpEnabled(true);
- }
-
- // savedInstanceState is non-null when there is fragment state
- // saved from previous configurations of this activity
- // (e.g. when rotating the screen from portrait to landscape).
- // In this case, the fragment will automatically be re-added
- // to its container so we don't need to manually add it.
- // For more information, see the Fragments API guide at:
- //
- // http://developer.android.com/guide/components/fragments.html
- //
- if (savedInstanceState == null) {
- // Create the detail fragment and add it to the activity
- // using a fragment transaction.
- Bundle arguments = new Bundle();
- arguments.putInt(ProfileDetailFragment.ARG_ITEM_ID, index);
- arguments.putInt(ProfileDetailFragment.ARG_HUE, themeHue);
- mFragment = new ProfileDetailFragment();
- mFragment.setArguments(arguments);
- getSupportFragmentManager().beginTransaction()
- .add(R.id.profile_detail_container, mFragment)
- .commit();
- }
- }
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- super.onCreateOptionsMenu(menu);
- debug("profiles", "[activity] Creating profile details options menu");
- if (mFragment != null)
- mFragment.onCreateOptionsMenu(menu, getMenuInflater());
-
- return true;
- }
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- if (item.getItemId() == android.R.id.home) {
- finish();
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+import androidx.appcompat.widget.Toolbar;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.navigation.NavController;
+import androidx.navigation.fragment.NavHostFragment;
+
+import net.ktnx.mobileledger.BuildConfig;
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.async.AsyncCrasher;
+import net.ktnx.mobileledger.async.SendTransactionTask;
+import net.ktnx.mobileledger.async.TaskCallback;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.LedgerTransaction;
+import net.ktnx.mobileledger.ui.activity.ProfileThemedActivity;
+
+import java.util.Objects;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+public class NewTransactionActivity extends ProfileThemedActivity implements TaskCallback,
+ NewTransactionFragment.OnNewTransactionFragmentInteractionListener {
+ private NavController navController;
+ private NewTransactionModel model;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_new_transaction);
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ Data.observeProfile(this,
+ mobileLedgerProfile -> toolbar.setSubtitle(mobileLedgerProfile.getName()));
+
+ NavHostFragment navHostFragment = (NavHostFragment) Objects.requireNonNull(
+ getSupportFragmentManager().findFragmentById(R.id.new_transaction_nav));
+ navController = navHostFragment.getNavController();
+
+ Objects.requireNonNull(getSupportActionBar())
+ .setDisplayHomeAsUpEnabled(true);
+
+ model = new ViewModelProvider(this).get(NewTransactionModel.class);
+ }
+ @Override
+ protected void initProfile() {
+ String profileUUID = getIntent().getStringExtra("profile_uuid");
+
+ if (profileUUID != null) {
+ mProfile = Data.getProfile(profileUUID);
+ if (mProfile == null)
+ finish();
+ Data.setCurrentProfile(mProfile);
+ }
+ else
+ super.initProfile();
+ }
+ @Override
+ public void finish() {
+ super.finish();
+ overridePendingTransition(R.anim.dummy, R.anim.slide_out_down);
+ }
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+ public void onTransactionSave(LedgerTransaction tr) {
+ navController.navigate(R.id.action_newTransactionFragment_to_newTransactionSavingFragment);
+ try {
+
+ SendTransactionTask saver =
+ new SendTransactionTask(this, mProfile, model.getSimulateSave());
+ saver.execute(tr);
+ }
+ catch (Exception e) {
+ debug("new-transaction", "Unknown error", e);
+
+ Bundle b = new Bundle();
+ b.putString("error", "unknown error");
+ navController.navigate(R.id.newTransactionFragment, b);
+ }
+ }
+ public void simulateCrash(MenuItem item) {
+ debug("crash", "Will crash intentionally");
+ new AsyncCrasher().execute();
+ }
+ public boolean onCreateOptionsMenu(Menu menu) {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ getMenuInflater().inflate(R.menu.new_transaction, menu);
+
+ if (BuildConfig.DEBUG) {
+ menu.findItem(R.id.action_simulate_crash)
+ .setVisible(true);
+ menu.findItem(R.id.action_simulate_save)
+ .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);
+ });
+
+ return true;
+ }
+
+
+ public int dp2px(float dp) {
+ return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
+ getResources().getDisplayMetrics()));
+ }
+ @Override
+ public void done(String error) {
+ Bundle b = new Bundle();
+ if (error != null) {
+ b.putString("error", error);
+ navController.navigate(R.id.action_newTransactionSavingFragment_Failure, b);
+ }
+ else
+ navController.navigate(R.id.action_newTransactionSavingFragment_Success, b);
+ }
+ public void toggleSimulateSave(MenuItem item) {
+ model.toggleSimulateSave();
+ }
+
+}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.renderscript.RSInvalidStateException;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.snackbar.Snackbar;
+
+import net.ktnx.mobileledger.R;
+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.utils.Logger;
+import net.ktnx.mobileledger.utils.Misc;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A simple {@link Fragment} subclass.
+ * Activities that contain this fragment must implement the
+ * {@link OnNewTransactionFragmentInteractionListener} interface
+ * to handle interaction events.
+ */
+
+// TODO: offer to undo account remove-on-swipe
+
+public class NewTransactionFragment extends Fragment {
+ private NewTransactionItemsAdapter listAdapter;
+ private NewTransactionModel viewModel;
+ final ActivityResultLauncher<Void> scanQrLauncher =
+ registerForActivityResult(new ActivityResultContract<Void, String>() {
+ @NonNull
+ @Override
+ public Intent createIntent(@NonNull Context context, Void input) {
+ final Intent intent = new Intent("com.google.zxing.client.android.SCAN");
+ intent.putExtra("SCAN_MODE", "QR_CODE_MODE");
+ return intent;
+ }
+ @Override
+ public String parseResult(int resultCode, @Nullable Intent intent) {
+ if (resultCode == Activity.RESULT_CANCELED)
+ return null;
+ return intent.getStringExtra("SCAN_RESULT");
+ }
+ }, this::onQrScanned);
+ private FloatingActionButton fab;
+ private OnNewTransactionFragmentInteractionListener mListener;
+ private MobileLedgerProfile mProfile;
+ public NewTransactionFragment() {
+ // Required empty public constructor
+ setHasOptionsMenu(true);
+ }
+ private void onQrScanned(String text) {
+ Logger.debug("qr", String.format("Got QR scan result [%s]", text));
+ Pattern p =
+ Pattern.compile("^(\\d+)\\*(\\d+)\\*(\\d+)-(\\d+)-(\\d+)\\*([:\\d]+)\\*([\\d.]+)$");
+ Matcher m = p.matcher(text);
+ if (m.matches()) {
+ float amount = Float.parseFloat(m.group(7));
+ viewModel.setDate(
+ new SimpleDate(Integer.parseInt(m.group(3)), Integer.parseInt(m.group(4)),
+ Integer.parseInt(m.group(5))));
+
+ if (viewModel.accountsInInitialState()) {
+ {
+ NewTransactionModel.Item firstItem = viewModel.getItem(1);
+ if (firstItem == null) {
+ viewModel.addAccount(new LedgerTransactionAccount("разход:пазар"));
+ listAdapter.notifyItemInserted(viewModel.items.size() - 1);
+ }
+ else {
+ firstItem.setAccountName("разход:пазар");
+ firstItem.getAccount()
+ .resetAmount();
+ listAdapter.notifyItemChanged(1);
+ }
+ }
+ {
+ NewTransactionModel.Item secondItem = viewModel.getItem(2);
+ if (secondItem == null) {
+ viewModel.addAccount(
+ new LedgerTransactionAccount("актив:кеш:дам", -amount, null, null));
+ listAdapter.notifyItemInserted(viewModel.items.size() - 1);
+ }
+ else {
+ secondItem.setAccountName("актив:кеш:дам");
+ secondItem.getAccount()
+ .setAmount(-amount);
+ listAdapter.notifyItemChanged(2);
+ }
+ }
+ }
+ else {
+ viewModel.addAccount(new LedgerTransactionAccount("разход:пазар"));
+ viewModel.addAccount(
+ new LedgerTransactionAccount("актив:кеш:дам", -amount, null, null));
+ listAdapter.notifyItemRangeInserted(viewModel.items.size() - 1, 2);
+ }
+
+ listAdapter.checkTransactionSubmittable();
+ }
+ }
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ final FragmentActivity activity = getActivity();
+
+ inflater.inflate(R.menu.new_transaction_fragment, menu);
+
+ menu.findItem(R.id.scan_qr)
+ .setOnMenuItemClickListener(this::onScanQrAction);
+
+ menu.findItem(R.id.action_reset_new_transaction_activity)
+ .setOnMenuItemClickListener(item -> {
+ listAdapter.reset();
+ return true;
+ });
+
+ final MenuItem toggleCurrencyItem = menu.findItem(R.id.toggle_currency);
+ toggleCurrencyItem.setOnMenuItemClickListener(item -> {
+ viewModel.toggleCurrencyVisible();
+ return true;
+ });
+ if (activity != null)
+ viewModel.showCurrency.observe(activity, toggleCurrencyItem::setChecked);
+
+ final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments);
+ toggleCommentsItem.setOnMenuItemClickListener(item -> {
+ viewModel.toggleShowComments();
+ return true;
+ });
+ if (activity != null)
+ viewModel.showComments.observe(activity, toggleCommentsItem::setChecked);
+ }
+ private boolean onScanQrAction(MenuItem item) {
+ try {
+ scanQrLauncher.launch(null);
+ }
+ catch (Exception e) {
+ Logger.debug("qr", "Error launching QR scanner", e);
+ }
+
+ return true;
+ }
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ // Inflate the layout for this fragment
+ return inflater.inflate(R.layout.fragment_new_transaction, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ FragmentActivity activity = getActivity();
+ if (activity == null)
+ throw new RSInvalidStateException(
+ "getActivity() returned null within onActivityCreated()");
+
+ viewModel = new ViewModelProvider(activity).get(NewTransactionModel.class);
+ viewModel.observeDataProfile(this);
+ mProfile = Data.getProfile();
+ listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
+
+ RecyclerView list = activity.findViewById(R.id.new_transaction_accounts);
+ list.setAdapter(listAdapter);
+ list.setLayoutManager(new LinearLayoutManager(activity));
+
+ Data.observeProfile(getViewLifecycleOwner(), profile -> {
+ 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.fab);
+ fab.setOnClickListener(v -> onFabPressed());
+
+ boolean keep = false;
+
+ Bundle args = getArguments();
+ if (args != null) {
+ String error = args.getString("error");
+ if (error != null) {
+ Logger.debug("new-trans-f", String.format("Got error: %s", error));
+
+ Context context = getContext();
+ if (context != null) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ final Resources resources = context.getResources();
+ final StringBuilder message = new StringBuilder();
+ message.append(resources.getString(R.string.err_json_send_error_head));
+ message.append("\n\n");
+ message.append(error);
+ if (mProfile.getApiVersion()
+ .equals(API.auto))
+ message.append(
+ resources.getString(R.string.err_json_send_error_unsupported));
+ else {
+ message.append(resources.getString(R.string.err_json_send_error_tail));
+ builder.setPositiveButton(R.string.btn_profile_options, (dialog, which) -> {
+ Logger.debug("error", "will start profile editor");
+ MobileLedgerProfile.startEditProfileActivity(context, mProfile);
+ });
+ }
+ builder.setMessage(message);
+ builder.create()
+ .show();
+ }
+ else {
+ Snackbar.make(list, error, Snackbar.LENGTH_INDEFINITE)
+ .show();
+ }
+ keep = true;
+ }
+ }
+
+ int focused = 0;
+ if (savedInstanceState != null) {
+ keep |= savedInstanceState.getBoolean("keep", true);
+ focused = savedInstanceState.getInt("focused", 0);
+ }
+
+ if (!keep)
+ viewModel.reset();
+ else {
+ viewModel.setFocusedItem(focused);
+ }
+
+ ProgressBar p = activity.findViewById(R.id.progressBar);
+ viewModel.observeBusyFlag(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);
+ });
+ }
+ @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);
+ }
+ }
+
+ @Override
+ public void onAttach(@NotNull Context context) {
+ super.onAttach(context);
+ if (context instanceof OnNewTransactionFragmentInteractionListener) {
+ mListener = (OnNewTransactionFragmentInteractionListener) context;
+ }
+ else {
+ throw new RuntimeException(
+ context.toString() + " must implement OnFragmentInteractionListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ mListener = null;
+ }
+
+ /**
+ * This interface must be implemented by activities that contain this
+ * fragment to allow an interaction in this fragment to be communicated
+ * to the activity and potentially other fragments contained in that
+ * activity.
+ * <p>
+ * See the Android Training lesson <a href=
+ * "http://developer.android.com/training/basics/fragments/communicating.html"
+ * >Communicating with Other Fragments</a> for more information.
+ */
+ public interface OnNewTransactionFragmentInteractionListener {
+ void onTransactionSave(LedgerTransaction tr);
+ }
+}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import android.annotation.SuppressLint;
+import android.graphics.Typeface;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.Gravity;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+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.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;
+import net.ktnx.mobileledger.ui.TextViewClearHelper;
+import net.ktnx.mobileledger.utils.DimensionUtils;
+import net.ktnx.mobileledger.utils.Logger;
+import net.ktnx.mobileledger.utils.MLDB;
+import net.ktnx.mobileledger.utils.Misc;
+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;
+
+class NewTransactionItemHolder extends RecyclerView.ViewHolder
+ implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback {
+ private final String decimalDot;
+ private final Observer<Boolean> showCommentsObserver;
+ private final MobileLedgerProfile mProfile;
+ private final Observer<SimpleDate> dateObserver;
+ private final Observer<String> descriptionObserver;
+ private final Observer<String> transactionCommentObserver;
+ private final Observer<String> hintObserver;
+ private final Observer<Integer> focusedAccountObserver;
+ private final Observer<Integer> accountCountObserver;
+ private final Observer<Boolean> editableObserver;
+ private final Observer<Currency.Position> currencyPositionObserver;
+ private final Observer<Boolean> currencyGapObserver;
+ private final Observer<Locale> localeObserver;
+ private final Observer<Currency> currencyObserver;
+ private final Observer<Boolean> showCurrencyObserver;
+ private final Observer<String> commentObserver;
+ private final Observer<Boolean> amountValidityObserver;
+ private final NewTransactionRowBinding b;
+ 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
+ NewTransactionItemHolder(@NonNull NewTransactionRowBinding b,
+ NewTransactionItemsAdapter adapter) {
+ super(b.getRoot());
+ this.b = b;
+ new TextViewClearHelper().attachToTextView(b.comment);
+
+ b.newTransactionDescription.setNextFocusForwardId(View.NO_ID);
+ b.accountRowAccName.setNextFocusForwardId(View.NO_ID);
+ b.accountRowAccAmounts.setNextFocusForwardId(View.NO_ID); // magic!
+
+ b.newTransactionDate.setOnClickListener(v -> pickTransactionDate());
+
+ b.accountCommentButton.setOnClickListener(v -> {
+ b.comment.setVisibility(View.VISIBLE);
+ b.comment.requestFocus();
+ });
+
+ b.transactionCommentButton.setOnClickListener(v -> {
+ b.transactionComment.setVisibility(View.VISIBLE);
+ b.transactionComment.requestFocus();
+ });
+
+ mProfile = Data.getProfile();
+
+ View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
+ final int id = v.getId();
+ if (hasFocus) {
+ boolean wasSyncing = syncingData;
+ syncingData = true;
+ try {
+ final int pos = getAdapterPosition();
+ adapter.updateFocusedItem(pos);
+ if (id == R.id.account_row_acc_name) {
+ adapter.noteFocusIsOnAccount(pos);
+ }
+ else if (id == R.id.account_row_acc_amounts) {
+ adapter.noteFocusIsOnAmount(pos);
+ }
+ else if (id == R.id.comment) {
+ adapter.noteFocusIsOnComment(pos);
+ }
+ else if (id == R.id.transaction_comment) {
+ adapter.noteFocusIsOnTransactionComment(pos);
+ }
+ else if (id == R.id.new_transaction_description) {
+ adapter.noteFocusIsOnDescription(pos);
+ }
+ }
+ finally {
+ syncingData = wasSyncing;
+ }
+ }
+
+ if (id == R.id.comment) {
+ commentFocusChanged(b.comment, hasFocus);
+ }
+ else if (id == R.id.transaction_comment) {
+ commentFocusChanged(b.transactionComment, hasFocus);
+ }
+ };
+
+ b.newTransactionDescription.setOnFocusChangeListener(focusMonitor);
+ b.accountRowAccName.setOnFocusChangeListener(focusMonitor);
+ b.accountRowAccAmounts.setOnFocusChangeListener(focusMonitor);
+ 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);
+
+ decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance()
+ .getMonetaryDecimalSeparator());
+ localeObserver = locale -> decimalSeparator = String.valueOf(
+ DecimalFormatSymbols.getInstance(locale)
+ .getMonetaryDecimalSeparator());
+
+ decimalDot = ".";
+
+ final TextWatcher tw = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+// debug("input", "text changed");
+ if (inUpdate)
+ return;
+
+ Logger.debug("textWatcher", "calling syncData()");
+ syncData();
+ Logger.debug("textWatcher",
+ "syncData() returned, checking if transaction is submittable");
+ adapter.checkTransactionSubmittable();
+ 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));
+ }
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {}
+ @Override
+ public void afterTextChanged(Editable s) {
+
+ if (syncData())
+ adapter.checkTransactionSubmittable();
+ }
+ };
+ b.newTransactionDescription.addTextChangedListener(tw);
+ b.transactionComment.addTextChangedListener(tw);
+ b.accountRowAccName.addTextChangedListener(tw);
+ b.comment.addTextChangedListener(tw);
+ 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.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 =
+ 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);
+ }
+ };
+
+ 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();
+
+ 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);
+ };
+
+ amountValidityObserver = valid -> {
+ b.accountRowAccAmounts.setCompoundDrawablesRelativeWithIntrinsicBounds(
+ valid ? 0 : R.drawable.ic_error_outline_black_24dp, 0, 0, 0);
+ b.accountRowAccAmounts.setMinEms(valid ? 4 : 5);
+ };
+ }
+ private void commentFocusChanged(TextView textView, boolean hasFocus) {
+ @ColorInt int textColor;
+ textColor = b.dummyText.getTextColors()
+ .getDefaultColor();
+ if (hasFocus) {
+ textView.setTypeface(null, Typeface.NORMAL);
+ textView.setHint(R.string.transaction_account_comment_hint);
+ }
+ else {
+ int alpha = (textColor >> 24 & 0xff);
+ alpha = 3 * alpha / 4;
+ textColor = (alpha << 24) | (0x00ffffff & textColor);
+ textView.setTypeface(null, Typeface.ITALIC);
+ textView.setHint("");
+ if (TextUtils.isEmpty(textView.getText())) {
+ textView.setVisibility(View.INVISIBLE);
+ }
+ }
+ textView.setTextColor(textColor);
+
+ }
+ private void updateCurrencyPositionAndPadding(Currency.Position position, boolean hasGap) {
+ ConstraintLayout.LayoutParams amountLP =
+ (ConstraintLayout.LayoutParams) b.accountRowAccAmounts.getLayoutParams();
+ ConstraintLayout.LayoutParams currencyLP =
+ (ConstraintLayout.LayoutParams) b.currency.getLayoutParams();
+
+ if (position == Currency.Position.before) {
+ currencyLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
+ currencyLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
+
+ amountLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
+ amountLP.endToStart = ConstraintLayout.LayoutParams.UNSET;
+ amountLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
+ amountLP.startToEnd = b.currency.getId();
+
+ b.currency.setGravity(Gravity.END);
+ }
+ else {
+ currencyLP.startToStart = ConstraintLayout.LayoutParams.UNSET;
+ currencyLP.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
+
+ amountLP.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
+ amountLP.startToEnd = ConstraintLayout.LayoutParams.UNSET;
+ amountLP.endToEnd = ConstraintLayout.LayoutParams.UNSET;
+ amountLP.endToStart = b.currency.getId();
+
+ b.currency.setGravity(Gravity.START);
+ }
+
+ amountLP.resolveLayoutDirection(b.accountRowAccAmounts.getLayoutDirection());
+ currencyLP.resolveLayoutDirection(b.currency.getLayoutDirection());
+
+ b.accountRowAccAmounts.setLayoutParams(amountLP);
+ b.currency.setLayoutParams(currencyLP);
+
+ // distance between the amount and the currency symbol
+ int gapSize = DimensionUtils.sp2px(b.currency.getContext(), 5);
+
+ if (position == Currency.Position.before) {
+ b.currency.setPaddingRelative(0, 0, hasGap ? gapSize : 0, 0);
+ }
+ else {
+ b.currency.setPaddingRelative(hasGap ? gapSize : 0, 0, 0, 0);
+ }
+ }
+ private void setCurrencyString(String currency) {
+ @ColorInt int textColor = b.dummyText.getTextColors()
+ .getDefaultColor();
+ if ((currency == null) || currency.isEmpty()) {
+ b.currency.setText(R.string.currency_symbol);
+ int alpha = (textColor >> 24) & 0xff;
+ alpha = alpha * 3 / 4;
+ b.currency.setTextColor((alpha << 24) | (0x00ffffff & textColor));
+ }
+ else {
+ b.currency.setText(currency);
+ b.currency.setTextColor(textColor);
+ }
+ }
+ private void setCurrency(Currency currency) {
+ setCurrencyString((currency == null) ? null : currency.getName());
+ }
+ private void setEditable(Boolean editable) {
+ b.newTransactionDate.setEnabled(editable);
+ b.newTransactionDescription.setEnabled(editable);
+ b.accountRowAccName.setEnabled(editable);
+ b.accountRowAccAmounts.setEnabled(editable);
+ }
+ private void beginUpdates() {
+ if (inUpdate)
+ throw new RuntimeException("Already in update mode");
+ inUpdate = true;
+ }
+ private void endUpdates() {
+ if (!inUpdate)
+ throw new RuntimeException("Not in update mode");
+ inUpdate = false;
+ }
+ /**
+ * syncData()
+ * <p>
+ * Stores the data from the UI elements into the model item
+ * Returns true if there were changes made that suggest transaction has to be
+ * checked for being submittable
+ */
+ private boolean syncData() {
+ if (item == null)
+ return false;
+
+ if (syncingData) {
+ Logger.debug("new-trans", "skipping syncData() loop");
+ return false;
+ }
+
+ 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();
+ }
+ 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);
+ }
+
+ break;
+ case bottomFiller:
+ throw new RuntimeException("Should not happen");
+ }
+
+ return true;
+ }
+ catch (ParseException e) {
+ throw new RuntimeException("Should not happen", e);
+ }
+ finally {
+ syncingData = false;
+ }
+ }
+ private void pickTransactionDate() {
+ DatePickerFragment picker = new DatePickerFragment();
+ picker.setFutureDates(mProfile.getFutureDates());
+ picker.setOnDatePickedListener(this);
+ picker.setCurrentDateFromText(b.newTransactionDate.getText());
+ picker.show(((NewTransactionActivity) b.getRoot()
+ .getContext()).getSupportFragmentManager(), null);
+ }
+ /**
+ * setData
+ *
+ * @param item updates the UI elements with the data from the model item
+ */
+ @SuppressLint("DefaultLocale")
+ public void setData(NewTransactionModel.Item item) {
+ beginUpdates();
+ try {
+ if (this.item != null && !this.item.equals(item)) {
+ this.item.stopObservingDate(dateObserver);
+ this.item.stopObservingDescription(descriptionObserver);
+ this.item.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;
+ }
+
+ 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();
+ b.accountRowAccName.setText(acc.getAccountName());
+ b.comment.setText(acc.getComment());
+ if (acc.isAmountSet()) {
+ b.accountRowAccAmounts.setText(String.format("%1.2f", acc.getAmount()));
+ }
+ else {
+ b.accountRowAccAmounts.setText("");
+// tvAmount.setHint(R.string.zero_amount);
+ }
+ b.accountRowAccAmounts.setHint(item.getAmountHint());
+ setCurrencyString(acc.getCurrency());
+ b.ntrData.setVisibility(View.GONE);
+ b.ntrAccount.setVisibility(View.VISIBLE);
+ b.ntrPadding.setVisibility(View.GONE);
+ setEditable(true);
+ break;
+ case 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;
+ }
+ }
+ }
+ finally {
+ endUpdates();
+ }
+ }
+ @Override
+ public void onDatePicked(int year, int month, int day) {
+ item.setDate(new SimpleDate(year, month + 1, day));
+ boolean focused = b.newTransactionDescription.requestFocus();
+ if (focused)
+ Misc.showSoftKeyboard((NewTransactionActivity) b.getRoot()
+ .getContext());
+
+ }
+ @Override
+ public void descriptionSelected(String description) {
+ b.accountRowAccName.setText(description);
+ b.accountRowAccAmounts.requestFocus(View.FOCUS_FORWARD);
+ }
+}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+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.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;
+
+class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemHolder>
+ implements DescriptionSelectedCallback {
+ private final NewTransactionModel model;
+ private final ItemTouchHelper touchHelper;
+ private MobileLedgerProfile mProfile;
+ private RecyclerView recyclerView;
+ private int checkHoldCounter = 0;
+ NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) {
+ super();
+ model = viewModel;
+ mProfile = profile;
+ int size = model.getAccountCount();
+ while (size < 2) {
+ Logger.debug("new-transaction",
+ String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
+ size = addRow();
+ }
+
+ NewTransactionItemsAdapter adapter = this;
+
+ touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return true;
+ }
+ @Override
+ public boolean canDropOver(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder current,
+ @NonNull RecyclerView.ViewHolder target) {
+ final int adapterPosition = target.getAdapterPosition();
+
+ // first and last items are immovable
+ if (adapterPosition == 0)
+ return false;
+ if (adapterPosition == adapter.getItemCount() - 1)
+ return false;
+
+ return super.canDropOver(recyclerView, current, target);
+ }
+ @Override
+ public int getMovementFlags(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder) {
+ int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
+ // the top (date and description) and the bottom (padding) items are always there
+ final int adapterPosition = viewHolder.getAdapterPosition();
+ if ((adapterPosition > 0) && (adapterPosition < adapter.getItemCount() - 1)) {
+ flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
+ ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
+ makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
+ ItemTouchHelper.START | ItemTouchHelper.END);
+ }
+
+ return flags;
+ }
+ @Override
+ public boolean onMove(@NonNull RecyclerView recyclerView,
+ @NonNull RecyclerView.ViewHolder viewHolder,
+ @NonNull RecyclerView.ViewHolder target) {
+
+ model.swapItems(viewHolder.getAdapterPosition(), target.getAdapterPosition());
+ notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
+ return true;
+ }
+ @Override
+ public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+ int pos = viewHolder.getAdapterPosition();
+ viewModel.removeItem(pos - 1);
+ notifyItemRemoved(pos);
+ viewModel.sendCountNotifications(); // needed after items re-arrangement
+ checkTransactionSubmittable();
+ }
+ });
+ }
+ public void setProfile(MobileLedgerProfile profile) {
+ mProfile = profile;
+ }
+ private int addRow() {
+ return addRow(null);
+ }
+ private int addRow(String commodity) {
+ final int newAccountCount = model.addAccount(new LedgerTransactionAccount("", commodity));
+ Logger.debug("new-transaction",
+ String.format(Locale.US, "invoking notifyItemInserted(%d)", newAccountCount));
+ // the header is at position 0
+ notifyItemInserted(newAccountCount);
+ model.sendCountNotifications(); // needed after holders' positions have changed
+ return newAccountCount;
+ }
+ @NonNull
+ @Override
+ public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ NewTransactionRowBinding b =
+ NewTransactionRowBinding.inflate(LayoutInflater.from(parent.getContext()), parent,
+ false);
+
+ return new NewTransactionItemHolder(b, this);
+ }
+ @Override
+ public void onBindViewHolder(@NonNull NewTransactionItemHolder holder, int position) {
+ Logger.debug("bind", String.format(Locale.US, "Binding item at position %d", position));
+ NewTransactionModel.Item item = model.getItem(position);
+ holder.setData(item);
+ Logger.debug("bind", String.format(Locale.US, "Bound %s item at position %d", item.getType()
+ .toString(),
+ position));
+ }
+ @Override
+ public int getItemCount() {
+ return model.getAccountCount() + 2;
+ }
+ private boolean accountListIsEmpty() {
+ for (int i = 0; i < model.getAccountCount(); i++) {
+ LedgerTransactionAccount acc = model.getAccount(i);
+ if (!acc.getAccountName()
+ .isEmpty())
+ return false;
+ if (acc.isAmountSet())
+ return false;
+ }
+
+ return true;
+ }
+ @Override
+ public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+ super.onAttachedToRecyclerView(recyclerView);
+ this.recyclerView = recyclerView;
+ touchHelper.attachToRecyclerView(recyclerView);
+ }
+ @Override
+ public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+ touchHelper.attachToRecyclerView(null);
+ super.onDetachedFromRecyclerView(recyclerView);
+ this.recyclerView = null;
+ }
+ public void descriptionSelected(String description) {
+ debug("description selected", description);
+ if (!accountListIsEmpty())
+ return;
+
+ String accFilter = mProfile.getPreferredAccountsFilter();
+
+ ArrayList<String> params = new ArrayList<>();
+ StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t");
+
+ 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<LedgerTransactionAccount> 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);
+ }
+ void noteFocusIsOnAmount(int position) {
+ model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
+ }
+ void noteFocusIsOnComment(int position) {
+ model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
+ }
+ void noteFocusIsOnTransactionComment(int position) {
+ model.noteFocusChanged(position, NewTransactionModel.FocusedElement.TransactionComment);
+ }
+ public void noteFocusIsOnDescription(int pos) {
+ model.noteFocusChanged(pos, NewTransactionModel.FocusedElement.Description);
+ }
+ private void holdSubmittableChecks() {
+ checkHoldCounter++;
+ }
+ private void releaseSubmittableChecks() {
+ if (checkHoldCounter == 0)
+ throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
+ checkHoldCounter--;
+ }
+ void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
+ Currency oldCurrency = item.getCurrency();
+ if (!Currency.equal(newCurrency, oldCurrency)) {
+ holdSubmittableChecks();
+ try {
+ item.setCurrency(newCurrency);
+// for (Item i : items) {
+// if (Currency.equal(i.getCurrency(), oldCurrency))
+// i.setCurrency(newCurrency);
+// }
+ }
+ finally {
+ releaseSubmittableChecks();
+ }
+
+ checkTransactionSubmittable();
+ }
+ }
+ /*
+ 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
+
+ */
+ @SuppressLint("DefaultLocale")
+ void checkTransactionSubmittable() {
+ if (checkHoldCounter > 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<NewTransactionModel.Item> 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<String, Float> 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<String> currencies() {
+ return hashMap.keySet();
+ }
+ boolean containsCurrency(String currencyName) {
+ return hashMap.containsKey(currencyName);
+ }
+ }
+
+ private static class ItemsForCurrency {
+ private final HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
+ @NonNull
+ List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
+ List<NewTransactionModel.Item> 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<String> currencies() {
+ return hashMap.keySet();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.new_transaction;
+
+import androidx.annotation.NonNull;
+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.model.Data;
+import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.utils.Globals;
+import net.ktnx.mobileledger.utils.SimpleDate;
+
+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.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class NewTransactionModel extends ViewModel {
+ final MutableLiveData<Boolean> showCurrency = new MutableLiveData<>(false);
+ final ArrayList<Item> items = new ArrayList<>();
+ final MutableLiveData<Boolean> isSubmittable = new MutableLiveData<>(false);
+ final MutableLiveData<Boolean> showComments = new MutableLiveData<>(true);
+ private final Item header = new Item(this, "");
+ private final Item trailer = new Item(this);
+ private final MutableLiveData<Integer> focusedItem = new MutableLiveData<>(0);
+ private final MutableLiveData<Integer> accountCount = new MutableLiveData<>(0);
+ private final MutableLiveData<Boolean> simulateSave = new MutableLiveData<>(false);
+ private final AtomicInteger busyCounter = new AtomicInteger(0);
+ private final MutableLiveData<Boolean> busyFlag = new MutableLiveData<>(false);
+ private final Observer<MobileLedgerProfile> profileObserver = profile -> {
+ showCurrency.postValue(profile.getShowCommodityByDefault());
+ showComments.postValue(profile.getShowCommentsByDefault());
+ };
+ private boolean observingDataProfile;
+ void observeShowComments(LifecycleOwner owner, Observer<? super Boolean> observer) {
+ showComments.observe(owner, observer);
+ }
+ void observeBusyFlag(@NonNull LifecycleOwner owner, Observer<? super Boolean> observer) {
+ busyFlag.observe(owner, observer);
+ }
+ void observeDataProfile(LifecycleOwner activity) {
+ if (!observingDataProfile)
+ Data.observeProfile(activity, profileObserver);
+ observingDataProfile = true;
+ }
+ boolean getSimulateSave() {
+ return simulateSave.getValue();
+ }
+ public void setSimulateSave(boolean simulateSave) {
+ this.simulateSave.setValue(simulateSave);
+ }
+ void toggleSimulateSave() {
+ simulateSave.setValue(!simulateSave.getValue());
+ }
+ void observeSimulateSave(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super Boolean> observer) {
+ this.simulateSave.observe(owner, observer);
+ }
+ int getAccountCount() {
+ return items.size();
+ }
+ public SimpleDate getDate() {
+ return header.date.getValue();
+ }
+ public void setDate(SimpleDate date) {
+ header.date.setValue(date);
+ }
+ public String getDescription() {
+ return header.description.getValue();
+ }
+ public String getComment() {
+ return header.comment.getValue();
+ }
+ LiveData<Boolean> 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<? super Integer> observer) {
+ this.focusedItem.observe(owner, observer);
+ }
+ void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer<? super Integer> observer) {
+ this.focusedItem.removeObserver(observer);
+ }
+ void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super Integer> observer) {
+ this.accountCount.observe(owner, observer);
+ }
+ void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer<? super Integer> 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();
+ }
+ boolean accountsInInitialState() {
+ for (Item item : items) {
+ LedgerTransactionAccount acc = item.getAccount();
+ if (acc.isAmountSet())
+ return false;
+ if (!acc.getAccountName()
+ .trim()
+ .isEmpty())
+ return false;
+ }
+
+ return true;
+ }
+ LedgerTransactionAccount getAccount(int index) {
+ return items.get(index)
+ .getAccount();
+ }
+ Item getItem(int index) {
+ if (index == 0) {
+ return header;
+ }
+
+ if (index <= items.size())
+ return items.get(index - 1);
+
+ return trailer;
+ }
+ void removeRow(Item item, NewTransactionItemsAdapter adapter) {
+ int pos = items.indexOf(item);
+ items.remove(pos);
+ if (adapter != null) {
+ adapter.notifyItemRemoved(pos + 1);
+ sendCountNotifications();
+ }
+ }
+ void removeItem(int pos) {
+ items.remove(pos);
+ accountCount.setValue(getAccountCount());
+ }
+ void sendCountNotifications() {
+ accountCount.setValue(getAccountCount());
+ }
+ public void sendFocusedNotification() {
+ focusedItem.setValue(focusedItem.getValue());
+ }
+ void updateFocusedItem(int position) {
+ focusedItem.setValue(position);
+ }
+ void noteFocusChanged(int position, FocusedElement element) {
+ getItem(position).setFocusedElement(element);
+ }
+ void swapItems(int one, int two) {
+ Collections.swap(items, one - 1, two - 1);
+ }
+ void moveItemLast(int index) {
+ /* 0
+ 1 <-- index
+ 2
+ 3 <-- desired position
+ */
+ int itemCount = items.size();
+
+ if (index < itemCount - 1) {
+ Item acc = items.remove(index);
+ items.add(itemCount - 1, acc);
+ }
+ }
+ void toggleCurrencyVisible() {
+ showCurrency.setValue(!showCurrency.getValue());
+ }
+ void stopObservingBusyFlag(Observer<Boolean> observer) {
+ busyFlag.removeObserver(observer);
+ }
+ void incrementBusyCounter() {
+ int newValue = busyCounter.incrementAndGet();
+ if (newValue == 1)
+ busyFlag.postValue(true);
+ }
+ void decrementBusyCounter() {
+ int newValue = busyCounter.decrementAndGet();
+ if (newValue == 0)
+ busyFlag.postValue(false);
+ }
+ public boolean getBusyFlag() {
+ return busyFlag.getValue();
+ }
+ public void toggleShowComments() {
+ showComments.setValue(!showComments.getValue());
+ }
+ enum ItemType {generalData, transactionRow, bottomFiller}
+
+ enum FocusedElement {Account, Comment, Amount, Description, TransactionComment}
+
+
+ //==========================================================================================
+
+
+ static class Item {
+ private final ItemType type;
+ private final MutableLiveData<SimpleDate> date = new MutableLiveData<>();
+ private final MutableLiveData<String> description = new MutableLiveData<>();
+ private final MutableLiveData<String> amountHint = new MutableLiveData<>(null);
+ private final NewTransactionModel model;
+ private final MutableLiveData<Boolean> editable = new MutableLiveData<>(true);
+ private final MutableLiveData<String> comment = new MutableLiveData<>(null);
+ private final MutableLiveData<Currency> currency = new MutableLiveData<>(null);
+ private final MutableLiveData<Boolean> 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;
+ }
+ void setFocusedElement(FocusedElement focusedElement) {
+ this.focusedElement = focusedElement;
+ }
+ public NewTransactionModel getModel() {
+ return model;
+ }
+ void setEditable(boolean editable) {
+ ensureTypeIsGeneralDataOrTransactionRow();
+ this.editable.setValue(editable);
+ }
+ 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));
+ }
+ }
+ 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;
+ }
+ else {
+ if (amountHint.equals(this.amountHint.getValue()))
+ return;
+ amountHintIsSet = true;
+ }
+
+ this.amountHint.setValue(amountHint);
+ }
+ void observeAmountHint(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.amountHint.observe(owner, observer);
+ }
+ void stopObservingAmountHint(
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.amountHint.removeObserver(observer);
+ }
+ ItemType getType() {
+ return type;
+ }
+ void ensureType(ItemType wantedType) {
+ if (type != wantedType) {
+ throw new RuntimeException(
+ String.format("Actual type (%s) differs from wanted (%s)", type,
+ wantedType));
+ }
+ }
+ public SimpleDate getDate() {
+ ensureType(ItemType.generalData);
+ return date.getValue();
+ }
+ public void setDate(SimpleDate date) {
+ ensureType(ItemType.generalData);
+ this.date.setValue(date);
+ }
+ public void setDate(String text) throws ParseException {
+ if ((text == null) || text.trim()
+ .isEmpty())
+ {
+ setDate((SimpleDate) null);
+ return;
+ }
+
+ SimpleDate date = Globals.parseLedgerDate(text);
+ this.setDate(date);
+ }
+ void observeDate(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
+ this.date.observe(owner, observer);
+ }
+ void stopObservingDate(@NonNull androidx.lifecycle.Observer<? super SimpleDate> observer) {
+ this.date.removeObserver(observer);
+ }
+ public String getDescription() {
+ ensureType(ItemType.generalData);
+ return description.getValue();
+ }
+ public void setDescription(String description) {
+ ensureType(ItemType.generalData);
+ this.description.setValue(description);
+ }
+ void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner,
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.description.observe(owner, observer);
+ }
+ void stopObservingDescription(
+ @NonNull androidx.lifecycle.Observer<? super String> observer) {
+ this.description.removeObserver(observer);
+ }
+ public String getTransactionComment() {
+ ensureType(ItemType.generalData);
+ return comment.getValue();
+ }
+ public void setTransactionComment(String transactionComment) {
+ ensureType(ItemType.generalData);
+ this.comment.setValue(transactionComment);
+ }
+ void observeTransactionComment(@NonNull @NotNull LifecycleOwner owner,
+ @NonNull Observer<? super String> observer) {
+ ensureType(ItemType.generalData);
+ this.comment.observe(owner, observer);
+ }
+ void stopObservingTransactionComment(@NonNull Observer<? super String> observer) {
+ this.comment.removeObserver(observer);
+ }
+ public LedgerTransactionAccount getAccount() {
+ ensureType(ItemType.transactionRow);
+ return account;
+ }
+ public void setAccountName(String name) {
+ account.setAccountName(name);
+ }
+ /**
+ * getFormattedDate()
+ *
+ * @return nicely formatted, shortest available date representation
+ */
+ 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.MONTH) != d.month - 1) {
+ return String.format(Locale.US, "%d/%02d", d.month, d.day);
+ }
+
+ return String.valueOf(d.day);
+ }
+ void observeEditableFlag(NewTransactionActivity activity, Observer<Boolean> observer) {
+ editable.observe(activity, observer);
+ }
+ void stopObservingEditableFlag(Observer<Boolean> observer) {
+ editable.removeObserver(observer);
+ }
+ void observeComment(NewTransactionActivity activity, Observer<String> observer) {
+ comment.observe(activity, observer);
+ }
+ void stopObservingComment(Observer<String> observer) {
+ comment.removeObserver(observer);
+ }
+ public void setComment(String comment) {
+ getAccount().setComment(comment);
+ this.comment.postValue(comment);
+ }
+ public Currency getCurrency() {
+ return this.currency.getValue();
+ }
+ 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);
+ }
+ }
+ void observeCurrency(NewTransactionActivity activity, Observer<Currency> observer) {
+ currency.observe(activity, observer);
+ }
+ void stopObservingCurrency(Observer<Currency> observer) {
+ currency.removeObserver(observer);
+ }
+ boolean isBottomFiller() {
+ return this.type == ItemType.bottomFiller;
+ }
+ boolean isAmountHintSet() {
+ return amountHintIsSet;
+ }
+ void validateAmount() {
+ amountValid.setValue(true);
+ }
+ void invalidateAmount() {
+ amountValid.setValue(false);
+ }
+ void observeAmountValidity(NewTransactionActivity activity, Observer<Boolean> observer) {
+ amountValid.observe(activity, observer);
+ }
+ void stopObservingAmountValidity(Observer<Boolean> observer) {
+ amountValid.removeObserver(observer);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.patterns;
+
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.View;
+
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.snackbar.Snackbar;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.databinding.ActivityPatternsBinding;
+import net.ktnx.mobileledger.ui.activity.CrashReportingActivity;
+
+public class PatternsActivity extends CrashReportingActivity {
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.pattern_list_menu, menu);
+
+ return true;
+ }
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ ActivityPatternsBinding b = ActivityPatternsBinding.inflate(getLayoutInflater());
+ setContentView(b.getRoot());
+ setSupportActionBar(b.toolbar);
+ b.toolbarLayout.setTitle(getTitle());
+
+ b.fab.setOnClickListener(this::fabClicked);
+
+ PatternsRecyclerViewAdapter modelAdapter = new PatternsRecyclerViewAdapter();
+
+ b.patternList.setAdapter(modelAdapter);
+ PatternsModel.retrievePatterns(modelAdapter);
+ LinearLayoutManager llm = new LinearLayoutManager(this);
+ llm.setOrientation(RecyclerView.VERTICAL);
+ b.patternList.setLayoutManager(llm);
+ }
+ private void fabClicked(View view) {
+ Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_INDEFINITE)
+ .setAction("Action", null)
+ .show();
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.ui.profiles;
+
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import androidx.lifecycle.ViewModelProvider;
+
+import net.ktnx.mobileledger.R;
+import net.ktnx.mobileledger.model.Data;
+import net.ktnx.mobileledger.model.MobileLedgerProfile;
+import net.ktnx.mobileledger.ui.activity.CrashReportingActivity;
+import net.ktnx.mobileledger.utils.Colors;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Locale;
+
+import static net.ktnx.mobileledger.utils.Logger.debug;
+
+/**
+ * An activity representing a single Profile detail screen. This
+ * activity is only used on narrow width devices. On tablet-size devices,
+ * item details are presented side-by-side with a list of items
+ * in a ProfileListActivity (not really).
+ */
+public class ProfileDetailActivity extends CrashReportingActivity {
+ private MobileLedgerProfile profile = null;
+ private ProfileDetailFragment mFragment;
+ @NotNull
+ private ProfileDetailModel getModel() {
+ return new ViewModelProvider(this).get(ProfileDetailModel.class);
+ }
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ final int index = getIntent().getIntExtra(ProfileDetailFragment.ARG_ITEM_ID, -1);
+
+ if (index != -1) {
+ ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
+ if (profiles != null) {
+ profile = profiles.get(index);
+ if (profile == null)
+ throw new AssertionError(
+ String.format("Can't get profile " + "(index:%d) from the global list",
+ index));
+
+ debug("profiles", String.format(Locale.ENGLISH, "Editing profile %s (%s); hue=%d",
+ profile.getName(), profile.getUuid(), profile.getThemeHue()));
+ }
+ }
+
+ super.onCreate(savedInstanceState);
+ int themeHue;
+ if (profile != null)
+ themeHue = profile.getThemeHue();
+ else {
+ themeHue = Colors.getNewProfileThemeHue(Data.profiles.getValue());
+ }
+ Colors.setupTheme(this, themeHue);
+ final ProfileDetailModel model = getModel();
+ model.initialThemeHue = themeHue;
+ setContentView(R.layout.activity_profile_detail);
+ Toolbar toolbar = findViewById(R.id.detail_toolbar);
+ setSupportActionBar(toolbar);
+
+
+ // Show the Up button in the action bar.
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ // savedInstanceState is non-null when there is fragment state
+ // saved from previous configurations of this activity
+ // (e.g. when rotating the screen from portrait to landscape).
+ // In this case, the fragment will automatically be re-added
+ // to its container so we don't need to manually add it.
+ // For more information, see the Fragments API guide at:
+ //
+ // http://developer.android.com/guide/components/fragments.html
+ //
+ if (savedInstanceState == null) {
+ // Create the detail fragment and add it to the activity
+ // using a fragment transaction.
+ Bundle arguments = new Bundle();
+ arguments.putInt(ProfileDetailFragment.ARG_ITEM_ID, index);
+ arguments.putInt(ProfileDetailFragment.ARG_HUE, themeHue);
+ mFragment = new ProfileDetailFragment();
+ mFragment.setArguments(arguments);
+ getSupportFragmentManager().beginTransaction()
+ .add(R.id.profile_detail_container, mFragment)
+ .commit();
+ }
+ }
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ debug("profiles", "[activity] Creating profile details options menu");
+ if (mFragment != null)
+ mFragment.onCreateOptionsMenu(menu, getMenuInflater());
+
+ return true;
+ }
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
/*
- * Copyright © 2020 Damyan Ivanov.
+ * 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
import net.ktnx.mobileledger.model.MobileLedgerProfile;
import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
import net.ktnx.mobileledger.ui.HueRingDialog;
-import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
import net.ktnx.mobileledger.utils.Colors;
import net.ktnx.mobileledger.utils.Misc;
/*
- * Copyright © 2020 Damyan Ivanov.
+ * 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
import net.ktnx.mobileledger.R;
import net.ktnx.mobileledger.model.Data;
import net.ktnx.mobileledger.model.MobileLedgerProfile;
-import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
import net.ktnx.mobileledger.utils.Colors;
import java.lang.ref.WeakReference;
<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2020 Damyan Ivanov.
+ ~ 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
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context=".ui.activity.NewTransactionActivity"
+ tools:context=".ui.new_transaction.NewTransactionActivity"
>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_height="match_parent"
android:fitsSystemWindows="true"
- tools:context=".ui.activity.PatternsActivity"
+ tools:context=".ui.patterns.PatternsActivity"
>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
- tools:context=".ui.activity.PatternsActivity"
+ tools:context=".ui.patterns.PatternsActivity"
tools:showIn="@layout/activity_patterns"
>
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright © 2020 Damyan Ivanov.
+ ~ 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
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context=".ui.activity.ProfileDetailActivity">
+ tools:context=".ui.profiles.ProfileDetailActivity"
+ >
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/app_bar_height"
android:fitsSystemWindows="true"
- android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
+ android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
+ >
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/toolbar_layout"
<!--
- ~ Copyright © 2020 Damyan Ivanov.
+ ~ 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
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
- tools:context="net.ktnx.mobileledger.ui.activity.NewTransactionActivity">
+ tools:context="net.ktnx.mobileledger.ui.new_transaction.NewTransactionActivity"
+ >
<ProgressBar
android:id="@+id/progressBar"
<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2020 Damyan Ivanov.
+ ~ 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
<fragment
android:id="@+id/newTransactionFragment"
- android:name="net.ktnx.mobileledger.ui.activity.NewTransactionFragment"
+ android:name="net.ktnx.mobileledger.ui.new_transaction.NewTransactionFragment"
android:label="NewTransactionFragment"
>
<action