]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java
move high-level login out of the new transaction model to the adapter
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / activity / NewTransactionItemsAdapter.java
1 /*
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.
8  *
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.
13  *
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/>.
16  */
17
18 package net.ktnx.mobileledger.ui.activity;
19
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;
25
26 import androidx.annotation.NonNull;
27 import androidx.annotation.Nullable;
28 import androidx.recyclerview.widget.ItemTouchHelper;
29 import androidx.recyclerview.widget.RecyclerView;
30
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;
42
43 import java.util.ArrayList;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Locale;
47 import java.util.Set;
48
49 import static net.ktnx.mobileledger.utils.Logger.debug;
50
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) {
59         super();
60         model = viewModel;
61         mProfile = profile;
62         int size = model.getAccountCount();
63         while (size < 2) {
64             Logger.debug("new-transaction",
65                     String.format(Locale.US, "%d accounts is too little, Calling addRow()", size));
66             size = addRow();
67         }
68
69         NewTransactionItemsAdapter adapter = this;
70
71         touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
72             @Override
73             public boolean isLongPressDragEnabled() {
74                 return true;
75             }
76             @Override
77             public boolean canDropOver(@NonNull RecyclerView recyclerView,
78                                        @NonNull RecyclerView.ViewHolder current,
79                                        @NonNull RecyclerView.ViewHolder target) {
80                 final int adapterPosition = target.getAdapterPosition();
81
82                 // first and last items are immovable
83                 if (adapterPosition == 0)
84                     return false;
85                 if (adapterPosition == adapter.getItemCount() - 1)
86                     return false;
87
88                 return super.canDropOver(recyclerView, current, target);
89             }
90             @Override
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);
101                 }
102
103                 return flags;
104             }
105             @Override
106             public boolean onMove(@NonNull RecyclerView recyclerView,
107                                   @NonNull RecyclerView.ViewHolder viewHolder,
108                                   @NonNull RecyclerView.ViewHolder target) {
109
110                 model.swapItems(viewHolder.getAdapterPosition(), target.getAdapterPosition());
111                 notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
112                 return true;
113             }
114             @Override
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();
121             }
122         });
123     }
124     public void setProfile(MobileLedgerProfile profile) {
125         mProfile = profile;
126     }
127     int addRow() {
128         return addRow(null);
129     }
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;
138     }
139     @NonNull
140     @Override
141     public NewTransactionItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
142         LinearLayout row = (LinearLayout) LayoutInflater.from(parent.getContext())
143                                                         .inflate(R.layout.new_transaction_row,
144                                                                 parent, false);
145
146         return new NewTransactionItemHolder(row, this);
147     }
148     @Override
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()
154                                                                                           .toString(),
155                 position));
156     }
157     @Override
158     public int getItemCount() {
159         return model.getAccountCount() + 2;
160     }
161     boolean accountListIsEmpty() {
162         for (int i = 0; i < model.getAccountCount(); i++) {
163             LedgerTransactionAccount acc = model.getAccount(i);
164             if (!acc.getAccountName()
165                     .isEmpty())
166                 return false;
167             if (acc.isAmountSet())
168                 return false;
169         }
170
171         return true;
172     }
173     @Override
174     public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
175         super.onAttachedToRecyclerView(recyclerView);
176         this.recyclerView = recyclerView;
177         touchHelper.attachToRecyclerView(recyclerView);
178     }
179     @Override
180     public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
181         touchHelper.attachToRecyclerView(null);
182         super.onDetachedFromRecyclerView(recyclerView);
183         this.recyclerView = null;
184     }
185     public void descriptionSelected(String description) {
186         debug("descr selected", description);
187         if (!accountListIsEmpty())
188             return;
189
190         String accFilter = mProfile.getPreferredAccountsFilter();
191
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);
196
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());
204         }
205
206         sb.append(" ORDER BY date desc limit 1");
207
208         final String sql = sb.toString();
209         debug("descr", sql);
210         debug("descr", params.toString());
211
212         try (Cursor c = App.getDatabase()
213                            .rawQuery(sql, params.toArray(new String[]{})))
214         {
215             if (!c.moveToNext())
216                 return;
217
218             String profileUUID = c.getString(0);
219             int transactionId = c.getInt(1);
220             LedgerTransaction tr;
221             MobileLedgerProfile profile = Data.getProfile(profileUUID);
222             if (profile == null)
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,
226                         description));
227
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);
241                 }
242                 item = model.getItem(i + 1);
243
244                 item.getAccount()
245                     .setAccountName(acc.getAccountName());
246                 if (acc.isAmountSet()) {
247                     item.getAccount()
248                         .setAmount(acc.getAmount());
249                     if (acc.getAmount() < 0) {
250                         if (firstNegative == null) {
251                             firstNegative = item;
252                             singleNegativeIndex = i;
253                         }
254                         else
255                             singleNegativeIndex = -1;
256                     }
257                     else {
258                         if (firstPositive == null) {
259                             firstPositive = item;
260                             singlePositiveIndex = i;
261                         }
262                         else
263                             singlePositiveIndex = -1;
264                     }
265                 }
266                 else
267                     item.getAccount()
268                         .resetAmount();
269                 notifyItemChanged(i + 1);
270             }
271
272             if (singleNegativeIndex != -1) {
273                 firstNegative.getAccount()
274                              .resetAmount();
275                 model.moveItemLast(singleNegativeIndex);
276             }
277             else if (singlePositiveIndex != -1) {
278                 firstPositive.getAccount()
279                              .resetAmount();
280                 model.moveItemLast(singlePositiveIndex);
281             }
282         }
283         checkTransactionSubmittable();
284         model.setFocusedItem(1);
285     }
286     public void toggleAllEditing(boolean editable) {
287         // item 0 is the header
288         for (int i = 0; i <= model.getAccountCount(); i++) {
289             model.getItem(i)
290                  .setEditable(editable);
291             notifyItemChanged(i);
292             // TODO perhaps do only one notification about the whole range (notifyDatasetChanged)?
293         }
294     }
295     public void reset() {
296         int presentItemCount = model.getAccountCount();
297         model.reset();
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
302     }
303     public void updateFocusedItem(int position) {
304         model.updateFocusedItem(position);
305     }
306     public void noteFocusIsOnAccount(int position) {
307         model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Account);
308     }
309     public void noteFocusIsOnAmount(int position) {
310         model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Amount);
311     }
312     public void noteFocusIsOnComment(int position) {
313         model.noteFocusChanged(position, NewTransactionModel.FocusedElement.Comment);
314     }
315     public void toggleComment(int position) {
316         model.toggleComment(position);
317     }
318     private void holdSubmittableChecks() {
319         checkHoldCounter++;
320     }
321     private void releaseSubmittableChecks() {
322         if (checkHoldCounter == 0)
323             throw new RuntimeException("Asymmetrical call to releaseSubmittableChecks");
324         checkHoldCounter--;
325     }
326     void setItemCurrency(NewTransactionModel.Item item, Currency newCurrency) {
327         Currency oldCurrency = item.getCurrency();
328         if (!Currency.equal(newCurrency, oldCurrency)) {
329             holdSubmittableChecks();
330             try {
331                 item.setCurrency(newCurrency);
332 //                for (Item i : items) {
333 //                    if (Currency.equal(i.getCurrency(), oldCurrency))
334 //                        i.setCurrency(newCurrency);
335 //                }
336             }
337             finally {
338                 releaseSubmittableChecks();
339             }
340
341             checkTransactionSubmittable();
342         }
343     }
344     /*
345          A transaction is submittable if:
346          0) has description
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
353          Side effects:
354          5) a row with an empty account name or empty amount is guaranteed to exist for each
355          commodity
356          6) at least two rows need to be present in the ledger
357
358         */
359     @SuppressLint("DefaultLocale")
360     void checkTransactionSubmittable() {
361         if (checkHoldCounter > 0)
362             return;
363
364         int accounts = 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<>();
382
383         try {
384             if ((descriptionText == null) || descriptionText.trim()
385                                                             .isEmpty())
386             {
387                 Logger.debug("submittable", "Transaction not submittable: missing description");
388                 submittable = false;
389             }
390
391             for (int i = 0; i < model.items.size(); i++) {
392                 NewTransactionModel.Item item = model.items.get(i);
393
394                 LedgerTransactionAccount acc = item.getAccount();
395                 String acc_name = acc.getAccountName()
396                                      .trim();
397                 String currName = acc.getCurrency();
398
399                 itemsForCurrency.add(currName, item);
400
401                 if (acc_name.isEmpty()) {
402                     itemsWithEmptyAccountForCurrency.add(currName, item);
403
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()));
409                         submittable = false;
410                     }
411                     else {
412                         emptyRowsForCurrency.add(currName, item);
413                     }
414                 }
415                 else {
416                     accounts++;
417                     itemsWithAccountForCurrency.add(currName, item);
418                 }
419
420                 if (acc.isAmountSet()) {
421                     itemsWithAmountForCurrency.add(currName, item);
422                     balance.add(currName, acc.getAmount());
423                 }
424                 else {
425                     itemsWithEmptyAmountForCurrency.add(currName, item);
426
427                     if (!acc_name.isEmpty())
428                         itemsWithAccountAndEmptyAmountForCurrency.add(currName, item);
429                 }
430             }
431
432             // 1) has at least two account names
433             if (accounts < 2) {
434                 if (accounts == 0)
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");
440                 else
441                     Logger.debug("submittable",
442                             String.format("Transaction not submittable: only %d account names",
443                                     accounts));
444                 submittable = false;
445             }
446
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);
457                     }
458                 }
459                 else {
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));
469                             else
470                                 Logger.debug("submittable", String.format(
471                                         "Transaction not submittable [%s]: non-zero balance " +
472                                         "with multiple empty amounts with accounts", balCurrency));
473                         }
474                         submittable = false;
475                     }
476
477                     List<NewTransactionModel.Item> emptyAmountList =
478                             itemsWithEmptyAmountForCurrency.getList(balCurrency);
479
480                     // suggest off-balance amount to a row and remove hints on other rows
481                     NewTransactionModel.Item receiver = null;
482                     if (!list.isEmpty())
483                         receiver = list.get(0);
484                     else if (!emptyAmountList.isEmpty())
485                         receiver = emptyAmountList.get(0);
486
487                     for (NewTransactionModel.Item item : model.items) {
488                         if (!Currency.equal(item.getCurrency(), balCurrency))
489                             continue;
490
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));
497                         }
498                         else {
499                             if (BuildConfig.DEBUG)
500                                 Logger.debug("submittable",
501                                         String.format("Resetting hint of '%s' [%s]",
502                                                 (item.getAccount() == null) ? "" : item.getAccount()
503                                                                                        .getAccountName(),
504                                                 balCurrency));
505                             item.setAmountHint(null);
506                         }
507                     }
508                 }
509             }
510
511             // 5) a row with an empty account name or empty amount is guaranteed to exist for
512             // each commodity
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)))
520                 {
521                     // perhaps there already is an unused empty row for another currency that
522                     // is not used?
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)));
532 //                                foundIt = true;
533 //                                break;
534 //                            }
535 //                        }
536 //
537 //                        if (!foundIt)
538                     addRow(balCurrency);
539                 }
540             }
541
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);
549                 }
550
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);
554
555                     if (currItems.size() == 1) {
556                         NewTransactionModel.Item item = emptyItems.get(0);
557                         model.removeRow(item, this);
558                     }
559                 }
560             }
561
562             // 6) at least two rows need to be present in the ledger
563             while (model.items.size() < 2)
564                 addRow();
565
566
567             debug("submittable", submittable ? "YES" : "NO");
568             model.isSubmittable.setValue(submittable);
569
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()));
579                 }
580             }
581         }
582         catch (NumberFormatException e) {
583             debug("submittable", "NO (because of NumberFormatException)");
584             model.isSubmittable.setValue(false);
585         }
586         catch (Exception e) {
587             e.printStackTrace();
588             debug("submittable", "NO (because of an Exception)");
589             model.isSubmittable.setValue(false);
590         }
591     }
592     private class BalanceForCurrency {
593         private HashMap<String, Float> hashMap = new HashMap<>();
594         float get(String currencyName) {
595             Float f = hashMap.get(currencyName);
596             if (f == null) {
597                 f = 0f;
598                 hashMap.put(currencyName, f);
599             }
600             return f;
601         }
602         void add(String currencyName, float amount) {
603             hashMap.put(currencyName, get(currencyName) + amount);
604         }
605         Set<String> currencies() {
606             return hashMap.keySet();
607         }
608         boolean containsCurrency(String currencyName) {
609             return hashMap.containsKey(currencyName);
610         }
611     }
612
613     private class ItemsForCurrency {
614         private HashMap<String, List<NewTransactionModel.Item>> hashMap = new HashMap<>();
615         @NonNull
616         List<NewTransactionModel.Item> getList(@Nullable String currencyName) {
617             List<NewTransactionModel.Item> list = hashMap.get(currencyName);
618             if (list == null) {
619                 list = new ArrayList<>();
620                 hashMap.put(currencyName, list);
621             }
622             return list;
623         }
624         void add(@Nullable String currencyName, @NonNull NewTransactionModel.Item item) {
625             getList(currencyName).add(item);
626         }
627         int size(@Nullable String currencyName) {
628             return this.getList(currencyName)
629                        .size();
630         }
631         Set<String> currencies() {
632             return hashMap.keySet();
633         }
634     }
635 }