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 if (!c.moveToNext()) {
219 sb = new StringBuilder("select t.profile, t.id from transactions t where t.description=?");
220 sb.append(" ORDER BY date desc LIMIT 1");
222 final String broaderSql = sb.toString();
223 debug("descr", broaderSql);
224 debug("descr", params.toString());
225 try (Cursor c2 = App.getDatabase().rawQuery(broaderSql, new String[]{description})) {
226 if (!c2.moveToNext()) return;
228 profileUUID = c2.getString(0);
229 transactionId = c2.getInt(1);
233 profileUUID = c.getString(0);
234 transactionId = c.getInt(1);
237 loadTransactionIntoModel(profileUUID, transactionId);
240 private void loadTransactionIntoModel(String profileUUID, int transactionId) {
241 LedgerTransaction tr;
242 MobileLedgerProfile profile = Data.getProfile(profileUUID);
244 throw new RuntimeException(String.format(
245 "Unable to find profile %s, which is supposed to contain transaction %d",
246 profileUUID, transactionId));
248 tr = profile.loadTransaction(transactionId);
249 ArrayList<LedgerTransactionAccount> accounts = tr.getAccounts();
250 NewTransactionModel.Item firstNegative = null;
251 NewTransactionModel.Item firstPositive = null;
252 int singleNegativeIndex = -1;
253 int singlePositiveIndex = -1;
254 int negativeCount = 0;
255 for (int i = 0; i < accounts.size(); i++) {
256 LedgerTransactionAccount acc = accounts.get(i);
257 NewTransactionModel.Item item;
258 if (model.getAccountCount() < i + 1) {
259 model.addAccount(acc);
260 notifyItemInserted(i + 1);
262 item = model.getItem(i + 1);
265 .setAccountName(acc.getAccountName());
266 item.setComment(acc.getComment());
267 if (acc.isAmountSet()) {
269 .setAmount(acc.getAmount());
270 if (acc.getAmount() < 0) {
271 if (firstNegative == null) {
272 firstNegative = item;
273 singleNegativeIndex = i;
276 singleNegativeIndex = -1;
279 if (firstPositive == null) {
280 firstPositive = item;
281 singlePositiveIndex = i;
284 singlePositiveIndex = -1;
290 notifyItemChanged(i + 1);
293 if (singleNegativeIndex != -1) {
294 firstNegative.getAccount()
296 model.moveItemLast(singleNegativeIndex);
298 else if (singlePositiveIndex != -1) {
299 firstPositive.getAccount()
301 model.moveItemLast(singlePositiveIndex);
304 checkTransactionSubmittable();
305 model.setFocusedItem(1);
307 public void toggleAllEditing(boolean editable) {
308 // item 0 is the header
309 for (int i = 0; i <= model.getAccountCount(); i++) {
311 .setEditable(editable);
312 notifyItemChanged(i);
313 // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)?
316 public void reset() {
317 int presentItemCount = model.getAccountCount();
319 notifyItemChanged(0); // header changed
320 notifyItemRangeChanged(1, 2); // the two empty rows
321 if (presentItemCount > 2)
322 notifyItemRangeRemoved(3, presentItemCount - 2); // all the rest are gone
324 public void updateFocusedItem(int position) {
325 model.updateFocusedItem(position);
327 public void noteFocusIsOnAccount(int position) {
328 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account);
330 public void noteFocusIsOnAmount(int position) {
331 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
333 public void noteFocusIsOnComment(int position) {
334 model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
336 public void toggleComment(int position) {
337 model.toggleComment(position);
339 private void holdSubmittableChecks() {
342 private void releaseSubmittableChecks() {
343 if (checkHoldCounter == 0)
344 throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
347 void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
348 Currency oldCurrency = item.getCurrency();
349 if (!Currency.equal(newCurrency, oldCurrency)) {
350 holdSubmittableChecks();
352 item.setCurrency(newCurrency);
353 // for (Item i : items) {
354 // if (Currency.equal(i.getCurrency(), oldCurrency))
355 // i.setCurrency(newCurrency);
359 releaseSubmittableChecks();
362 checkTransactionSubmittable();
366 A transaction is submittable if:
368 1) has at least two account names
369 2) each row with amount has account name
370 3) for each commodity:
371 3a) amounts must balance to 0, or
372 3b) there must be exactly one empty amount (with account)
373 4) empty accounts with empty amounts are ignored
375 5) a row with an empty account name or empty amount is guaranteed to exist for each
377 6) at least two rows need to be present in the ledger
380 @SuppressLint("DefaultLocale")
381 void checkTransactionSubmittable() {
382 if (checkHoldCounter > 0)
386 final BalanceForCurrency balance = new BalanceForCurrency();
387 final String descriptionText = model.getDescription();
388 boolean submittable = true;
389 final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
390 final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency();
391 final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency();
392 final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency();
393 final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency();
394 final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency();
395 final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency();
396 final List<NewTransactionModel.Item> emptyRows = new ArrayList<>();
399 if ((descriptionText == null) || descriptionText.trim()
402 Logger.debug("submittable", "Transaction not submittable: missing description");
406 for (int i = 0; i < model.items.size(); i++) {
407 NewTransactionModel.Item item = model.items.get(i);
409 LedgerTransactionAccount acc = item.getAccount();
410 String acc_name = acc.getAccountName()
412 String currName = acc.getCurrency();
414 itemsForCurrency.add(currName, item);
416 if (acc_name.isEmpty()) {
417 itemsWithEmptyAccountForCurrency.add(currName, item);
419 if (acc.isAmountSet()) {
420 // 2) each amount has account name
421 Logger.debug("submittable", String.format(
422 "Transaction not submittable: row %d has no account name, but" +
423 " has" + " amount %1.2f", i + 1, acc.getAmount()));
427 emptyRowsForCurrency.add(currName, item);
432 itemsWithAccountForCurrency.add(currName, item);
435 if (acc.isAmountSet()) {
436 itemsWithAmountForCurrency.add(currName, item);
437 balance.add(currName, acc.getAmount());
440 itemsWithEmptyAmountForCurrency.add(currName, item);
442 if (!acc_name.isEmpty())
443 itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
447 // 1) has at least two account names
450 Logger.debug("submittable",
451 "Transaction not submittable: no account " + "names");
452 else if (accounts == 1)
453 Logger.debug("submittable",
454 "Transaction not submittable: only one account name");
456 Logger.debug("submittable",
457 String.format("Transaction not submittable: only %d account names",
462 // 3) for each commodity:
463 // 3a) amount must balance to 0, or
464 // 3b) there must be exactly one empty amount (with account)
465 for (String balCurrency : itemsForCurrency.currencies()) {
466 float currencyBalance = balance.get(balCurrency);
467 if (Misc.isZero(currencyBalance)) {
468 // remove hints from all amount inputs in that currency
469 for (NewTransactionModel.Item item : model.items) {
470 if (Currency.equal(item.getCurrency(), balCurrency))
471 item.setAmountHint(null);
475 List<NewTransactionModel.Item> list =
476 itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
477 int balanceReceiversCount = list.size();
478 if (balanceReceiversCount != 1) {
479 if (BuildConfig.DEBUG) {
480 if (balanceReceiversCount == 0)
481 Logger.debug("submittable", String.format(
482 "Transaction not submittable [%s]: non-zero balance " +
483 "with no empty amounts with accounts", balCurrency));
485 Logger.debug("submittable", String.format(
486 "Transaction not submittable [%s]: non-zero balance " +
487 "with multiple empty amounts with accounts", balCurrency));
492 List<NewTransactionModel.Item> emptyAmountList =
493 itemsWithEmptyAmountForCurrency.getList(balCurrency);
495 // suggest off-balance amount to a row and remove hints on other rows
496 NewTransactionModel.Item receiver = null;
498 receiver = list.get(0);
499 else if (!emptyAmountList.isEmpty())
500 receiver = emptyAmountList.get(0);
502 for (NewTransactionModel.Item item : model.items) {
503 if (!Currency.equal(item.getCurrency(), balCurrency))
506 if (item.equals(receiver)) {
507 if (BuildConfig.DEBUG)
508 Logger.debug("submittable",
509 String.format("Setting amount hint to %1.2f [%s]",
510 -currencyBalance, balCurrency));
511 item.setAmountHint(String.format("%1.2f", -currencyBalance));
514 if (BuildConfig.DEBUG)
515 Logger.debug("submittable",
516 String.format("Resetting hint of '%s' [%s]",
517 (item.getAccount() == null) ? "" : item.getAccount()
520 item.setAmountHint(null);
526 // 5) a row with an empty account name or empty amount is guaranteed to exist for
528 for (String balCurrency : balance.currencies()) {
529 int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
530 int currRows = itemsForCurrency.size(balCurrency);
531 int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
532 int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
533 if ((currEmptyRows == 0) &&
534 ((currRows == currAccounts) || (currRows == currAmounts)))
536 // perhaps there already is an unused empty row for another currency that
538 // boolean foundIt = false;
539 // for (Item item : emptyRows) {
540 // Currency itemCurrency = item.getCurrency();
541 // String itemCurrencyName =
542 // (itemCurrency == null) ? "" : itemCurrency.getName();
543 // if (Misc.isZero(balance.get(itemCurrencyName))) {
544 // item.setCurrency(Currency.loadByName(balCurrency));
545 // item.setAmountHint(
546 // String.format("%1.2f", -balance.get(balCurrency)));
557 // drop extra empty rows, not needed
558 for (String currName : emptyRowsForCurrency.currencies()) {
559 List<NewTransactionModel.Item> emptyItems = emptyRowsForCurrency.getList(currName);
560 while ((model.items.size() > 2) && (emptyItems.size() > 1)) {
561 NewTransactionModel.Item item = emptyItems.get(1);
562 emptyItems.remove(1);
563 model.removeRow(item, this);
566 // unused currency, remove last item (which is also an empty one)
567 if ((model.items.size() > 2) && (emptyItems.size() == 1)) {
568 List<NewTransactionModel.Item> currItems = itemsForCurrency.getList(currName);
570 if (currItems.size() == 1) {
571 NewTransactionModel.Item item = emptyItems.get(0);
572 model.removeRow(item, this);
577 // 6) at least two rows need to be present in the ledger
578 while (model.items.size() < 2)
582 debug("submittable", submittable ? "YES" : "NO");
583 model.isSubmittable.setValue(submittable);
585 if (BuildConfig.DEBUG) {
586 debug("submittable", "== Dump of all items");
587 for (int i = 0; i < model.items.size(); i++) {
588 NewTransactionModel.Item item = model.items.get(i);
589 LedgerTransactionAccount acc = item.getAccount();
590 debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i,
591 acc.isAmountSet() ? acc.getAmount() : 0,
592 item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(),
593 acc.getAccountName(), acc.getComment()));
597 catch (NumberFormatException e) {
598 debug("submittable", "NO (because of NumberFormatException)");
599 model.isSubmittable.setValue(false);
601 catch (Exception e) {
603 debug("submittable", "NO (because of an Exception)");
604 model.isSubmittable.setValue(false);
607 private class BalanceForCurrency {
608 private HashMap<String, Float> hashMap = new HashMap<>();
609 float get(String currencyName) {
610 Float f = hashMap.get(currencyName);
613 hashMap.put(currencyName, f);
617 void add(String currencyName, float amount) {
618 hashMap.put(currencyName, get(currencyName) + amount);
620 Set<String> currencies() {
621 return hashMap.keySet();
623 boolean containsCurrency(String currencyName) {
624 return hashMap.containsKey(currencyName);
628 private class ItemsForCurrency {
629 private HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
631 List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
632 List<NewTransactionModel.Item> list = hashMap.get(currencyName);
634 list = new ArrayList<>();
635 hashMap.put(currencyName, list);
639 void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
640 getList(currencyName).add(item);
642 int size(@Nullable String currencyName) {
643 return this.getList(currencyName)
646 Set<String> currencies() {
647 return hashMap.keySet();