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 private void holdSubmittableChecks() {
339 private void releaseSubmittableChecks() {
340 if (checkHoldCounter == 0)
341 throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
344 void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
345 Currency oldCurrency = item.getCurrency();
346 if (!Currency.equal(newCurrency, oldCurrency)) {
347 holdSubmittableChecks();
349 item.setCurrency(newCurrency);
350 // for (Item i : items) {
351 // if (Currency.equal(i.getCurrency(), oldCurrency))
352 // i.setCurrency(newCurrency);
356 releaseSubmittableChecks();
359 checkTransactionSubmittable();
363 A transaction is submittable if:
365 1) has at least two account names
366 2) each row with amount has account name
367 3) for each commodity:
368 3a) amounts must balance to 0, or
369 3b) there must be exactly one empty amount (with account)
370 4) empty accounts with empty amounts are ignored
372 5) a row with an empty account name or empty amount is guaranteed to exist for each
374 6) at least two rows need to be present in the ledger
377 @SuppressLint("DefaultLocale")
378 void checkTransactionSubmittable() {
379 if (checkHoldCounter > 0)
383 final BalanceForCurrency balance = new BalanceForCurrency();
384 final String descriptionText = model.getDescription();
385 boolean submittable = true;
386 final ItemsForCurrency itemsForCurrency = new ItemsForCurrency();
387 final ItemsForCurrency itemsWithEmptyAmountForCurrency = new ItemsForCurrency();
388 final ItemsForCurrency itemsWithAccountAndEmptyAmountForCurrency = new ItemsForCurrency();
389 final ItemsForCurrency itemsWithEmptyAccountForCurrency = new ItemsForCurrency();
390 final ItemsForCurrency itemsWithAmountForCurrency = new ItemsForCurrency();
391 final ItemsForCurrency itemsWithAccountForCurrency = new ItemsForCurrency();
392 final ItemsForCurrency emptyRowsForCurrency = new ItemsForCurrency();
393 final List<NewTransactionModel.Item> emptyRows = new ArrayList<>();
396 if ((descriptionText == null) || descriptionText.trim()
399 Logger.debug("submittable", "Transaction not submittable: missing description");
403 for (int i = 0; i < model.items.size(); i++) {
404 NewTransactionModel.Item item = model.items.get(i);
406 LedgerTransactionAccount acc = item.getAccount();
407 String acc_name = acc.getAccountName()
409 String currName = acc.getCurrency();
411 itemsForCurrency.add(currName, item);
413 if (acc_name.isEmpty()) {
414 itemsWithEmptyAccountForCurrency.add(currName, item);
416 if (acc.isAmountSet()) {
417 // 2) each amount has account name
418 Logger.debug("submittable", String.format(
419 "Transaction not submittable: row %d has no account name, but" +
420 " has" + " amount %1.2f", i + 1, acc.getAmount()));
424 emptyRowsForCurrency.add(currName, item);
429 itemsWithAccountForCurrency.add(currName, item);
432 if (acc.isAmountSet()) {
433 itemsWithAmountForCurrency.add(currName, item);
434 balance.add(currName, acc.getAmount());
437 itemsWithEmptyAmountForCurrency.add(currName, item);
439 if (!acc_name.isEmpty())
440 itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
444 // 1) has at least two account names
447 Logger.debug("submittable",
448 "Transaction not submittable: no account " + "names");
449 else if (accounts == 1)
450 Logger.debug("submittable",
451 "Transaction not submittable: only one account name");
453 Logger.debug("submittable",
454 String.format("Transaction not submittable: only %d account names",
459 // 3) for each commodity:
460 // 3a) amount must balance to 0, or
461 // 3b) there must be exactly one empty amount (with account)
462 for (String balCurrency : itemsForCurrency.currencies()) {
463 float currencyBalance = balance.get(balCurrency);
464 if (Misc.isZero(currencyBalance)) {
465 // remove hints from all amount inputs in that currency
466 for (NewTransactionModel.Item item : model.items) {
467 if (Currency.equal(item.getCurrency(), balCurrency))
468 item.setAmountHint(null);
472 List<NewTransactionModel.Item> list =
473 itemsWithAccountAndEmptyAmountForCurrency.getList(balCurrency);
474 int balanceReceiversCount = list.size();
475 if (balanceReceiversCount != 1) {
476 if (BuildConfig.DEBUG) {
477 if (balanceReceiversCount == 0)
478 Logger.debug("submittable", String.format(
479 "Transaction not submittable [%s]: non-zero balance " +
480 "with no empty amounts with accounts", balCurrency));
482 Logger.debug("submittable", String.format(
483 "Transaction not submittable [%s]: non-zero balance " +
484 "with multiple empty amounts with accounts", balCurrency));
489 List<NewTransactionModel.Item> emptyAmountList =
490 itemsWithEmptyAmountForCurrency.getList(balCurrency);
492 // suggest off-balance amount to a row and remove hints on other rows
493 NewTransactionModel.Item receiver = null;
495 receiver = list.get(0);
496 else if (!emptyAmountList.isEmpty())
497 receiver = emptyAmountList.get(0);
499 for (NewTransactionModel.Item item : model.items) {
500 if (!Currency.equal(item.getCurrency(), balCurrency))
503 if (item.equals(receiver)) {
504 if (BuildConfig.DEBUG)
505 Logger.debug("submittable",
506 String.format("Setting amount hint to %1.2f [%s]",
507 -currencyBalance, balCurrency));
508 item.setAmountHint(String.format("%1.2f", -currencyBalance));
511 if (BuildConfig.DEBUG)
512 Logger.debug("submittable",
513 String.format("Resetting hint of '%s' [%s]",
514 (item.getAccount() == null) ? "" : item.getAccount()
517 item.setAmountHint(null);
523 // 5) a row with an empty account name or empty amount is guaranteed to exist for
525 for (String balCurrency : balance.currencies()) {
526 int currEmptyRows = itemsWithEmptyAccountForCurrency.size(balCurrency);
527 int currRows = itemsForCurrency.size(balCurrency);
528 int currAccounts = itemsWithAccountForCurrency.size(balCurrency);
529 int currAmounts = itemsWithAmountForCurrency.size(balCurrency);
530 if ((currEmptyRows == 0) &&
531 ((currRows == currAccounts) || (currRows == currAmounts)))
533 // perhaps there already is an unused empty row for another currency that
535 // boolean foundIt = false;
536 // for (Item item : emptyRows) {
537 // Currency itemCurrency = item.getCurrency();
538 // String itemCurrencyName =
539 // (itemCurrency == null) ? "" : itemCurrency.getName();
540 // if (Misc.isZero(balance.get(itemCurrencyName))) {
541 // item.setCurrency(Currency.loadByName(balCurrency));
542 // item.setAmountHint(
543 // String.format("%1.2f", -balance.get(balCurrency)));
554 // drop extra empty rows, not needed
555 for (String currName : emptyRowsForCurrency.currencies()) {
556 List<NewTransactionModel.Item> emptyItems = emptyRowsForCurrency.getList(currName);
557 while ((model.items.size() > 2) && (emptyItems.size() > 1)) {
558 NewTransactionModel.Item item = emptyItems.get(1);
559 emptyItems.remove(1);
560 model.removeRow(item, this);
563 // unused currency, remove last item (which is also an empty one)
564 if ((model.items.size() > 2) && (emptyItems.size() == 1)) {
565 List<NewTransactionModel.Item> currItems = itemsForCurrency.getList(currName);
567 if (currItems.size() == 1) {
568 NewTransactionModel.Item item = emptyItems.get(0);
569 model.removeRow(item, this);
574 // 6) at least two rows need to be present in the ledger
575 while (model.items.size() < 2)
579 debug("submittable", submittable ? "YES" : "NO");
580 model.isSubmittable.setValue(submittable);
582 if (BuildConfig.DEBUG) {
583 debug("submittable", "== Dump of all items");
584 for (int i = 0; i < model.items.size(); i++) {
585 NewTransactionModel.Item item = model.items.get(i);
586 LedgerTransactionAccount acc = item.getAccount();
587 debug("submittable", String.format("Item %2d: [%4.2f(%s) %s] %s ; %s", i,
588 acc.isAmountSet() ? acc.getAmount() : 0,
589 item.isAmountHintSet() ? item.getAmountHint() : "ø", acc.getCurrency(),
590 acc.getAccountName(), acc.getComment()));
594 catch (NumberFormatException e) {
595 debug("submittable", "NO (because of NumberFormatException)");
596 model.isSubmittable.setValue(false);
598 catch (Exception e) {
600 debug("submittable", "NO (because of an Exception)");
601 model.isSubmittable.setValue(false);
604 private class BalanceForCurrency {
605 private HashMap<String, Float> hashMap = new HashMap<>();
606 float get(String currencyName) {
607 Float f = hashMap.get(currencyName);
610 hashMap.put(currencyName, f);
614 void add(String currencyName, float amount) {
615 hashMap.put(currencyName, get(currencyName) + amount);
617 Set<String> currencies() {
618 return hashMap.keySet();
620 boolean containsCurrency(String currencyName) {
621 return hashMap.containsKey(currencyName);
625 private class ItemsForCurrency {
626 private HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
628 List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
629 List<NewTransactionModel.Item> list = hashMap.get(currencyName);
631 list = new ArrayList<>();
632 hashMap.put(currencyName, list);
636 void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
637 getList(currencyName).add(item);
639 int size(@Nullable String currencyName) {
640 return this.getList(currencyName)
643 Set<String> currencies() {
644 return hashMap.keySet();