From: Damyan Ivanov Date: Tue, 12 Jan 2021 17:14:02 +0000 (+0200) Subject: shuffle some classes under proper packages X-Git-Tag: v0.17.0~217 X-Git-Url: https://git.ktnx.net/?a=commitdiff_plain;h=9fad5003ac30c3e4f9d073e04f4569aeb31779b2;p=mobile-ledger.git shuffle some classes under proper packages --- diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a865bf9d..21a7e5ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,7 @@ android:supportsRtl="true" tools:ignore="GoogleAppIndexingWarning"> diff --git a/app/src/main/java/net/ktnx/mobileledger/async/AsyncCrasher.java b/app/src/main/java/net/ktnx/mobileledger/async/AsyncCrasher.java new file mode 100644 index 00000000..0ec9bb27 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/async/AsyncCrasher.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.async; + +import android.os.AsyncTask; + +public class AsyncCrasher extends AsyncTask { + @Override + protected Void doInBackground(Void... voids) { + throw new RuntimeException("Simulated crash"); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java index 21d15d16..0e8c0448 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java @@ -1,5 +1,5 @@ /* - * 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 @@ -32,7 +32,7 @@ import net.ktnx.mobileledger.App; 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; diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java deleted file mode 100644 index ce02ff51..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/AsyncCrasher.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2019 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.ui.activity; - -import android.os.AsyncTask; - -class AsyncCrasher extends AsyncTask { - @Override - protected Void doInBackground(Void... voids) { - throw new RuntimeException("Simulated crash"); - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java index da39640a..3ed89291 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java @@ -54,6 +54,8 @@ import net.ktnx.mobileledger.model.Data; 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; diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java deleted file mode 100644 index 74996417..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionActivity.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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 . - */ - -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(); - } - -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java deleted file mode 100644 index d9d844a4..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionFragment.java +++ /dev/null @@ -1,377 +0,0 @@ -/* - * Copyright © 2021 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.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 scanQrLauncher = - registerForActivityResult(new ActivityResultContract() { - @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. - *

