]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsAdapter.java
73d986b66e6f496bb0f003a4f86fccb22f469a00
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / templates / TemplateDetailsAdapter.java
1 /*
2  * Copyright © 2021 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.templates;
19
20 import android.annotation.SuppressLint;
21 import android.content.res.Resources;
22 import android.text.Editable;
23 import android.text.TextWatcher;
24 import android.view.LayoutInflater;
25 import android.view.MotionEvent;
26 import android.view.View;
27 import android.view.ViewGroup;
28 import android.widget.TextView;
29
30 import androidx.annotation.NonNull;
31 import androidx.appcompat.app.AppCompatActivity;
32 import androidx.recyclerview.widget.AsyncListDiffer;
33 import androidx.recyclerview.widget.DiffUtil;
34 import androidx.recyclerview.widget.ItemTouchHelper;
35 import androidx.recyclerview.widget.RecyclerView;
36
37 import net.ktnx.mobileledger.BuildConfig;
38 import net.ktnx.mobileledger.R;
39 import net.ktnx.mobileledger.databinding.TemplateDetailsAccountBinding;
40 import net.ktnx.mobileledger.databinding.TemplateDetailsHeaderBinding;
41 import net.ktnx.mobileledger.db.AccountAutocompleteAdapter;
42 import net.ktnx.mobileledger.model.Data;
43 import net.ktnx.mobileledger.model.TemplateDetailsItem;
44 import net.ktnx.mobileledger.ui.QRScanCapableFragment;
45 import net.ktnx.mobileledger.ui.TemplateDetailSourceSelectorFragment;
46 import net.ktnx.mobileledger.utils.Logger;
47 import net.ktnx.mobileledger.utils.Misc;
48
49 import org.jetbrains.annotations.NotNull;
50
51 import java.text.ParseException;
52 import java.util.List;
53 import java.util.Locale;
54 import java.util.regex.Matcher;
55 import java.util.regex.Pattern;
56
57 class TemplateDetailsAdapter extends RecyclerView.Adapter<TemplateDetailsAdapter.ViewHolder> {
58     private static final String D_TEMPLATE_UI = "template-ui";
59     private final AsyncListDiffer<TemplateDetailsItem> differ;
60     private final TemplateDetailsViewModel mModel;
61     private final ItemTouchHelper itemTouchHelper;
62     public TemplateDetailsAdapter(TemplateDetailsViewModel model) {
63         super();
64         mModel = model;
65         setHasStableIds(true);
66         differ = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<TemplateDetailsItem>() {
67             @Override
68             public boolean areItemsTheSame(@NonNull TemplateDetailsItem oldItem,
69                                            @NonNull TemplateDetailsItem newItem) {
70                 if (oldItem.getType() != newItem.getType())
71                     return false;
72                 if (oldItem.getType()
73                            .equals(TemplateDetailsItem.Type.HEADER))
74                     return true;    // only one header item, ever
75                 // the rest is comparing two account row items
76                 return oldItem.asAccountRowItem()
77                               .getId() == newItem.asAccountRowItem()
78                                                  .getId();
79             }
80             @Override
81             public boolean areContentsTheSame(@NonNull TemplateDetailsItem oldItem,
82                                               @NonNull TemplateDetailsItem newItem) {
83                 if (oldItem.getType()
84                            .equals(TemplateDetailsItem.Type.HEADER))
85                 {
86                     TemplateDetailsItem.Header oldHeader = oldItem.asHeaderItem();
87                     TemplateDetailsItem.Header newHeader = newItem.asHeaderItem();
88
89                     return oldHeader.equalContents(newHeader);
90                 }
91                 else {
92                     TemplateDetailsItem.AccountRow oldAcc = oldItem.asAccountRowItem();
93                     TemplateDetailsItem.AccountRow newAcc = newItem.asAccountRowItem();
94
95                     return oldAcc.equalContents(newAcc);
96                 }
97             }
98         });
99         itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {
100             @Override
101             public float getMoveThreshold(@NonNull RecyclerView.ViewHolder viewHolder) {
102                 return 0.5f;
103             }
104             @Override
105             public boolean isLongPressDragEnabled() {
106                 return false;
107             }
108             @Override
109             public RecyclerView.ViewHolder chooseDropTarget(
110                     @NonNull RecyclerView.ViewHolder selected,
111                     @NonNull List<RecyclerView.ViewHolder> dropTargets, int curX, int curY) {
112                 RecyclerView.ViewHolder best = null;
113                 int bestDistance = 0;
114                 for (RecyclerView.ViewHolder v : dropTargets) {
115                     if (v == selected)
116                         continue;
117
118                     final int viewTop = v.itemView.getTop();
119                     int distance = Math.abs(viewTop - curY);
120                     if (best == null) {
121                         best = v;
122                         bestDistance = distance;
123                     }
124                     else {
125                         if (distance < bestDistance) {
126                             bestDistance = distance;
127                             best = v;
128                         }
129                     }
130                 }
131
132                 Logger.debug("dnd", "Best target is " + best);
133                 return best;
134             }
135             @Override
136             public boolean canDropOver(@NonNull RecyclerView recyclerView,
137                                        @NonNull RecyclerView.ViewHolder current,
138                                        @NonNull RecyclerView.ViewHolder target) {
139                 final int adapterPosition = target.getAdapterPosition();
140
141                 // first item is immovable
142                 if (adapterPosition == 0)
143                     return false;
144
145                 return super.canDropOver(recyclerView, current, target);
146             }
147             @Override
148             public int getMovementFlags(@NonNull RecyclerView recyclerView,
149                                         @NonNull RecyclerView.ViewHolder viewHolder) {
150                 int flags = 0;
151                 // the top item (transaction params) is always there
152                 final int adapterPosition = viewHolder.getAdapterPosition();
153                 if (adapterPosition > 0)
154                     flags |= makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
155                             ItemTouchHelper.UP | ItemTouchHelper.DOWN) |
156                              makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE,
157                                      ItemTouchHelper.START | ItemTouchHelper.END);
158
159                 return flags;
160             }
161             @Override
162             public boolean onMove(@NonNull RecyclerView recyclerView,
163                                   @NonNull RecyclerView.ViewHolder viewHolder,
164                                   @NonNull RecyclerView.ViewHolder target) {
165
166                 final int fromPosition = viewHolder.getAdapterPosition();
167                 final int toPosition = target.getAdapterPosition();
168                 if (fromPosition == toPosition) {
169                     Logger.debug("drag", String.format(Locale.US,
170                             "Ignoring request to move an account from position %d to %d",
171                             fromPosition, toPosition));
172                     return false;
173                 }
174
175                 Logger.debug("drag",
176                         String.format(Locale.US, "Moving account from %d to %d", fromPosition,
177                                 toPosition));
178                 mModel.moveItem(fromPosition, toPosition);
179
180                 return true;
181             }
182             @Override
183             public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
184                 int pos = viewHolder.getAdapterPosition();
185                 mModel.removeItem(pos);
186             }
187         });
188     }
189     @Override
190     public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
191         super.onAttachedToRecyclerView(recyclerView);
192
193         itemTouchHelper.attachToRecyclerView(recyclerView);
194     }
195     @Override
196     public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
197         super.onDetachedFromRecyclerView(recyclerView);
198
199         itemTouchHelper.attachToRecyclerView(null);
200     }
201     @Override
202     public long getItemId(int position) {
203         // header item is always first and IDs id may duplicate some of the account IDs
204         if (position == 0)
205             return 0;
206         TemplateDetailsItem.AccountRow accRow = differ.getCurrentList()
207                                                       .get(position)
208                                                       .asAccountRowItem();
209         return accRow.getId();
210     }
211     @Override
212     public int getItemViewType(int position) {
213
214         return differ.getCurrentList()
215                      .get(position)
216                      .getType()
217                      .toInt();
218     }
219     @NonNull
220     @Override
221     public TemplateDetailsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
222                                                                 int viewType) {
223         final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
224         switch (viewType) {
225             case TemplateDetailsItem.TYPE.header:
226                 return new Header(TemplateDetailsHeaderBinding.inflate(inflater, parent, false));
227             case TemplateDetailsItem.TYPE.accountItem:
228                 return new AccountRow(
229                         TemplateDetailsAccountBinding.inflate(inflater, parent, false));
230             default:
231                 throw new IllegalStateException("Unsupported view type " + viewType);
232         }
233     }
234     @Override
235     public void onBindViewHolder(@NonNull TemplateDetailsAdapter.ViewHolder holder, int position) {
236         TemplateDetailsItem item = differ.getCurrentList()
237                                          .get(position);
238         holder.bind(item);
239     }
240     @Override
241     public int getItemCount() {
242         return differ.getCurrentList()
243                      .size();
244     }
245     public void setItems(List<TemplateDetailsItem> items) {
246         if (BuildConfig.DEBUG) {
247             Logger.debug("tmpl", "Got new list");
248             for (int i = 1; i < items.size(); i++) {
249                 final TemplateDetailsItem item = items.get(i);
250                 Logger.debug("tmpl",
251                         String.format(Locale.US, "  %d: id %d, pos %d", i, item.getId(), item.getPosition()));
252             }
253         }
254         differ.submitList(items);
255     }
256     public String getMatchGroupText(int groupNumber) {
257         TemplateDetailsItem.Header header = getHeader();
258         Pattern p = header.getCompiledPattern();
259         if (p == null)
260             return null;
261
262         final String testText = Misc.nullIsEmpty(header.getTestText());
263         Matcher m = p.matcher(testText);
264         if (m.matches() && m.groupCount() >= groupNumber)
265             return m.group(groupNumber);
266         else
267             return null;
268     }
269     protected TemplateDetailsItem.Header getHeader() {
270         return differ.getCurrentList()
271                      .get(0)
272                      .asHeaderItem();
273     }
274
275     private enum HeaderDetail {DESCRIPTION, COMMENT, DATE_YEAR, DATE_MONTH, DATE_DAY}
276
277     private enum AccDetail {ACCOUNT, COMMENT, AMOUNT}
278
279     public abstract static class ViewHolder extends RecyclerView.ViewHolder {
280         ViewHolder(@NonNull View itemView) {
281             super(itemView);
282         }
283         abstract void bind(TemplateDetailsItem item);
284     }
285
286     private abstract static class BaseItem extends ViewHolder {
287         boolean updatePropagationDisabled = false;
288         BaseItem(@NonNull View itemView) {
289             super(itemView);
290         }
291         void disableUpdatePropagation() {
292             updatePropagationDisabled = true;
293         }
294         void enableUpdatePropagation() {
295             updatePropagationDisabled = false;
296         }
297     }
298
299     public class Header extends BaseItem {
300         private final TemplateDetailsHeaderBinding b;
301         boolean updatePropagationDisabled = false;
302         public Header(@NonNull TemplateDetailsHeaderBinding binding) {
303             super(binding.getRoot());
304             b = binding;
305
306             TextWatcher templateNameWatcher = new TextWatcher() {
307                 @Override
308                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
309                 @Override
310                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
311                 @Override
312                 public void afterTextChanged(Editable s) {
313                     if (updatePropagationDisabled)
314                         return;
315
316                     final TemplateDetailsItem.Header header = getItem();
317                     Logger.debug(D_TEMPLATE_UI,
318                             "Storing changed template name " + s + "; header=" + header);
319                     header.setName(String.valueOf(s));
320                 }
321             };
322             b.templateName.addTextChangedListener(templateNameWatcher);
323
324             TextWatcher patternWatcher = new TextWatcher() {
325                 @Override
326                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
327                 @Override
328                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
329                 @Override
330                 public void afterTextChanged(Editable s) {
331                     if (updatePropagationDisabled)
332                         return;
333
334                     final TemplateDetailsItem.Header header = getItem();
335                     Logger.debug(D_TEMPLATE_UI,
336                             "Storing changed pattern " + s + "; header=" + header);
337                     header.setPattern(String.valueOf(s));
338
339                     checkPatternError(header);
340                 }
341             };
342             b.pattern.addTextChangedListener(patternWatcher);
343
344             TextWatcher testTextWatcher = new TextWatcher() {
345                 @Override
346                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
347                 @Override
348                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
349                 @Override
350                 public void afterTextChanged(Editable s) {
351                     if (updatePropagationDisabled)
352                         return;
353
354                     final TemplateDetailsItem.Header header = getItem();
355                     Logger.debug(D_TEMPLATE_UI,
356                             "Storing changed test text " + s + "; header=" + header);
357                     header.setTestText(String.valueOf(s));
358
359                     checkPatternError(header);
360                 }
361             };
362             b.testText.addTextChangedListener(testTextWatcher);
363
364             TextWatcher transactionDescriptionWatcher = new TextWatcher() {
365                 @Override
366                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
367                 }
368                 @Override
369                 public void onTextChanged(CharSequence s, int start, int before, int count) {
370                 }
371                 @Override
372                 public void afterTextChanged(Editable s) {
373                     if (updatePropagationDisabled)
374                         return;
375
376                     final TemplateDetailsItem.Header header = getItem();
377                     Logger.debug(D_TEMPLATE_UI,
378                             "Storing changed transaction description " + s + "; header=" + header);
379                     header.setTransactionDescription(String.valueOf(s));
380                 }
381             };
382             b.transactionDescription.addTextChangedListener(transactionDescriptionWatcher);
383             TextWatcher transactionCommentWatcher = new TextWatcher() {
384                 @Override
385                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
386                 }
387                 @Override
388                 public void onTextChanged(CharSequence s, int start, int before, int count) {
389                 }
390                 @Override
391                 public void afterTextChanged(Editable s) {
392                     if (updatePropagationDisabled)
393                         return;
394
395                     final TemplateDetailsItem.Header header = getItem();
396                     Logger.debug(D_TEMPLATE_UI,
397                             "Storing changed transaction description " + s + "; header=" + header);
398                     header.setTransactionComment(String.valueOf(s));
399                 }
400             };
401             b.transactionComment.addTextChangedListener(transactionCommentWatcher);
402         }
403         @NotNull
404         private TemplateDetailsItem.Header getItem() {
405             int pos = getAdapterPosition();
406             return differ.getCurrentList()
407                          .get(pos)
408                          .asHeaderItem();
409         }
410         private void selectHeaderDetailSource(View v, HeaderDetail detail) {
411             TemplateDetailsItem.Header header = getItem();
412             Logger.debug(D_TEMPLATE_UI, "header is " + header);
413             TemplateDetailSourceSelectorFragment sel =
414                     TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
415                             header.getTestText());
416             sel.setOnSourceSelectedListener((literal, group) -> {
417                 if (literal) {
418                     switch (detail) {
419                         case DESCRIPTION:
420                             header.switchToLiteralTransactionDescription();
421                             break;
422                         case COMMENT:
423                             header.switchToLiteralTransactionComment();
424                             break;
425                         case DATE_YEAR:
426                             header.switchToLiteralDateYear();
427                             break;
428                         case DATE_MONTH:
429                             header.switchToLiteralDateMonth();
430                             break;
431                         case DATE_DAY:
432                             header.switchToLiteralDateDay();
433                             break;
434                         default:
435                             throw new IllegalStateException("Unexpected detail " + detail);
436                     }
437                 }
438                 else {
439                     switch (detail) {
440                         case DESCRIPTION:
441                             header.setTransactionDescriptionMatchGroup(group);
442                             break;
443                         case COMMENT:
444                             header.setTransactionCommentMatchGroup(group);
445                             break;
446                         case DATE_YEAR:
447                             header.setDateYearMatchGroup(group);
448                             break;
449                         case DATE_MONTH:
450                             header.setDateMonthMatchGroup(group);
451                             break;
452                         case DATE_DAY:
453                             header.setDateDayMatchGroup(group);
454                             break;
455                         default:
456                             throw new IllegalStateException("Unexpected detail " + detail);
457                     }
458                 }
459
460                 notifyItemChanged(getAdapterPosition());
461             });
462             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
463             sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
464         }
465         @Override
466         void bind(TemplateDetailsItem item) {
467             TemplateDetailsItem.Header header = item.asHeaderItem();
468             Logger.debug(D_TEMPLATE_UI, "Binding to header " + header);
469
470             disableUpdatePropagation();
471             try {
472                 String groupNoText = b.getRoot()
473                                       .getResources()
474                                       .getString(R.string.template_item_match_group_source);
475
476                 b.templateName.setText(header.getName());
477                 b.pattern.setText(header.getPattern());
478                 b.testText.setText(header.getTestText());
479
480                 if (header.hasLiteralDateYear()) {
481                     b.templateDetailsYearSource.setText(R.string.template_details_source_literal);
482                     final Integer dateYear = header.getDateYear();
483                     b.templateDetailsDateYear.setText(
484                             (dateYear == null) ? null : String.valueOf(dateYear));
485                     b.templateDetailsDateYearLayout.setVisibility(View.VISIBLE);
486                 }
487                 else {
488                     b.templateDetailsDateYearLayout.setVisibility(View.GONE);
489                     b.templateDetailsYearSource.setText(
490                             String.format(Locale.US, groupNoText, header.getDateYearMatchGroup(),
491                                     getMatchGroupText(header.getDateYearMatchGroup())));
492                 }
493                 b.templateDetailsYearSourceLabel.setOnClickListener(
494                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
495                 b.templateDetailsYearSource.setOnClickListener(
496                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
497
498                 if (header.hasLiteralDateMonth()) {
499                     b.templateDetailsMonthSource.setText(R.string.template_details_source_literal);
500                     final Integer dateMonth = header.getDateMonth();
501                     b.templateDetailsDateMonth.setText(
502                             (dateMonth == null) ? null : String.valueOf(dateMonth));
503                     b.templateDetailsDateMonthLayout.setVisibility(View.VISIBLE);
504                 }
505                 else {
506                     b.templateDetailsDateMonthLayout.setVisibility(View.GONE);
507                     b.templateDetailsMonthSource.setText(
508                             String.format(Locale.US, groupNoText, header.getDateMonthMatchGroup(),
509                                     getMatchGroupText(header.getDateMonthMatchGroup())));
510                 }
511                 b.templateDetailsMonthSourceLabel.setOnClickListener(
512                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
513                 b.templateDetailsMonthSource.setOnClickListener(
514                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
515
516                 if (header.hasLiteralDateDay()) {
517                     b.templateDetailsDaySource.setText(R.string.template_details_source_literal);
518                     final Integer dateDay = header.getDateDay();
519                     b.templateDetailsDateDay.setText(
520                             (dateDay == null) ? null : String.valueOf(dateDay));
521                     b.templateDetailsDateDayLayout.setVisibility(View.VISIBLE);
522                 }
523                 else {
524                     b.templateDetailsDateDayLayout.setVisibility(View.GONE);
525                     b.templateDetailsDaySource.setText(
526                             String.format(Locale.US, groupNoText, header.getDateDayMatchGroup(),
527                                     getMatchGroupText(header.getDateDayMatchGroup())));
528                 }
529                 b.templateDetailsDaySourceLabel.setOnClickListener(
530                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
531                 b.templateDetailsDaySource.setOnClickListener(
532                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
533
534                 if (header.hasLiteralTransactionDescription()) {
535                     b.templateTransactionDescriptionSource.setText(
536                             R.string.template_details_source_literal);
537                     b.transactionDescription.setText(header.getTransactionDescription());
538                     b.transactionDescriptionLayout.setVisibility(View.VISIBLE);
539                 }
540                 else {
541                     b.transactionDescriptionLayout.setVisibility(View.GONE);
542                     b.templateTransactionDescriptionSource.setText(
543                             String.format(Locale.US, groupNoText,
544                                     header.getTransactionDescriptionMatchGroup(), getMatchGroupText(
545                                             header.getTransactionDescriptionMatchGroup())));
546
547                 }
548                 b.templateTransactionDescriptionSourceLabel.setOnClickListener(
549                         v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
550                 b.templateTransactionDescriptionSource.setOnClickListener(
551                         v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
552
553                 if (header.hasLiteralTransactionComment()) {
554                     b.templateTransactionCommentSource.setText(
555                             R.string.template_details_source_literal);
556                     b.transactionComment.setText(header.getTransactionComment());
557                     b.transactionCommentLayout.setVisibility(View.VISIBLE);
558                 }
559                 else {
560                     b.transactionCommentLayout.setVisibility(View.GONE);
561                     b.templateTransactionCommentSource.setText(String.format(Locale.US, groupNoText,
562                             header.getTransactionCommentMatchGroup(),
563                             getMatchGroupText(header.getTransactionCommentMatchGroup())));
564
565                 }
566                 b.templateTransactionCommentSourceLabel.setOnClickListener(
567                         v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
568                 b.templateTransactionCommentSource.setOnClickListener(
569                         v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
570
571                 b.templateDetailsHeadScanQrButton.setOnClickListener(this::scanTestQR);
572
573                 checkPatternError(header);
574             }
575             finally {
576                 enableUpdatePropagation();
577             }
578         }
579         private void checkPatternError(TemplateDetailsItem.Header item) {
580             if (item.getPatternError() != null) {
581                 b.patternLayout.setError(item.getPatternError());
582                 b.patternHintTitle.setVisibility(View.GONE);
583                 b.patternHintText.setVisibility(View.GONE);
584             }
585             else {
586                 b.patternLayout.setError(null);
587                 if (item.testMatch() != null) {
588                     b.patternHintText.setText(item.testMatch());
589                     b.patternHintTitle.setVisibility(View.VISIBLE);
590                     b.patternHintText.setVisibility(View.VISIBLE);
591                 }
592                 else {
593                     b.patternLayout.setError(null);
594                     b.patternHintTitle.setVisibility(View.GONE);
595                     b.patternHintText.setVisibility(View.GONE);
596                 }
597             }
598
599         }
600         private void scanTestQR(View view) {
601             QRScanCapableFragment.triggerQRScan();
602         }
603     }
604
605     public class AccountRow extends BaseItem {
606         private final TemplateDetailsAccountBinding b;
607         public AccountRow(@NonNull TemplateDetailsAccountBinding binding) {
608             super(binding.getRoot());
609             b = binding;
610
611             TextWatcher accountNameWatcher = new TextWatcher() {
612                 @Override
613                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
614                 @Override
615                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
616                 @Override
617                 public void afterTextChanged(Editable s) {
618                     if (updatePropagationDisabled)
619                         return;
620
621                     TemplateDetailsItem.AccountRow accRow = getItem();
622                     Logger.debug(D_TEMPLATE_UI,
623                             "Storing changed account name " + s + "; accRow=" + accRow);
624                     accRow.setAccountName(String.valueOf(s));
625
626                     mModel.applyList(null);
627                 }
628             };
629             b.templateDetailsAccountName.addTextChangedListener(accountNameWatcher);
630             b.templateDetailsAccountName.setAdapter(new AccountAutocompleteAdapter(b.getRoot()
631                                                                                     .getContext()));
632             b.templateDetailsAccountName.setOnItemClickListener(
633                     (parent, view, position, id) -> b.templateDetailsAccountName.setText(
634                             ((TextView) view).getText()));
635             TextWatcher accountCommentWatcher = new TextWatcher() {
636                 @Override
637                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
638                 @Override
639                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
640                 @Override
641                 public void afterTextChanged(Editable s) {
642                     if (updatePropagationDisabled)
643                         return;
644
645                     TemplateDetailsItem.AccountRow accRow = getItem();
646                     Logger.debug(D_TEMPLATE_UI,
647                             "Storing changed account comment " + s + "; accRow=" + accRow);
648                     accRow.setAccountComment(String.valueOf(s));
649
650                     mModel.applyList(null);
651                 }
652             };
653             b.templateDetailsAccountComment.addTextChangedListener(accountCommentWatcher);
654
655             b.templateDetailsAccountAmount.addTextChangedListener(new TextWatcher() {
656                 @Override
657                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
658                 }
659                 @Override
660                 public void onTextChanged(CharSequence s, int start, int before, int count) {
661                 }
662                 @Override
663                 public void afterTextChanged(Editable s) {
664                     if (updatePropagationDisabled)
665                         return;
666
667                     TemplateDetailsItem.AccountRow accRow = getItem();
668
669                     String str = String.valueOf(s);
670                     if (Misc.emptyIsNull(str) == null) {
671                         accRow.setAmount(null);
672                     }
673                     else {
674                         try {
675                             final float amount = Data.parseNumber(str);
676                             accRow.setAmount(amount);
677                             b.templateDetailsAccountAmountLayout.setError(null);
678
679                             Logger.debug(D_TEMPLATE_UI, String.format(Locale.US,
680                                     "Storing changed account amount %s [%4.2f]; accRow=%s", s,
681                                     amount, accRow));
682                         }
683                         catch (NumberFormatException | ParseException e) {
684                             b.templateDetailsAccountAmountLayout.setError("!");
685                         }
686                     }
687
688                     mModel.applyList(null);
689                 }
690             });
691             b.templateDetailsAccountAmount.setOnFocusChangeListener((v, hasFocus) -> {
692                 if (hasFocus)
693                     return;
694
695                 TemplateDetailsItem.AccountRow accRow = getItem();
696                 if (!accRow.hasLiteralAmount())
697                     return;
698                 Float amt = accRow.getAmount();
699                 if (amt == null)
700                     return;
701
702                 b.templateDetailsAccountAmount.setText(Data.formatNumber(amt));
703             });
704
705             b.negateAmountSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
706                 if (updatePropagationDisabled)
707                     return;
708
709                 getItem().setNegateAmount(isChecked);
710                 b.templateDetailsNegateAmountText.setText(
711                         isChecked ? R.string.template_account_change_amount_sign
712                                   : R.string.template_account_keep_amount_sign);
713             });
714             final View.OnClickListener negLabelClickListener =
715                     (view) -> b.negateAmountSwitch.toggle();
716             b.templateDetailsNegateAmountLabel.setOnClickListener(negLabelClickListener);
717             b.templateDetailsNegateAmountText.setOnClickListener(negLabelClickListener);
718             manageAccountLabelDrag();
719         }
720         @SuppressLint("ClickableViewAccessibility")
721         public void manageAccountLabelDrag() {
722             b.patternAccountLabel.setOnTouchListener((v, event) -> {
723                 if (event.getAction() == MotionEvent.ACTION_DOWN) {
724                     itemTouchHelper.startDrag(this);
725                 }
726                 return false;
727             });
728         }
729         @Override
730         void bind(TemplateDetailsItem item) {
731             disableUpdatePropagation();
732             try {
733                 final Resources resources = b.getRoot()
734                                              .getResources();
735                 String groupNoText = resources.getString(R.string.template_item_match_group_source);
736
737                 Logger.debug("drag", String.format(Locale.US, "Binding account id %d, pos %d at %d",
738                         item.getId(), item.getPosition(), getAdapterPosition()));
739                 TemplateDetailsItem.AccountRow accRow = item.asAccountRowItem();
740                 b.patternAccountLabel.setText(String.format(Locale.US,
741                         resources.getString(R.string.template_details_account_row_label),
742                         accRow.getPosition()));
743                 if (accRow.hasLiteralAccountName()) {
744                     b.templateDetailsAccountNameLayout.setVisibility(View.VISIBLE);
745                     b.templateDetailsAccountName.setText(accRow.getAccountName());
746                     b.templateDetailsAccountNameSource.setText(
747                             R.string.template_details_source_literal);
748                 }
749                 else {
750                     b.templateDetailsAccountNameLayout.setVisibility(View.GONE);
751                     b.templateDetailsAccountNameSource.setText(
752                             String.format(Locale.US, groupNoText, accRow.getAccountNameMatchGroup(),
753                                     getMatchGroupText(accRow.getAccountNameMatchGroup())));
754                 }
755
756                 if (accRow.hasLiteralAccountComment()) {
757                     b.templateDetailsAccountCommentLayout.setVisibility(View.VISIBLE);
758                     b.templateDetailsAccountComment.setText(accRow.getAccountComment());
759                     b.templateDetailsAccountCommentSource.setText(
760                             R.string.template_details_source_literal);
761                 }
762                 else {
763                     b.templateDetailsAccountCommentLayout.setVisibility(View.GONE);
764                     b.templateDetailsAccountCommentSource.setText(
765                             String.format(Locale.US, groupNoText,
766                                     accRow.getAccountCommentMatchGroup(),
767                                     getMatchGroupText(accRow.getAccountCommentMatchGroup())));
768                 }
769
770                 if (accRow.hasLiteralAmount()) {
771                     b.templateDetailsAccountAmountSource.setText(
772                             R.string.template_details_source_literal);
773                     b.templateDetailsAccountAmount.setVisibility(View.VISIBLE);
774                     Float amt = accRow.getAmount();
775                     b.templateDetailsAccountAmount.setText((amt == null) ? null : String.format(
776                             Data.locale.getValue(), "%,4.2f", (accRow.getAmount())));
777                     b.negateAmountSwitch.setVisibility(View.GONE);
778                     b.templateDetailsNegateAmountLabel.setVisibility(View.GONE);
779                     b.templateDetailsNegateAmountText.setVisibility(View.GONE);
780                 }
781                 else {
782                     b.templateDetailsAccountAmountSource.setText(
783                             String.format(Locale.US, groupNoText, accRow.getAmountMatchGroup(),
784                                     getMatchGroupText(accRow.getAmountMatchGroup())));
785                     b.templateDetailsAccountAmountLayout.setVisibility(View.GONE);
786                     b.negateAmountSwitch.setVisibility(View.VISIBLE);
787                     b.negateAmountSwitch.setChecked(accRow.isNegateAmount());
788                     b.templateDetailsNegateAmountText.setText(
789                             accRow.isNegateAmount() ? R.string.template_account_change_amount_sign
790                                                     : R.string.template_account_keep_amount_sign);
791                     b.templateDetailsNegateAmountLabel.setVisibility(View.VISIBLE);
792                     b.templateDetailsNegateAmountText.setVisibility(View.VISIBLE);
793                 }
794
795                 b.templateAccountNameSourceLabel.setOnClickListener(
796                         v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
797                 b.templateDetailsAccountNameSource.setOnClickListener(
798                         v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
799                 b.templateAccountCommentSourceLabel.setOnClickListener(
800                         v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
801                 b.templateDetailsAccountCommentSource.setOnClickListener(
802                         v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
803                 b.templateAccountAmountSourceLabel.setOnClickListener(
804                         v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
805                 b.templateDetailsAccountAmountSource.setOnClickListener(
806                         v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
807             }
808             finally {
809                 enableUpdatePropagation();
810             }
811         }
812         private @NotNull TemplateDetailsItem.AccountRow getItem() {
813             return differ.getCurrentList()
814                          .get(getAdapterPosition())
815                          .asAccountRowItem();
816         }
817         private void selectAccountRowDetailSource(View v, AccDetail detail) {
818             TemplateDetailsItem.AccountRow accRow = getItem();
819             final TemplateDetailsItem.Header header = getHeader();
820             Logger.debug(D_TEMPLATE_UI, "header is " + header);
821             TemplateDetailSourceSelectorFragment sel =
822                     TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
823                             header.getTestText());
824             sel.setOnSourceSelectedListener((literal, group) -> {
825                 if (literal) {
826                     switch (detail) {
827                         case ACCOUNT:
828                             accRow.switchToLiteralAccountName();
829                             break;
830                         case COMMENT:
831                             accRow.switchToLiteralAccountComment();
832                             break;
833                         case AMOUNT:
834                             accRow.switchToLiteralAmount();
835                             break;
836                         default:
837                             throw new IllegalStateException("Unexpected detail " + detail);
838                     }
839                 }
840                 else {
841                     switch (detail) {
842                         case ACCOUNT:
843                             accRow.setAccountNameMatchGroup(group);
844                             break;
845                         case COMMENT:
846                             accRow.setAccountCommentMatchGroup(group);
847                             break;
848                         case AMOUNT:
849                             accRow.setAmountMatchGroup(group);
850                             break;
851                         default:
852                             throw new IllegalStateException("Unexpected detail " + detail);
853                     }
854                 }
855
856                 notifyItemChanged(getAdapterPosition());
857             });
858             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
859             sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
860         }
861     }
862 }