2 * Copyright © 2019 Damyan Ivanov.
3 * This file is part of MoLe.
4 * MoLe is free software: you can distribute it and/or modify it
5 * under the term of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your opinion), any later version.
9 * MoLe is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License terms for details.
14 * You should have received a copy of the GNU General Public License
15 * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
18 package net.ktnx.mobileledger.ui.activity;
20 import android.annotation.SuppressLint;
21 import android.database.Cursor;
22 import android.view.LayoutInflater;
23 import android.view.ViewGroup;
24 import android.widget.LinearLayout;
26 import androidx.annotation.NonNull;
27 import androidx.annotation.Nullable;
28 import androidx.recyclerview.widget.ItemTouchHelper;
29 import androidx.recyclerview.widget.RecyclerView;
31 import net.ktnx.mobileledger.App;
32 import net.ktnx.mobileledger.BuildConfig;
33 import net.ktnx.mobileledger.R;
34 import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
35 import net.ktnx.mobileledger.model.Currency;
36 import net.ktnx.mobileledger.model.Data;
37 import net.ktnx.mobileledger.model.LedgerTransaction;
38 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
39 import net.ktnx.mobileledger.model.MobileLedgerProfile;
40 import net.ktnx.mobileledger.utils.Logger;
41 import net.ktnx.mobileledger.utils.Misc;
43 import java.util.ArrayList;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Locale;
49 import static net.ktnx.mobileledger.utils.Logger.debug;
51 class NewTransactionItemsAdapter extends RecyclerView.Adapter<NewTransactionItemHolder>
52 implements DescriptionSelectedCallback {
53 NewTransactionModel model;
54 private MobileLedgerProfile mProfile;
55 private ItemTouchHelper touchHelper;
56 private RecyclerView recyclerView;
57 private int checkHoldCounter = 0;
58 NewTransactionItemsAdapter(NewTransactionModel viewModel, MobileLedgerProfile profile) {
62 int size = model.getAccountCount();
64 Logger.debug("new-transaction",
65 String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
69 NewTransactionItemsAdapter adapter = this;
71 touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
73 public boolean isLongPressDragEnabled() {
77 public boolean canDropOver(@NonNull RecyclerView recyclerView,
78 @NonNull RecyclerView.ViewHolder current,
79 @NonNull RecyclerView.ViewHolder target) {
80 final int adapterPosition = target.getAdapterPosition();
82 // first and last items are immovable
83 if (adapterPosition == 0)
85 if (adapterPosition == adapter.getItemCount() - 1)
88 return super.canDropOver(recyclerView, current, target);
91 public int getMovementFlags(@NonNull RecyclerView recyclerView,
92 @NonNull RecyclerView.ViewHolder viewHolder) {
93 int flags = makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END);
94 // the top (date and description) and the bottom (padding) items are always there
95 final int adapterPosition = viewHolder.getAdapterPosition();
96 if ((adapterPosition > 0) && (adapterPosition < adapter.getItemCount() - 1)) {
97 flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
98 ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
99 makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
100 ItemTouchHelper.START | ItemTouchHelper.END);
106 public boolean onMove(@NonNull RecyclerView recyclerView,
107 @NonNull RecyclerView.ViewHolder viewHolder,
108 @NonNull RecyclerView.ViewHolder target) {
110 model.swapItems(viewHolder.getAdapterPosition(), target.getAdapterPosition());
111 notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
115 public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
116 int pos = viewHolder.getAdapterPosition();
117 viewModel.removeItem(pos - 1);
118 notifyItemRemoved(pos);
119 viewModel.sendCountNotifications(); // needed after items re-arrangement
120 checkTransactionSubmittable();
124 public void setProfile(MobileLedgerProfile profile) {
130 int addRow(String commodity) {
131 final int newAccountCount = model.addAccount(new LedgerTransactionAccount("", commodity));
132 Logger.debug("new-transaction",
133 String.format(Locale.US, "invoking notifyItemInserted(%d)", newAccountCount));
134 // the header is at position 0
135 notifyItemInserted(newAccountCount);
136 model.sendCountNotifications(); // needed after holders' positions have changed
137 return newAccountCount;
141 public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
142 LinearLayout row = (LinearLayout) LayoutInflater.from(parent.getContext())
143 .inflate(R.layout.new_transaction_row,
146 return new NewTransactionItemHolder(row, this);
149 public void onBindViewHolder(@NonNull NewTransactionItemHolder holder, int position) {
150 Logger.debug("bind", String.format(Locale.US, "Binding item at position %d", position));
151 NewTransactionModel.Item item = model.getItem(position);
152 holder.setData(item);
153 Logger.debug("bind", String.format(Locale.US, "Bound %s item at position %d", item.getType()
158 public int getItemCount() {
159 return model.getAccountCount() + 2;
161 boolean accountListIsEmpty() {
162 for (int i = 0; i < model.getAccountCount(); i++) {
163 LedgerTransactionAccount acc = model.getAccount(i);
164 if (!acc.getAccountName()
167 if (acc.isAmountSet())
174 public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
175 super.onAttachedToRecyclerView(recyclerView);
176 this.recyclerView = recyclerView;
177 touchHelper.attachToRecyclerView(recyclerView);
180 public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
181 touchHelper.attachToRecyclerView(null);
182 super.onDetachedFromRecyclerView(recyclerView);
183 this.recyclerView = null;
185 public void descriptionSelected(String description) {
186 debug("descr selected", description);
187 if (!accountListIsEmpty())
190 String accFilter = mProfile.getPreferredAccountsFilter();
192 ArrayList<String> params = new ArrayList<>();
193 StringBuilder sb = new StringBuilder(
194 "select t.profile, t.id from transactions t where t.description=?");
195 params.add(description);
197 if (accFilter != null) {
198 sb.append(" AND EXISTS (")
199 .append("SELECT 1 FROM transaction_accounts ta ")
200 .append("WHERE ta.profile = t.profile")
201 .append(" AND ta.transaction_id = t.id")
202 .append(" AND UPPER(ta.account_name) LIKE '%'||?||'%')");
203 params.add(accFilter.toUpperCase());
206 sb.append(" ORDER BY date desc limit 1");
208 final String sql = sb.toString();
210 debug("descr", params.toString());
212 try (Cursor c = App.getDatabase()
213 .rawQuery(sql, params.toArray(new String[]{})))
218 String profileUUID = c.getString(0);
219 int transactionId = c.getInt(1);
220 LedgerTransaction tr;
221 MobileLedgerProfile profile = Data.getProfile(profileUUID);
223 throw new RuntimeException(String.format(
224 "Unable to find profile %s, which is supposed to contain " +
225 "transaction %d with description %s", profileUUID, transactionId,
228 tr = profile.loadTransaction(transactionId);
229 ArrayList<LedgerTransactionAccount> accounts = tr.getAccounts();
230 NewTransactionModel.Item firstNegative = null;
231 NewTransactionModel.Item firstPositive = null;
232 int singleNegativeIndex = -1;
233 int singlePositiveIndex = -1;
234 int negativeCount = 0;
235 for (int i = 0; i < accounts.size(); i++) {
236 LedgerTransactionAccount acc = accounts.get(i);
237 NewTransactionModel.Item item;
238 if (model.getAccountCount() < i + 1) {
239 model.addAccount(acc);
240 notifyItemInserted(i + 1);
242 item = model.getItem(i + 1);
245 .setAccountName(acc.getAccountName());
246 if (acc.isAmountSet()) {
248 .setAmount(acc.getAmount());
249 if (acc.getAmount() < 0) {
250 if (firstNegative == null) {
251 firstNegative = item;
252 singleNegativeIndex = i;
255 singleNegativeIndex = -1;
258 if (firstPositive == null) {
259 firstPositive = item;
260 singlePositiveIndex = i;
263 singlePositiveIndex = -1;
269 notifyItemChanged(i + 1);
272 if (singleNegativeIndex != -1) {
273 firstNegative.getAccount()
275 model.moveItemLast(singleNegativeIndex);
277 else if (singlePositiveIndex != -1) {
278 firstPositive.getAccount()
280 model.moveItemLast(singlePositiveIndex);
283 checkTransactionSubmittable();
284 model.setFocusedItem(1);
286 public void toggleAllEditing(boolean editable) {
287 // item 0 is the header
288 for (int i = 0; i <= model.getAccountCount(); i++) {
290 .setEditable(editable);
291 notifyItemChanged(i);
292 // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)?
295 public void reset() {
296 int presentItemCount = model.getAccountCount();
298 notifyItemChanged(0); // header changed
299 notifyItemRangeChanged(1, 2); // the two empty rows
300 if (presentItemCount > 2)
301 notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone
303 public void updateFocusedItem(int position) {
304 model.updateFocusedItem(position);
306 public void noteFocusIsOnAccount(int position) {
307 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account);
309 public void noteFocusIsOnAmount(int position) {
310 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
312 public void noteFocusIsOnComment(int position) {
313 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
315 public void toggleComment(int position) {
316 model.toggleComment(position);
318 private void holdSubmittableChecks() {
321 private void releaseSubmittableChecks() {
322 if (checkHoldCounter == 0)
323 throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
326 void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
327 Currency oldCurrency = item.getCurrency();
328 if (!Currency.equal(newCurrency, oldCurrency)) {
329 holdSubmittableChecks();
331 item.setCurrency(newCurrency);
332 // for (Item i : items) {
333 // if (Currency.equal(i.getCurrency(), oldCurrency))
334 // i.setCurrency(newCurrency);
338 releaseSubmittableChecks();
341 checkTransactionSubmittable();
345 A transaction is submittable if:
347 1) has at least two account names
348 2) each row with amount has account name
349 3) for each commodity:
350 3a) amounts must balance to 0, or
351 3b) there must be exactly one empty amount (with account)
352 4) empty accounts with empty amounts are ignored
354 5) a row with an empty account name or empty amount is guaranteed to exist for each
356 6) at least two rows need to be present in the ledger
359 @SuppressLint("DefaultLocale")
360 void checkTransactionSubmittable() {
361 if (checkHoldCounter > 0)
365 final BalanceForCurrency balance = new BalanceForCurrency();
366 final String descriptionText = model.getDescription();
367 boolean submittable = true;
368 final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
369 final ItemsForCurrency itemsWithEmptyAmountForCurrency =
370 new ItemsForCurrency();
371 final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency =
372 new ItemsForCurrency();
373 final ItemsForCurrency itemsWithEmptyAccountForCurrency =
374 new ItemsForCurrency();
375 final ItemsForCurrency itemsWithAmountForCurrency =
376 new ItemsForCurrency();
377 final ItemsForCurrency itemsWithAccountForCurrency =
378 new ItemsForCurrency();
379 final ItemsForCurrency emptyRowsForCurrency =
380 new ItemsForCurrency();
381 final List<NewTransactionModel.Item> emptyRows = new ArrayList<>();
384 if ((descriptionText == null) || descriptionText.trim()
387 Logger.debug("submittable", "Transaction not submittable: missing description");
391 for (int i = 0; i < model.items.size(); i++) {
392 NewTransactionModel.Item item = model.items.get(i);
394 LedgerTransactionAccount acc = item.getAccount();
395 String acc_name = acc.getAccountName()
397 String currName = acc.getCurrency();
399 itemsForCurrency.add(currName, item);
401 if (acc_name.isEmpty()) {
402 itemsWithEmptyAccountForCurrency.add(currName, item);
404 if (acc.isAmountSet()) {
405 // 2) each amount has account name
406 Logger.debug("submittable", String.format(
407 "Transaction not submittable: row %d has no account name, but" +
408 " has" + " amount %1.2f", i + 1, acc.getAmount()));
412 emptyRowsForCurrency.add(currName, item);
417 itemsWithAccountForCurrency.add(currName, item);
420 if (acc.isAmountSet()) {
421 itemsWithAmountForCurrency.add(currName, item);
422 balance.add(currName, acc.getAmount());
425 itemsWithEmptyAmountForCurrency.add(currName, item);
427 if (!acc_name.isEmpty())
428 itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
432 // 1) has at least two account names
435 Logger.debug("submittable",
436 "Transaction not submittable: no account " + "names");
437 else if (accounts == 1)
438 Logger.debug("submittable",
439 "Transaction not submittable: only one account name");
441 Logger.debug("submittable",
442 String.format("Transaction not submittable: only %d account names",
447 // 3) for each commodity:
448 // 3a) amount must balance to 0, or
449 // 3b) there must be exactly one empty amount (with account)
450 for (String balCurrency : itemsForCurrency.currencies()) {
451 float currencyBalance = balance.get(balCurrency);
452 if (Misc.isZero(currencyBalance)) {
453 // remove hints from all amount inputs in that currency
454 for (NewTransactionModel.Item item : model.items) {
455 if (Currency.equal(item.getCurrency(), balCurrency))
456 item.setAmountHint(null);
460 List<NewTransactionModel.Item> list =
461 itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
462 int balanceReceiversCount = list.size();
463 if (balanceReceiversCount != 1) {
464 if (BuildConfig.DEBUG) {
465 if (balanceReceiversCount == 0)
466 Logger.debug("submittable", String.format(
467 "Transaction not submittable [%s]: non-zero balance " +
468 "with no empty amounts with accounts", balCurrency));
470 Logger.debug("submittable", String.format(
471 "Transaction not submittable [%s]: non-zero balance " +
472 "with multiple empty amounts with accounts", balCurrency));
477 List<NewTransactionModel.Item> emptyAmountList =
478 itemsWithEmptyAmountForCurrency.getList(balCurrency);
480 // suggest off-balance amount to a row and remove hints on other rows
481 NewTransactionModel.Item receiver = null;
483 receiver = list.get(0);
484 else if (!emptyAmountList.isEmpty())
485 receiver = emptyAmountList.get(0);
487 for (NewTransactionModel.Item item : model.items) {
488 if (!Currency.equal(item.getCurrency(), balCurrency))
491 if (item.equals(receiver)) {
492 if (BuildConfig.DEBUG)
493 Logger.debug("submittable",
494 String.format("Setting amount hint to %1.2f [%s]",
495 -currencyBalance, balCurrency));
496 item.setAmountHint(String.format("%1.2f", -currencyBalance));
499 if (BuildConfig.DEBUG)
500 Logger.debug("submittable",
501 String.format("Resetting hint of '%s' [%s]",
502 (item.getAccount() == null) ? "" : item.getAccount()
505 item.setAmountHint(null);
511 // 5) a row with an empty account name or empty amount is guaranteed to exist for
513 for (String balCurrency : balance.currencies()) {
514 int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
515 int currRows = itemsForCurrency.size(balCurrency);
516 int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
517 int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
518 if ((currEmptyRows == 0) &&
519 ((currRows == currAccounts) || (currRows == currAmounts)))
521 // perhaps there already is an unused empty row for another currency that
523 // boolean foundIt = false;
524 // for (Item item : emptyRows) {
525 // Currency itemCurrency = item.getCurrency();
526 // String itemCurrencyName =
527 // (itemCurrency == null) ? "" : itemCurrency.getName();
528 // if (Misc.isZero(balance.get(itemCurrencyName))) {
529 // item.setCurrency(Currency.loadByName(balCurrency));
530 // item.setAmountHint(
531 // String.format("%1.2f", -balance.get(balCurrency)));
542 // drop extra empty rows, not needed
543 for (String currName : emptyRowsForCurrency.currencies()) {
544 List<NewTransactionModel.Item> emptyItems = emptyRowsForCurrency.getList(currName);
545 while ((model.items.size() > 2) && (emptyItems.size() > 1)) {
546 NewTransactionModel.Item item = emptyItems.get(1);
547 emptyItems.remove(1);
548 model.removeRow(item, this);
551 // unused currency, remove last item (which is also an empty one)
552 if ((model.items.size() > 2) && (emptyItems.size() == 1)) {
553 List<NewTransactionModel.Item> currItems = itemsForCurrency.getList(currName);
555 if (currItems.size() == 1) {
556 NewTransactionModel.Item item = emptyItems.get(0);
557 model.removeRow(item, this);
562 // 6) at least two rows need to be present in the ledger
563 while (model.items.size() < 2)
567 debug("submittable", submittable ? "YES" : "NO");
568 model.isSubmittable.setValue(submittable);
570 if (BuildConfig.DEBUG) {
571 debug("submittable", "== Dump of all items");
572 for (int i = 0; i < model.items.size(); i++) {
573 NewTransactionModel.Item item = model.items.get(i);
574 LedgerTransactionAccount acc = item.getAccount();
575 debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i,
576 acc.isAmountSet() ? acc.getAmount() : 0,
577 item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(),
578 acc.getAccountName(), acc.getComment()));
582 catch (NumberFormatException e) {
583 debug("submittable", "NO (because of NumberFormatException)");
584 model.isSubmittable.setValue(false);
586 catch (Exception e) {
588 debug("submittable", "NO (because of an Exception)");
589 model.isSubmittable.setValue(false);
592 private class BalanceForCurrency {
593 private HashMap<String, Float> hashMap = new HashMap<>();
594 float get(String currencyName) {
595 Float f = hashMap.get(currencyName);
598 hashMap.put(currencyName, f);
602 void add(String currencyName, float amount) {
603 hashMap.put(currencyName, get(currencyName) + amount);
605 Set<String> currencies() {
606 return hashMap.keySet();
608 boolean containsCurrency(String currencyName) {
609 return hashMap.containsKey(currencyName);
613 private class ItemsForCurrency {
614 private HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
616 List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
617 List<NewTransactionModel.Item> list = hashMap.get(currencyName);
619 list = new ArrayList<>();
620 hashMap.put(currencyName, list);
624 void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
625 getList(currencyName).add(item);
627 int size(@Nullable String currencyName) {
628 return this.getList(currencyName)
631 Set<String> currencies() {
632 return hashMap.keySet();