- * See the Android Training lesson Communicating with Other Fragments for more information. - */ - public interface OnNewTransactionFragmentInteractionListener { - void onTransactionSave(LedgerTransaction tr); - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java deleted file mode 100644 index 94d85e76..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java +++ /dev/null @@ -1,711 +0,0 @@ -/* - * Copyright © 2021 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.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 showCommentsObserver; - private final MobileLedgerProfile mProfile; - private final Observer dateObserver; - private final Observer descriptionObserver; - private final Observer transactionCommentObserver; - private final Observer hintObserver; - private final Observer focusedAccountObserver; - private final Observer accountCountObserver; - private final Observer editableObserver; - private final Observer currencyPositionObserver; - private final Observer currencyGapObserver; - private final Observer localeObserver; - private final Observer currencyObserver; - private final Observer showCurrencyObserver; - private final Observer commentObserver; - private final Observer amountValidityObserver; - private final NewTransactionRowBinding b; - private 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() - *

- * 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); - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java deleted file mode 100644 index c91d3905..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java +++ /dev/null @@ -1,697 +0,0 @@ -/* - * Copyright © 2021 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.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 - 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 params = new ArrayList<>(); - StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t"); - - if (!TextUtils.isEmpty(accFilter)) { - sb.append(" JOIN transaction_accounts ta") - .append(" ON ta.profile = t.profile") - .append(" AND ta.transaction_id = t.id"); - } - - sb.append(" WHERE t.description=?"); - params.add(description); - - if (!TextUtils.isEmpty(accFilter)) { - sb.append(" AND ta.account_name LIKE '%'||?||'%'"); - params.add(accFilter); - } - - sb.append(" ORDER BY t.year desc, t.month desc, t.day desc LIMIT 1"); - - final String sql = sb.toString(); - debug("description", sql); - debug("description", params.toString()); - - Activity activity = (Activity) recyclerView.getContext(); - // FIXME: handle exceptions? - MLDB.queryInBackground(sql, params.toArray(new String[]{}), new MLDB.CallbackHelper() { - @Override - public void onStart() { - model.incrementBusyCounter(); - } - @Override - public void onDone() { - model.decrementBusyCounter(); - } - @Override - public boolean onRow(@NonNull Cursor cursor) { - final String profileUUID = cursor.getString(0); - final int transactionId = cursor.getInt(1); - activity.runOnUiThread(() -> loadTransactionIntoModel(profileUUID, transactionId)); - return false; // limit 1, by the way - } - @Override - public void onNoRows() { - if (TextUtils.isEmpty(accFilter)) - return; - - debug("description", "Trying transaction search without preferred account filter"); - - final String broaderSql = - "select t.profile, t.id from transactions t where t.description=?" + - " ORDER BY year desc, month desc, day desc LIMIT 1"; - params.remove(1); - debug("description", broaderSql); - debug("description", description); - - activity.runOnUiThread( - () -> Snackbar.make(recyclerView, R.string.ignoring_preferred_account, - Snackbar.LENGTH_INDEFINITE) - .show()); - - MLDB.queryInBackground(broaderSql, new String[]{description}, - new MLDB.CallbackHelper() { - @Override - public void onStart() { - model.incrementBusyCounter(); - } - @Override - public boolean onRow(@NonNull Cursor cursor) { - final String profileUUID = cursor.getString(0); - final int transactionId = cursor.getInt(1); - activity.runOnUiThread( - () -> loadTransactionIntoModel(profileUUID, transactionId)); - return false; - } - @Override - public void onDone() { - model.decrementBusyCounter(); - } - }); - } - }); - } - private void loadTransactionIntoModel(String profileUUID, int transactionId) { - LedgerTransaction tr; - MobileLedgerProfile profile = Data.getProfile(profileUUID); - if (profile == null) - throw new RuntimeException(String.format( - "Unable to find profile %s, which is supposed to contain transaction %d", - profileUUID, transactionId)); - - tr = profile.loadTransaction(transactionId); - List accounts = tr.getAccounts(); - NewTransactionModel.Item firstNegative = null; - NewTransactionModel.Item firstPositive = null; - int singleNegativeIndex = -1; - int singlePositiveIndex = -1; - int negativeCount = 0; - for (int i = 0; i < accounts.size(); i++) { - LedgerTransactionAccount acc = accounts.get(i); - NewTransactionModel.Item item; - if (model.getAccountCount() < i + 1) { - model.addAccount(acc); - notifyItemInserted(i + 1); - } - item = model.getItem(i + 1); - - item.getAccount() - .setAccountName(acc.getAccountName()); - item.setComment(acc.getComment()); - if (acc.isAmountSet()) { - item.getAccount() - .setAmount(acc.getAmount()); - if (acc.getAmount() < 0) { - if (firstNegative == null) { - firstNegative = item; - singleNegativeIndex = i; - } - else - singleNegativeIndex = -1; - } - else { - if (firstPositive == null) { - firstPositive = item; - singlePositiveIndex = i; - } - else - singlePositiveIndex = -1; - } - } - else - item.getAccount() - .resetAmount(); - notifyItemChanged(i + 1); - } - - if (singleNegativeIndex != -1) { - firstNegative.getAccount() - .resetAmount(); - model.moveItemLast(singleNegativeIndex); - } - else if (singlePositiveIndex != -1) { - firstPositive.getAccount() - .resetAmount(); - model.moveItemLast(singlePositiveIndex); - } - - checkTransactionSubmittable(); - model.setFocusedItem(1); - } - public void toggleAllEditing(boolean editable) { - // item 0 is the header - for (int i = 0; i <= model.getAccountCount(); i++) { - model.getItem(i) - .setEditable(editable); - notifyItemChanged(i); - // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)? - } - } - void reset() { - int presentItemCount = model.getAccountCount(); - model.reset(); - notifyItemChanged(0); // header changed - notifyItemRangeChanged(1, 2); // the two empty rows - if (presentItemCount > 2) - notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone - } - void updateFocusedItem(int position) { - model.updateFocusedItem(position); - } - void noteFocusIsOnAccount(int position) { - model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account); - } - 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 emptyRows = new ArrayList<>(); - - try { - if ((descriptionText == null) || descriptionText.trim() - .isEmpty()) - { - Logger.debug("submittable", "Transaction not submittable: missing description"); - submittable = false; - } - - for (int i = 0; i < model.items.size(); i++) { - NewTransactionModel.Item item = model.items.get(i); - - LedgerTransactionAccount acc = item.getAccount(); - String acc_name = acc.getAccountName() - .trim(); - String currName = acc.getCurrency(); - - itemsForCurrency.add(currName, item); - - if (acc_name.isEmpty()) { - itemsWithEmptyAccountForCurrency.add(currName, item); - - if (acc.isAmountSet()) { - // 2) each amount has account name - Logger.debug("submittable", String.format( - "Transaction not submittable: row %d has no account name, but" + - " has" + " amount %1.2f", i + 1, acc.getAmount())); - submittable = false; - } - else { - emptyRowsForCurrency.add(currName, item); - } - } - else { - accounts++; - itemsWithAccountForCurrency.add(currName, item); - } - - if (!acc.isAmountValid()) { - Logger.debug("submittable", - String.format("Not submittable: row %d has an invalid amount", i + 1)); - submittable = false; - } - else if (acc.isAmountSet()) { - itemsWithAmountForCurrency.add(currName, item); - balance.add(currName, acc.getAmount()); - } - else { - itemsWithEmptyAmountForCurrency.add(currName, item); - - if (!acc_name.isEmpty()) - itemsWithAccountAndEmptyAmountForCurrency.add(currName, item); - } - } - - // 1) has at least two account names - if (accounts < 2) { - if (accounts == 0) - Logger.debug("submittable", - "Transaction not submittable: no account " + "names"); - else if (accounts == 1) - Logger.debug("submittable", - "Transaction not submittable: only one account name"); - else - Logger.debug("submittable", - String.format("Transaction not submittable: only %d account names", - accounts)); - submittable = false; - } - - // 3) for each commodity: - // 3a) amount must balance to 0, or - // 3b) there must be exactly one empty amount (with account) - for (String balCurrency : itemsForCurrency.currencies()) { - float currencyBalance = balance.get(balCurrency); - if (Misc.isZero(currencyBalance)) { - // remove hints from all amount inputs in that currency - for (NewTransactionModel.Item item : model.items) { - if (Currency.equal(item.getCurrency(), balCurrency)) - item.setAmountHint(null); - } - } - else { - List list = - itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency); - int balanceReceiversCount = list.size(); - if (balanceReceiversCount != 1) { - if (BuildConfig.DEBUG) { - if (balanceReceiversCount == 0) - Logger.debug("submittable", String.format( - "Transaction not submittable [%s]: non-zero balance " + - "with no empty amounts with accounts", balCurrency)); - else - Logger.debug("submittable", String.format( - "Transaction not submittable [%s]: non-zero balance " + - "with multiple empty amounts with accounts", balCurrency)); - } - submittable = false; - } - - List emptyAmountList = - itemsWithEmptyAmountForCurrency.getList(balCurrency); - - // suggest off-balance amount to a row and remove hints on other rows - NewTransactionModel.Item receiver = null; - if (!list.isEmpty()) - receiver = list.get(0); - else if (!emptyAmountList.isEmpty()) - receiver = emptyAmountList.get(0); - - for (NewTransactionModel.Item item : model.items) { - if (!Currency.equal(item.getCurrency(), balCurrency)) - continue; - - if (item.equals(receiver)) { - if (BuildConfig.DEBUG) - Logger.debug("submittable", - String.format("Setting amount hint to %1.2f [%s]", - -currencyBalance, balCurrency)); - item.setAmountHint(String.format("%1.2f", -currencyBalance)); - } - else { - if (BuildConfig.DEBUG) - Logger.debug("submittable", - String.format("Resetting hint of '%s' [%s]", - (item.getAccount() == null) ? "" : item.getAccount() - .getAccountName(), - balCurrency)); - item.setAmountHint(null); - } - } - } - } - - // 5) a row with an empty account name or empty amount is guaranteed to exist for - // each commodity - for (String balCurrency : balance.currencies()) { - int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency); - int currRows = itemsForCurrency.size(balCurrency); - int currAccounts = itemsWithAccountForCurrency.size(balCurrency); - int currAmounts = itemsWithAmountForCurrency.size(balCurrency); - if ((currEmptyRows == 0) && - ((currRows == currAccounts) || (currRows == currAmounts))) - { - // perhaps there already is an unused empty row for another currency that - // is not used? -// boolean foundIt = false; -// for (Item item : emptyRows) { -// Currency itemCurrency = item.getCurrency(); -// String itemCurrencyName = -// (itemCurrency == null) ? "" : itemCurrency.getName(); -// if (Misc.isZero(balance.get(itemCurrencyName))) { -// item.setCurrency(Currency.loadByName(balCurrency)); -// item.setAmountHint( -// String.format("%1.2f", -balance.get(balCurrency))); -// foundIt = true; -// break; -// } -// } -// -// if (!foundIt) - addRow(balCurrency); - } - } - - // drop extra empty rows, not needed - for (String currName : emptyRowsForCurrency.currencies()) { - List emptyItems = emptyRowsForCurrency.getList(currName); - while ((model.items.size() > 2) && (emptyItems.size() > 1)) { - NewTransactionModel.Item item = emptyItems.get(1); - emptyItems.remove(1); - model.removeRow(item, this); - } - - // unused currency, remove last item (which is also an empty one) - if ((model.items.size() > 2) && (emptyItems.size() == 1)) { - List currItems = itemsForCurrency.getList(currName); - - if (currItems.size() == 1) { - NewTransactionModel.Item item = emptyItems.get(0); - model.removeRow(item, this); - } - } - } - - // 6) at least two rows need to be present in the ledger - while (model.items.size() < 2) - addRow(); - - - debug("submittable", submittable ? "YES" : "NO"); - model.isSubmittable.setValue(submittable); - - if (BuildConfig.DEBUG) { - debug("submittable", "== Dump of all items"); - for (int i = 0; i < model.items.size(); i++) { - NewTransactionModel.Item item = model.items.get(i); - LedgerTransactionAccount acc = item.getAccount(); - debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i, - acc.isAmountSet() ? acc.getAmount() : 0, - item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(), - acc.getAccountName(), acc.getComment())); - } - } - } - catch (NumberFormatException e) { - debug("submittable", "NO (because of NumberFormatException)"); - model.isSubmittable.setValue(false); - } - catch (Exception e) { - e.printStackTrace(); - debug("submittable", "NO (because of an Exception)"); - model.isSubmittable.setValue(false); - } - } - - private static class BalanceForCurrency { - private final HashMap hashMap = new HashMap<>(); - float get(String currencyName) { - Float f = hashMap.get(currencyName); - if (f == null) { - f = 0f; - hashMap.put(currencyName, f); - } - return f; - } - void add(String currencyName, float amount) { - hashMap.put(currencyName, get(currencyName) + amount); - } - Set currencies() { - return hashMap.keySet(); - } - boolean containsCurrency(String currencyName) { - return hashMap.containsKey(currencyName); - } - } - - private static class ItemsForCurrency { - private final HashMap> hashMap = new HashMap<>(); - @NonNull - List getList(@Nullable String currencyName) { - List list = hashMap.get(currencyName); - if (list == null) { - list = new ArrayList<>(); - hashMap.put(currencyName, list); - } - return list; - } - void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) { - getList(currencyName).add(item); - } - int size(@Nullable String currencyName) { - return this.getList(currencyName) - .size(); - } - Set currencies() { - return hashMap.keySet(); - } - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java deleted file mode 100644 index b647eb85..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionModel.java +++ /dev/null @@ -1,470 +0,0 @@ -/* - * Copyright © 2021 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.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 showCurrency = new MutableLiveData<>(false); - final ArrayList items = new ArrayList<>(); - final MutableLiveData isSubmittable = new MutableLiveData<>(false); - final MutableLiveData showComments = new MutableLiveData<>(true); - private final Item header = new Item(this, ""); - private final Item trailer = new Item(this); - private final MutableLiveData focusedItem = new MutableLiveData<>(0); - private final MutableLiveData accountCount = new MutableLiveData<>(0); - private final MutableLiveData simulateSave = new MutableLiveData<>(false); - private final AtomicInteger busyCounter = new AtomicInteger(0); - private final MutableLiveData busyFlag = new MutableLiveData<>(false); - private final Observer profileObserver = profile -> { - showCurrency.postValue(profile.getShowCommodityByDefault()); - showComments.postValue(profile.getShowCommentsByDefault()); - }; - private boolean observingDataProfile; - void observeShowComments(LifecycleOwner owner, Observer observer) { - showComments.observe(owner, observer); - } - void observeBusyFlag(@NonNull LifecycleOwner owner, Observer 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 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 isSubmittable() { - return this.isSubmittable; - } - void reset() { - header.date.setValue(null); - header.description.setValue(null); - header.comment.setValue(null); - items.clear(); - items.add(new Item(this, new LedgerTransactionAccount(""))); - items.add(new Item(this, new LedgerTransactionAccount(""))); - focusedItem.setValue(0); - } - void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.observe(owner, observer); - } - void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer observer) { - this.focusedItem.removeObserver(observer); - } - void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.accountCount.observe(owner, observer); - } - void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer observer) { - this.accountCount.removeObserver(observer); - } - int getFocusedItem() { return focusedItem.getValue(); } - void setFocusedItem(int position) { - focusedItem.setValue(position); - } - int addAccount(LedgerTransactionAccount acc) { - items.add(new Item(this, acc)); - accountCount.setValue(getAccountCount()); - return items.size(); - } - 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 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 date = new MutableLiveData<>(); - private final MutableLiveData description = new MutableLiveData<>(); - private final MutableLiveData amountHint = new MutableLiveData<>(null); - private final NewTransactionModel model; - private final MutableLiveData editable = new MutableLiveData<>(true); - private final MutableLiveData comment = new MutableLiveData<>(null); - private final MutableLiveData currency = new MutableLiveData<>(null); - private final MutableLiveData amountValid = new MutableLiveData<>(true); - private LedgerTransactionAccount account; - private FocusedElement focusedElement = FocusedElement.Account; - private boolean amountHintIsSet = false; - Item(NewTransactionModel model) { - this.model = model; - type = ItemType.bottomFiller; - editable.setValue(false); - } - Item(NewTransactionModel model, String description) { - this.model = model; - this.type = ItemType.generalData; - this.description.setValue(description); - this.editable.setValue(true); - } - Item(NewTransactionModel model, LedgerTransactionAccount account) { - this.model = model; - this.type = ItemType.transactionRow; - this.account = account; - String currName = account.getCurrency(); - Currency curr = null; - if ((currName != null) && !currName.isEmpty()) - curr = Currency.loadByName(currName); - this.currency.setValue(curr); - this.editable.setValue(true); - } - FocusedElement getFocusedElement() { - return focusedElement; - } - 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 observer) { - this.amountHint.observe(owner, observer); - } - void stopObservingAmountHint( - @NonNull androidx.lifecycle.Observer 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 observer) { - this.date.observe(owner, observer); - } - void stopObservingDate(@NonNull androidx.lifecycle.Observer observer) { - this.date.removeObserver(observer); - } - public String getDescription() { - ensureType(ItemType.generalData); - return description.getValue(); - } - public void setDescription(String description) { - ensureType(ItemType.generalData); - this.description.setValue(description); - } - void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, - @NonNull androidx.lifecycle.Observer observer) { - this.description.observe(owner, observer); - } - void stopObservingDescription( - @NonNull androidx.lifecycle.Observer 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 observer) { - ensureType(ItemType.generalData); - this.comment.observe(owner, observer); - } - void stopObservingTransactionComment(@NonNull Observer 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 observer) { - editable.observe(activity, observer); - } - void stopObservingEditableFlag(Observer observer) { - editable.removeObserver(observer); - } - void observeComment(NewTransactionActivity activity, Observer observer) { - comment.observe(activity, observer); - } - void stopObservingComment(Observer 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 observer) { - currency.observe(activity, observer); - } - void stopObservingCurrency(Observer 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 observer) { - amountValid.observe(activity, observer); - } - void stopObservingAmountValidity(Observer observer) { - amountValid.removeObserver(observer); - } - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/PatternsActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/PatternsActivity.java deleted file mode 100644 index cc057f42..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/PatternsActivity.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2021 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.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 diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java deleted file mode 100644 index 1bc17ec4..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/ProfileDetailActivity.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright © 2019 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.ui.activity; - -import android.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 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); - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java new file mode 100644 index 00000000..413365db --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionActivity.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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(); + } + +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java new file mode 100644 index 00000000..aa44c72f --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java @@ -0,0 +1,377 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 scanQrLauncher = + registerForActivityResult(new ActivityResultContract() { + @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. + *

+ * See the Android Training lesson Communicating with Other Fragments for more information. + */ + public interface OnNewTransactionFragmentInteractionListener { + void onTransactionSave(LedgerTransaction tr); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java new file mode 100644 index 00000000..3bf09d27 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemHolder.java @@ -0,0 +1,710 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 showCommentsObserver; + private final MobileLedgerProfile mProfile; + private final Observer dateObserver; + private final Observer descriptionObserver; + private final Observer transactionCommentObserver; + private final Observer hintObserver; + private final Observer focusedAccountObserver; + private final Observer accountCountObserver; + private final Observer editableObserver; + private final Observer currencyPositionObserver; + private final Observer currencyGapObserver; + private final Observer localeObserver; + private final Observer currencyObserver; + private final Observer showCurrencyObserver; + private final Observer commentObserver; + private final Observer amountValidityObserver; + private final NewTransactionRowBinding b; + private 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() + *

+ * 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); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java new file mode 100644 index 00000000..29b4e9aa --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionItemsAdapter.java @@ -0,0 +1,697 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 + 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 params = new ArrayList<>(); + StringBuilder sb = new StringBuilder("select t.profile, t.id from transactions t"); + + if (!TextUtils.isEmpty(accFilter)) { + sb.append(" JOIN transaction_accounts ta") + .append(" ON ta.profile = t.profile") + .append(" AND ta.transaction_id = t.id"); + } + + sb.append(" WHERE t.description=?"); + params.add(description); + + if (!TextUtils.isEmpty(accFilter)) { + sb.append(" AND ta.account_name LIKE '%'||?||'%'"); + params.add(accFilter); + } + + sb.append(" ORDER BY t.year desc, t.month desc, t.day desc LIMIT 1"); + + final String sql = sb.toString(); + debug("description", sql); + debug("description", params.toString()); + + Activity activity = (Activity) recyclerView.getContext(); + // FIXME: handle exceptions? + MLDB.queryInBackground(sql, params.toArray(new String[]{}), new MLDB.CallbackHelper() { + @Override + public void onStart() { + model.incrementBusyCounter(); + } + @Override + public void onDone() { + model.decrementBusyCounter(); + } + @Override + public boolean onRow(@NonNull Cursor cursor) { + final String profileUUID = cursor.getString(0); + final int transactionId = cursor.getInt(1); + activity.runOnUiThread(() -> loadTransactionIntoModel(profileUUID, transactionId)); + return false; // limit 1, by the way + } + @Override + public void onNoRows() { + if (TextUtils.isEmpty(accFilter)) + return; + + debug("description", "Trying transaction search without preferred account filter"); + + final String broaderSql = + "select t.profile, t.id from transactions t where t.description=?" + + " ORDER BY year desc, month desc, day desc LIMIT 1"; + params.remove(1); + debug("description", broaderSql); + debug("description", description); + + activity.runOnUiThread( + () -> Snackbar.make(recyclerView, R.string.ignoring_preferred_account, + Snackbar.LENGTH_INDEFINITE) + .show()); + + MLDB.queryInBackground(broaderSql, new String[]{description}, + new MLDB.CallbackHelper() { + @Override + public void onStart() { + model.incrementBusyCounter(); + } + @Override + public boolean onRow(@NonNull Cursor cursor) { + final String profileUUID = cursor.getString(0); + final int transactionId = cursor.getInt(1); + activity.runOnUiThread( + () -> loadTransactionIntoModel(profileUUID, transactionId)); + return false; + } + @Override + public void onDone() { + model.decrementBusyCounter(); + } + }); + } + }); + } + private void loadTransactionIntoModel(String profileUUID, int transactionId) { + LedgerTransaction tr; + MobileLedgerProfile profile = Data.getProfile(profileUUID); + if (profile == null) + throw new RuntimeException(String.format( + "Unable to find profile %s, which is supposed to contain transaction %d", + profileUUID, transactionId)); + + tr = profile.loadTransaction(transactionId); + List accounts = tr.getAccounts(); + NewTransactionModel.Item firstNegative = null; + NewTransactionModel.Item firstPositive = null; + int singleNegativeIndex = -1; + int singlePositiveIndex = -1; + int negativeCount = 0; + for (int i = 0; i < accounts.size(); i++) { + LedgerTransactionAccount acc = accounts.get(i); + NewTransactionModel.Item item; + if (model.getAccountCount() < i + 1) { + model.addAccount(acc); + notifyItemInserted(i + 1); + } + item = model.getItem(i + 1); + + item.getAccount() + .setAccountName(acc.getAccountName()); + item.setComment(acc.getComment()); + if (acc.isAmountSet()) { + item.getAccount() + .setAmount(acc.getAmount()); + if (acc.getAmount() < 0) { + if (firstNegative == null) { + firstNegative = item; + singleNegativeIndex = i; + } + else + singleNegativeIndex = -1; + } + else { + if (firstPositive == null) { + firstPositive = item; + singlePositiveIndex = i; + } + else + singlePositiveIndex = -1; + } + } + else + item.getAccount() + .resetAmount(); + notifyItemChanged(i + 1); + } + + if (singleNegativeIndex != -1) { + firstNegative.getAccount() + .resetAmount(); + model.moveItemLast(singleNegativeIndex); + } + else if (singlePositiveIndex != -1) { + firstPositive.getAccount() + .resetAmount(); + model.moveItemLast(singlePositiveIndex); + } + + checkTransactionSubmittable(); + model.setFocusedItem(1); + } + public void toggleAllEditing(boolean editable) { + // item 0 is the header + for (int i = 0; i <= model.getAccountCount(); i++) { + model.getItem(i) + .setEditable(editable); + notifyItemChanged(i); + // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)? + } + } + void reset() { + int presentItemCount = model.getAccountCount(); + model.reset(); + notifyItemChanged(0); // header changed + notifyItemRangeChanged(1, 2); // the two empty rows + if (presentItemCount > 2) + notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone + } + void updateFocusedItem(int position) { + model.updateFocusedItem(position); + } + void noteFocusIsOnAccount(int position) { + model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account); + } + 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 emptyRows = new ArrayList<>(); + + try { + if ((descriptionText == null) || descriptionText.trim() + .isEmpty()) + { + Logger.debug("submittable", "Transaction not submittable: missing description"); + submittable = false; + } + + for (int i = 0; i < model.items.size(); i++) { + NewTransactionModel.Item item = model.items.get(i); + + LedgerTransactionAccount acc = item.getAccount(); + String acc_name = acc.getAccountName() + .trim(); + String currName = acc.getCurrency(); + + itemsForCurrency.add(currName, item); + + if (acc_name.isEmpty()) { + itemsWithEmptyAccountForCurrency.add(currName, item); + + if (acc.isAmountSet()) { + // 2) each amount has account name + Logger.debug("submittable", String.format( + "Transaction not submittable: row %d has no account name, but" + + " has" + " amount %1.2f", i + 1, acc.getAmount())); + submittable = false; + } + else { + emptyRowsForCurrency.add(currName, item); + } + } + else { + accounts++; + itemsWithAccountForCurrency.add(currName, item); + } + + if (!acc.isAmountValid()) { + Logger.debug("submittable", + String.format("Not submittable: row %d has an invalid amount", i + 1)); + submittable = false; + } + else if (acc.isAmountSet()) { + itemsWithAmountForCurrency.add(currName, item); + balance.add(currName, acc.getAmount()); + } + else { + itemsWithEmptyAmountForCurrency.add(currName, item); + + if (!acc_name.isEmpty()) + itemsWithAccountAndEmptyAmountForCurrency.add(currName, item); + } + } + + // 1) has at least two account names + if (accounts < 2) { + if (accounts == 0) + Logger.debug("submittable", + "Transaction not submittable: no account " + "names"); + else if (accounts == 1) + Logger.debug("submittable", + "Transaction not submittable: only one account name"); + else + Logger.debug("submittable", + String.format("Transaction not submittable: only %d account names", + accounts)); + submittable = false; + } + + // 3) for each commodity: + // 3a) amount must balance to 0, or + // 3b) there must be exactly one empty amount (with account) + for (String balCurrency : itemsForCurrency.currencies()) { + float currencyBalance = balance.get(balCurrency); + if (Misc.isZero(currencyBalance)) { + // remove hints from all amount inputs in that currency + for (NewTransactionModel.Item item : model.items) { + if (Currency.equal(item.getCurrency(), balCurrency)) + item.setAmountHint(null); + } + } + else { + List list = + itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency); + int balanceReceiversCount = list.size(); + if (balanceReceiversCount != 1) { + if (BuildConfig.DEBUG) { + if (balanceReceiversCount == 0) + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with no empty amounts with accounts", balCurrency)); + else + Logger.debug("submittable", String.format( + "Transaction not submittable [%s]: non-zero balance " + + "with multiple empty amounts with accounts", balCurrency)); + } + submittable = false; + } + + List emptyAmountList = + itemsWithEmptyAmountForCurrency.getList(balCurrency); + + // suggest off-balance amount to a row and remove hints on other rows + NewTransactionModel.Item receiver = null; + if (!list.isEmpty()) + receiver = list.get(0); + else if (!emptyAmountList.isEmpty()) + receiver = emptyAmountList.get(0); + + for (NewTransactionModel.Item item : model.items) { + if (!Currency.equal(item.getCurrency(), balCurrency)) + continue; + + if (item.equals(receiver)) { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format("Setting amount hint to %1.2f [%s]", + -currencyBalance, balCurrency)); + item.setAmountHint(String.format("%1.2f", -currencyBalance)); + } + else { + if (BuildConfig.DEBUG) + Logger.debug("submittable", + String.format("Resetting hint of '%s' [%s]", + (item.getAccount() == null) ? "" : item.getAccount() + .getAccountName(), + balCurrency)); + item.setAmountHint(null); + } + } + } + } + + // 5) a row with an empty account name or empty amount is guaranteed to exist for + // each commodity + for (String balCurrency : balance.currencies()) { + int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency); + int currRows = itemsForCurrency.size(balCurrency); + int currAccounts = itemsWithAccountForCurrency.size(balCurrency); + int currAmounts = itemsWithAmountForCurrency.size(balCurrency); + if ((currEmptyRows == 0) && + ((currRows == currAccounts) || (currRows == currAmounts))) + { + // perhaps there already is an unused empty row for another currency that + // is not used? +// boolean foundIt = false; +// for (Item item : emptyRows) { +// Currency itemCurrency = item.getCurrency(); +// String itemCurrencyName = +// (itemCurrency == null) ? "" : itemCurrency.getName(); +// if (Misc.isZero(balance.get(itemCurrencyName))) { +// item.setCurrency(Currency.loadByName(balCurrency)); +// item.setAmountHint( +// String.format("%1.2f", -balance.get(balCurrency))); +// foundIt = true; +// break; +// } +// } +// +// if (!foundIt) + addRow(balCurrency); + } + } + + // drop extra empty rows, not needed + for (String currName : emptyRowsForCurrency.currencies()) { + List emptyItems = emptyRowsForCurrency.getList(currName); + while ((model.items.size() > 2) && (emptyItems.size() > 1)) { + NewTransactionModel.Item item = emptyItems.get(1); + emptyItems.remove(1); + model.removeRow(item, this); + } + + // unused currency, remove last item (which is also an empty one) + if ((model.items.size() > 2) && (emptyItems.size() == 1)) { + List currItems = itemsForCurrency.getList(currName); + + if (currItems.size() == 1) { + NewTransactionModel.Item item = emptyItems.get(0); + model.removeRow(item, this); + } + } + } + + // 6) at least two rows need to be present in the ledger + while (model.items.size() < 2) + addRow(); + + + debug("submittable", submittable ? "YES" : "NO"); + model.isSubmittable.setValue(submittable); + + if (BuildConfig.DEBUG) { + debug("submittable", "== Dump of all items"); + for (int i = 0; i < model.items.size(); i++) { + NewTransactionModel.Item item = model.items.get(i); + LedgerTransactionAccount acc = item.getAccount(); + debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i, + acc.isAmountSet() ? acc.getAmount() : 0, + item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(), + acc.getAccountName(), acc.getComment())); + } + } + } + catch (NumberFormatException e) { + debug("submittable", "NO (because of NumberFormatException)"); + model.isSubmittable.setValue(false); + } + catch (Exception e) { + e.printStackTrace(); + debug("submittable", "NO (because of an Exception)"); + model.isSubmittable.setValue(false); + } + } + + private static class BalanceForCurrency { + private final HashMap hashMap = new HashMap<>(); + float get(String currencyName) { + Float f = hashMap.get(currencyName); + if (f == null) { + f = 0f; + hashMap.put(currencyName, f); + } + return f; + } + void add(String currencyName, float amount) { + hashMap.put(currencyName, get(currencyName) + amount); + } + Set currencies() { + return hashMap.keySet(); + } + boolean containsCurrency(String currencyName) { + return hashMap.containsKey(currencyName); + } + } + + private static class ItemsForCurrency { + private final HashMap> hashMap = new HashMap<>(); + @NonNull + List getList(@Nullable String currencyName) { + List list = hashMap.get(currencyName); + if (list == null) { + list = new ArrayList<>(); + hashMap.put(currencyName, list); + } + return list; + } + void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) { + getList(currencyName).add(item); + } + int size(@Nullable String currencyName) { + return this.getList(currencyName) + .size(); + } + Set currencies() { + return hashMap.keySet(); + } + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java new file mode 100644 index 00000000..b96b917a --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionModel.java @@ -0,0 +1,470 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 showCurrency = new MutableLiveData<>(false); + final ArrayList items = new ArrayList<>(); + final MutableLiveData isSubmittable = new MutableLiveData<>(false); + final MutableLiveData showComments = new MutableLiveData<>(true); + private final Item header = new Item(this, ""); + private final Item trailer = new Item(this); + private final MutableLiveData focusedItem = new MutableLiveData<>(0); + private final MutableLiveData accountCount = new MutableLiveData<>(0); + private final MutableLiveData simulateSave = new MutableLiveData<>(false); + private final AtomicInteger busyCounter = new AtomicInteger(0); + private final MutableLiveData busyFlag = new MutableLiveData<>(false); + private final Observer profileObserver = profile -> { + showCurrency.postValue(profile.getShowCommodityByDefault()); + showComments.postValue(profile.getShowCommentsByDefault()); + }; + private boolean observingDataProfile; + void observeShowComments(LifecycleOwner owner, Observer observer) { + showComments.observe(owner, observer); + } + void observeBusyFlag(@NonNull LifecycleOwner owner, Observer 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 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 isSubmittable() { + return this.isSubmittable; + } + void reset() { + header.date.setValue(null); + header.description.setValue(null); + header.comment.setValue(null); + items.clear(); + items.add(new Item(this, new LedgerTransactionAccount(""))); + items.add(new Item(this, new LedgerTransactionAccount(""))); + focusedItem.setValue(0); + } + void observeFocusedItem(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, + @NonNull androidx.lifecycle.Observer observer) { + this.focusedItem.observe(owner, observer); + } + void stopObservingFocusedItem(@NonNull androidx.lifecycle.Observer observer) { + this.focusedItem.removeObserver(observer); + } + void observeAccountCount(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, + @NonNull androidx.lifecycle.Observer observer) { + this.accountCount.observe(owner, observer); + } + void stopObservingAccountCount(@NonNull androidx.lifecycle.Observer observer) { + this.accountCount.removeObserver(observer); + } + int getFocusedItem() { return focusedItem.getValue(); } + void setFocusedItem(int position) { + focusedItem.setValue(position); + } + int addAccount(LedgerTransactionAccount acc) { + items.add(new Item(this, acc)); + accountCount.setValue(getAccountCount()); + return items.size(); + } + 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 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 date = new MutableLiveData<>(); + private final MutableLiveData description = new MutableLiveData<>(); + private final MutableLiveData amountHint = new MutableLiveData<>(null); + private final NewTransactionModel model; + private final MutableLiveData editable = new MutableLiveData<>(true); + private final MutableLiveData comment = new MutableLiveData<>(null); + private final MutableLiveData currency = new MutableLiveData<>(null); + private final MutableLiveData amountValid = new MutableLiveData<>(true); + private LedgerTransactionAccount account; + private FocusedElement focusedElement = FocusedElement.Account; + private boolean amountHintIsSet = false; + Item(NewTransactionModel model) { + this.model = model; + type = ItemType.bottomFiller; + editable.setValue(false); + } + Item(NewTransactionModel model, String description) { + this.model = model; + this.type = ItemType.generalData; + this.description.setValue(description); + this.editable.setValue(true); + } + Item(NewTransactionModel model, LedgerTransactionAccount account) { + this.model = model; + this.type = ItemType.transactionRow; + this.account = account; + String currName = account.getCurrency(); + Currency curr = null; + if ((currName != null) && !currName.isEmpty()) + curr = Currency.loadByName(currName); + this.currency.setValue(curr); + this.editable.setValue(true); + } + FocusedElement getFocusedElement() { + return focusedElement; + } + 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 observer) { + this.amountHint.observe(owner, observer); + } + void stopObservingAmountHint( + @NonNull androidx.lifecycle.Observer 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 observer) { + this.date.observe(owner, observer); + } + void stopObservingDate(@NonNull androidx.lifecycle.Observer observer) { + this.date.removeObserver(observer); + } + public String getDescription() { + ensureType(ItemType.generalData); + return description.getValue(); + } + public void setDescription(String description) { + ensureType(ItemType.generalData); + this.description.setValue(description); + } + void observeDescription(@NonNull @NotNull androidx.lifecycle.LifecycleOwner owner, + @NonNull androidx.lifecycle.Observer observer) { + this.description.observe(owner, observer); + } + void stopObservingDescription( + @NonNull androidx.lifecycle.Observer 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 observer) { + ensureType(ItemType.generalData); + this.comment.observe(owner, observer); + } + void stopObservingTransactionComment(@NonNull Observer 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 observer) { + editable.observe(activity, observer); + } + void stopObservingEditableFlag(Observer observer) { + editable.removeObserver(observer); + } + void observeComment(NewTransactionActivity activity, Observer observer) { + comment.observe(activity, observer); + } + void stopObservingComment(Observer 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 observer) { + currency.observe(activity, observer); + } + void stopObservingCurrency(Observer 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 observer) { + amountValid.observe(activity, observer); + } + void stopObservingAmountValidity(Observer observer) { + amountValid.removeObserver(observer); + } + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/patterns/PatternsActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/patterns/PatternsActivity.java new file mode 100644 index 00000000..8e57a2e7 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/patterns/PatternsActivity.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java new file mode 100644 index 00000000..8afdefc1 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailActivity.java @@ -0,0 +1,132 @@ +/* + * Copyright © 2021 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.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 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); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java index 36e39af2..e531809a 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java @@ -1,5 +1,5 @@ /* - * 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 @@ -52,7 +52,6 @@ import net.ktnx.mobileledger.model.Data; 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; diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java index cef8d8fb..21984333 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java @@ -1,5 +1,5 @@ /* - * 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 @@ -39,7 +39,6 @@ import androidx.recyclerview.widget.RecyclerView; 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; diff --git a/app/src/main/res/layout/activity_new_transaction.xml b/app/src/main/res/layout/activity_new_transaction.xml index 706794f9..2461b3be 100644 --- a/app/src/main/res/layout/activity_new_transaction.xml +++ b/app/src/main/res/layout/activity_new_transaction.xml @@ -1,5 +1,5 @@