]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/templates/TemplateDetailsAdapter.java
whitespace
[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(),
252                                 item.getPosition()));
253             }
254         }
255         differ.submitList(items);
256     }
257     public String getMatchGroupText(int groupNumber) {
258         TemplateDetailsItem.Header header = getHeader();
259         Pattern p = header.getCompiledPattern();
260         if (p == null)
261             return null;
262
263         final String testText = Misc.nullIsEmpty(header.getTestText());
264         Matcher m = p.matcher(testText);
265         if (m.matches() && m.groupCount() >= groupNumber)
266             return m.group(groupNumber);
267         else
268             return null;
269     }
270     protected TemplateDetailsItem.Header getHeader() {
271         return differ.getCurrentList()
272                      .get(0)
273                      .asHeaderItem();
274     }
275
276     private enum HeaderDetail {DESCRIPTION, COMMENT, DATE_YEAR, DATE_MONTH, DATE_DAY}
277
278     private enum AccDetail {ACCOUNT, COMMENT, AMOUNT}
279
280     public abstract static class ViewHolder extends RecyclerView.ViewHolder {
281         ViewHolder(@NonNull View itemView) {
282             super(itemView);
283         }
284         abstract void bind(TemplateDetailsItem item);
285     }
286
287     private abstract static class BaseItem extends ViewHolder {
288         boolean updatePropagationDisabled = false;
289         BaseItem(@NonNull View itemView) {
290             super(itemView);
291         }
292         void disableUpdatePropagation() {
293             updatePropagationDisabled = true;
294         }
295         void enableUpdatePropagation() {
296             updatePropagationDisabled = false;
297         }
298     }
299
300     public class Header extends BaseItem {
301         private final TemplateDetailsHeaderBinding b;
302         boolean updatePropagationDisabled = false;
303         public Header(@NonNull TemplateDetailsHeaderBinding binding) {
304             super(binding.getRoot());
305             b = binding;
306
307             TextWatcher templateNameWatcher = new TextWatcher() {
308                 @Override
309                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
310                 @Override
311                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
312                 @Override
313                 public void afterTextChanged(Editable s) {
314                     if (updatePropagationDisabled)
315                         return;
316
317                     final TemplateDetailsItem.Header header = getItem();
318                     Logger.debug(D_TEMPLATE_UI,
319                             "Storing changed template name " + s + "; header=" + header);
320                     header.setName(String.valueOf(s));
321                 }
322             };
323             b.templateName.addTextChangedListener(templateNameWatcher);
324
325             TextWatcher patternWatcher = new TextWatcher() {
326                 @Override
327                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
328                 @Override
329                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
330                 @Override
331                 public void afterTextChanged(Editable s) {
332                     if (updatePropagationDisabled)
333                         return;
334
335                     final TemplateDetailsItem.Header header = getItem();
336                     Logger.debug(D_TEMPLATE_UI,
337                             "Storing changed pattern " + s + "; header=" + header);
338                     header.setPattern(String.valueOf(s));
339
340                     checkPatternError(header);
341                 }
342             };
343             b.pattern.addTextChangedListener(patternWatcher);
344
345             TextWatcher testTextWatcher = new TextWatcher() {
346                 @Override
347                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
348                 @Override
349                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
350                 @Override
351                 public void afterTextChanged(Editable s) {
352                     if (updatePropagationDisabled)
353                         return;
354
355                     final TemplateDetailsItem.Header header = getItem();
356                     Logger.debug(D_TEMPLATE_UI,
357                             "Storing changed test text " + s + "; header=" + header);
358                     header.setTestText(String.valueOf(s));
359
360                     checkPatternError(header);
361                 }
362             };
363             b.testText.addTextChangedListener(testTextWatcher);
364
365             TextWatcher transactionDescriptionWatcher = new TextWatcher() {
366                 @Override
367                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
368                 }
369                 @Override
370                 public void onTextChanged(CharSequence s, int start, int before, int count) {
371                 }
372                 @Override
373                 public void afterTextChanged(Editable s) {
374                     if (updatePropagationDisabled)
375                         return;
376
377                     final TemplateDetailsItem.Header header = getItem();
378                     Logger.debug(D_TEMPLATE_UI,
379                             "Storing changed transaction description " + s + "; header=" + header);
380                     header.setTransactionDescription(String.valueOf(s));
381                 }
382             };
383             b.transactionDescription.addTextChangedListener(transactionDescriptionWatcher);
384             TextWatcher transactionCommentWatcher = new TextWatcher() {
385                 @Override
386                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
387                 }
388                 @Override
389                 public void onTextChanged(CharSequence s, int start, int before, int count) {
390                 }
391                 @Override
392                 public void afterTextChanged(Editable s) {
393                     if (updatePropagationDisabled)
394                         return;
395
396                     final TemplateDetailsItem.Header header = getItem();
397                     Logger.debug(D_TEMPLATE_UI,
398                             "Storing changed transaction description " + s + "; header=" + header);
399                     header.setTransactionComment(String.valueOf(s));
400                 }
401             };
402             b.transactionComment.addTextChangedListener(transactionCommentWatcher);
403
404             b.templateIsFallbackSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
405                 if (updatePropagationDisabled)
406                     return;
407
408                 getItem().setFallback(isChecked);
409                 b.templateIsFallbackText.setText(isChecked ? R.string.template_is_fallback_yes
410                                                            : R.string.template_is_fallback_no);
411             });
412             final View.OnClickListener fallbackLabelClickListener =
413                     (view) -> b.templateIsFallbackSwitch.toggle();
414             b.templateIsFallbackLabel.setOnClickListener(fallbackLabelClickListener);
415             b.templateIsFallbackText.setOnClickListener(fallbackLabelClickListener);
416         }
417         @NotNull
418         private TemplateDetailsItem.Header getItem() {
419             int pos = getAdapterPosition();
420             return differ.getCurrentList()
421                          .get(pos)
422                          .asHeaderItem();
423         }
424         private void selectHeaderDetailSource(View v, HeaderDetail detail) {
425             TemplateDetailsItem.Header header = getItem();
426             Logger.debug(D_TEMPLATE_UI, "header is " + header);
427             TemplateDetailSourceSelectorFragment sel =
428                     TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
429                             header.getTestText());
430             sel.setOnSourceSelectedListener((literal, group) -> {
431                 if (literal) {
432                     switch (detail) {
433                         case DESCRIPTION:
434                             header.switchToLiteralTransactionDescription();
435                             break;
436                         case COMMENT:
437                             header.switchToLiteralTransactionComment();
438                             break;
439                         case DATE_YEAR:
440                             header.switchToLiteralDateYear();
441                             break;
442                         case DATE_MONTH:
443                             header.switchToLiteralDateMonth();
444                             break;
445                         case DATE_DAY:
446                             header.switchToLiteralDateDay();
447                             break;
448                         default:
449                             throw new IllegalStateException("Unexpected detail " + detail);
450                     }
451                 }
452                 else {
453                     switch (detail) {
454                         case DESCRIPTION:
455                             header.setTransactionDescriptionMatchGroup(group);
456                             break;
457                         case COMMENT:
458                             header.setTransactionCommentMatchGroup(group);
459                             break;
460                         case DATE_YEAR:
461                             header.setDateYearMatchGroup(group);
462                             break;
463                         case DATE_MONTH:
464                             header.setDateMonthMatchGroup(group);
465                             break;
466                         case DATE_DAY:
467                             header.setDateDayMatchGroup(group);
468                             break;
469                         default:
470                             throw new IllegalStateException("Unexpected detail " + detail);
471                     }
472                 }
473
474                 notifyItemChanged(getAdapterPosition());
475             });
476             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
477             sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
478         }
479         @Override
480         void bind(TemplateDetailsItem item) {
481             TemplateDetailsItem.Header header = item.asHeaderItem();
482             Logger.debug(D_TEMPLATE_UI, "Binding to header " + header);
483
484             disableUpdatePropagation();
485             try {
486                 String groupNoText = b.getRoot()
487                                       .getResources()
488                                       .getString(R.string.template_item_match_group_source);
489
490                 b.templateName.setText(header.getName());
491                 b.pattern.setText(header.getPattern());
492                 b.testText.setText(header.getTestText());
493
494                 if (header.hasLiteralDateYear()) {
495                     b.templateDetailsYearSource.setText(R.string.template_details_source_literal);
496                     final Integer dateYear = header.getDateYear();
497                     b.templateDetailsDateYear.setText(
498                             (dateYear == null) ? null : String.valueOf(dateYear));
499                     b.templateDetailsDateYearLayout.setVisibility(View.VISIBLE);
500                 }
501                 else {
502                     b.templateDetailsDateYearLayout.setVisibility(View.GONE);
503                     b.templateDetailsYearSource.setText(
504                             String.format(Locale.US, groupNoText, header.getDateYearMatchGroup(),
505                                     getMatchGroupText(header.getDateYearMatchGroup())));
506                 }
507                 b.templateDetailsYearSourceLabel.setOnClickListener(
508                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
509                 b.templateDetailsYearSource.setOnClickListener(
510                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_YEAR));
511
512                 if (header.hasLiteralDateMonth()) {
513                     b.templateDetailsMonthSource.setText(R.string.template_details_source_literal);
514                     final Integer dateMonth = header.getDateMonth();
515                     b.templateDetailsDateMonth.setText(
516                             (dateMonth == null) ? null : String.valueOf(dateMonth));
517                     b.templateDetailsDateMonthLayout.setVisibility(View.VISIBLE);
518                 }
519                 else {
520                     b.templateDetailsDateMonthLayout.setVisibility(View.GONE);
521                     b.templateDetailsMonthSource.setText(
522                             String.format(Locale.US, groupNoText, header.getDateMonthMatchGroup(),
523                                     getMatchGroupText(header.getDateMonthMatchGroup())));
524                 }
525                 b.templateDetailsMonthSourceLabel.setOnClickListener(
526                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
527                 b.templateDetailsMonthSource.setOnClickListener(
528                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_MONTH));
529
530                 if (header.hasLiteralDateDay()) {
531                     b.templateDetailsDaySource.setText(R.string.template_details_source_literal);
532                     final Integer dateDay = header.getDateDay();
533                     b.templateDetailsDateDay.setText(
534                             (dateDay == null) ? null : String.valueOf(dateDay));
535                     b.templateDetailsDateDayLayout.setVisibility(View.VISIBLE);
536                 }
537                 else {
538                     b.templateDetailsDateDayLayout.setVisibility(View.GONE);
539                     b.templateDetailsDaySource.setText(
540                             String.format(Locale.US, groupNoText, header.getDateDayMatchGroup(),
541                                     getMatchGroupText(header.getDateDayMatchGroup())));
542                 }
543                 b.templateDetailsDaySourceLabel.setOnClickListener(
544                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
545                 b.templateDetailsDaySource.setOnClickListener(
546                         v -> selectHeaderDetailSource(v, HeaderDetail.DATE_DAY));
547
548                 if (header.hasLiteralTransactionDescription()) {
549                     b.templateTransactionDescriptionSource.setText(
550                             R.string.template_details_source_literal);
551                     b.transactionDescription.setText(header.getTransactionDescription());
552                     b.transactionDescriptionLayout.setVisibility(View.VISIBLE);
553                 }
554                 else {
555                     b.transactionDescriptionLayout.setVisibility(View.GONE);
556                     b.templateTransactionDescriptionSource.setText(
557                             String.format(Locale.US, groupNoText,
558                                     header.getTransactionDescriptionMatchGroup(), getMatchGroupText(
559                                             header.getTransactionDescriptionMatchGroup())));
560
561                 }
562                 b.templateTransactionDescriptionSourceLabel.setOnClickListener(
563                         v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
564                 b.templateTransactionDescriptionSource.setOnClickListener(
565                         v -> selectHeaderDetailSource(v, HeaderDetail.DESCRIPTION));
566
567                 if (header.hasLiteralTransactionComment()) {
568                     b.templateTransactionCommentSource.setText(
569                             R.string.template_details_source_literal);
570                     b.transactionComment.setText(header.getTransactionComment());
571                     b.transactionCommentLayout.setVisibility(View.VISIBLE);
572                 }
573                 else {
574                     b.transactionCommentLayout.setVisibility(View.GONE);
575                     b.templateTransactionCommentSource.setText(String.format(Locale.US, groupNoText,
576                             header.getTransactionCommentMatchGroup(),
577                             getMatchGroupText(header.getTransactionCommentMatchGroup())));
578
579                 }
580                 b.templateTransactionCommentSourceLabel.setOnClickListener(
581                         v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
582                 b.templateTransactionCommentSource.setOnClickListener(
583                         v -> selectHeaderDetailSource(v, HeaderDetail.COMMENT));
584
585                 b.templateDetailsHeadScanQrButton.setOnClickListener(this::scanTestQR);
586
587                 b.templateIsFallbackSwitch.setChecked(header.isFallback());
588                 b.templateIsFallbackText.setText(
589                         header.isFallback() ? R.string.template_is_fallback_yes
590                                             : R.string.template_is_fallback_no);
591
592                 checkPatternError(header);
593             }
594             finally {
595                 enableUpdatePropagation();
596             }
597         }
598         private void checkPatternError(TemplateDetailsItem.Header item) {
599             if (item.getPatternError() != null) {
600                 b.patternLayout.setError(item.getPatternError());
601                 b.patternHintTitle.setVisibility(View.GONE);
602                 b.patternHintText.setVisibility(View.GONE);
603             }
604             else {
605                 b.patternLayout.setError(null);
606                 if (item.testMatch() != null) {
607                     b.patternHintText.setText(item.testMatch());
608                     b.patternHintTitle.setVisibility(View.VISIBLE);
609                     b.patternHintText.setVisibility(View.VISIBLE);
610                 }
611                 else {
612                     b.patternLayout.setError(null);
613                     b.patternHintTitle.setVisibility(View.GONE);
614                     b.patternHintText.setVisibility(View.GONE);
615                 }
616             }
617
618         }
619         private void scanTestQR(View view) {
620             QRScanCapableFragment.triggerQRScan();
621         }
622     }
623
624     public class AccountRow extends BaseItem {
625         private final TemplateDetailsAccountBinding b;
626         public AccountRow(@NonNull TemplateDetailsAccountBinding binding) {
627             super(binding.getRoot());
628             b = binding;
629
630             TextWatcher accountNameWatcher = new TextWatcher() {
631                 @Override
632                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
633                 @Override
634                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
635                 @Override
636                 public void afterTextChanged(Editable s) {
637                     if (updatePropagationDisabled)
638                         return;
639
640                     TemplateDetailsItem.AccountRow accRow = getItem();
641                     Logger.debug(D_TEMPLATE_UI,
642                             "Storing changed account name " + s + "; accRow=" + accRow);
643                     accRow.setAccountName(String.valueOf(s));
644
645                     mModel.applyList(null);
646                 }
647             };
648             b.templateDetailsAccountName.addTextChangedListener(accountNameWatcher);
649             b.templateDetailsAccountName.setAdapter(new AccountAutocompleteAdapter(b.getRoot()
650                                                                                     .getContext()));
651             b.templateDetailsAccountName.setOnItemClickListener(
652                     (parent, view, position, id) -> b.templateDetailsAccountName.setText(
653                             ((TextView) view).getText()));
654             TextWatcher accountCommentWatcher = new TextWatcher() {
655                 @Override
656                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
657                 @Override
658                 public void onTextChanged(CharSequence s, int start, int before, int count) {}
659                 @Override
660                 public void afterTextChanged(Editable s) {
661                     if (updatePropagationDisabled)
662                         return;
663
664                     TemplateDetailsItem.AccountRow accRow = getItem();
665                     Logger.debug(D_TEMPLATE_UI,
666                             "Storing changed account comment " + s + "; accRow=" + accRow);
667                     accRow.setAccountComment(String.valueOf(s));
668
669                     mModel.applyList(null);
670                 }
671             };
672             b.templateDetailsAccountComment.addTextChangedListener(accountCommentWatcher);
673
674             b.templateDetailsAccountAmount.addTextChangedListener(new TextWatcher() {
675                 @Override
676                 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
677                 }
678                 @Override
679                 public void onTextChanged(CharSequence s, int start, int before, int count) {
680                 }
681                 @Override
682                 public void afterTextChanged(Editable s) {
683                     if (updatePropagationDisabled)
684                         return;
685
686                     TemplateDetailsItem.AccountRow accRow = getItem();
687
688                     String str = String.valueOf(s);
689                     if (Misc.emptyIsNull(str) == null) {
690                         accRow.setAmount(null);
691                     }
692                     else {
693                         try {
694                             final float amount = Data.parseNumber(str);
695                             accRow.setAmount(amount);
696                             b.templateDetailsAccountAmountLayout.setError(null);
697
698                             Logger.debug(D_TEMPLATE_UI, String.format(Locale.US,
699                                     "Storing changed account amount %s [%4.2f]; accRow=%s", s,
700                                     amount, accRow));
701                         }
702                         catch (NumberFormatException | ParseException e) {
703                             b.templateDetailsAccountAmountLayout.setError("!");
704                         }
705                     }
706
707                     mModel.applyList(null);
708                 }
709             });
710             b.templateDetailsAccountAmount.setOnFocusChangeListener((v, hasFocus) -> {
711                 if (hasFocus)
712                     return;
713
714                 TemplateDetailsItem.AccountRow accRow = getItem();
715                 if (!accRow.hasLiteralAmount())
716                     return;
717                 Float amt = accRow.getAmount();
718                 if (amt == null)
719                     return;
720
721                 b.templateDetailsAccountAmount.setText(Data.formatNumber(amt));
722             });
723
724             b.negateAmountSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
725                 if (updatePropagationDisabled)
726                     return;
727
728                 getItem().setNegateAmount(isChecked);
729                 b.templateDetailsNegateAmountText.setText(
730                         isChecked ? R.string.template_account_change_amount_sign
731                                   : R.string.template_account_keep_amount_sign);
732             });
733             final View.OnClickListener negLabelClickListener =
734                     (view) -> b.negateAmountSwitch.toggle();
735             b.templateDetailsNegateAmountLabel.setOnClickListener(negLabelClickListener);
736             b.templateDetailsNegateAmountText.setOnClickListener(negLabelClickListener);
737             manageAccountLabelDrag();
738         }
739         @SuppressLint("ClickableViewAccessibility")
740         public void manageAccountLabelDrag() {
741             b.patternAccountLabel.setOnTouchListener((v, event) -> {
742                 if (event.getAction() == MotionEvent.ACTION_DOWN) {
743                     itemTouchHelper.startDrag(this);
744                 }
745                 return false;
746             });
747         }
748         @Override
749         void bind(TemplateDetailsItem item) {
750             disableUpdatePropagation();
751             try {
752                 final Resources resources = b.getRoot()
753                                              .getResources();
754                 String groupNoText = resources.getString(R.string.template_item_match_group_source);
755
756                 Logger.debug("drag", String.format(Locale.US, "Binding account id %d, pos %d at %d",
757                         item.getId(), item.getPosition(), getAdapterPosition()));
758                 TemplateDetailsItem.AccountRow accRow = item.asAccountRowItem();
759                 b.patternAccountLabel.setText(String.format(Locale.US,
760                         resources.getString(R.string.template_details_account_row_label),
761                         accRow.getPosition()));
762                 if (accRow.hasLiteralAccountName()) {
763                     b.templateDetailsAccountNameLayout.setVisibility(View.VISIBLE);
764                     b.templateDetailsAccountName.setText(accRow.getAccountName());
765                     b.templateDetailsAccountNameSource.setText(
766                             R.string.template_details_source_literal);
767                 }
768                 else {
769                     b.templateDetailsAccountNameLayout.setVisibility(View.GONE);
770                     b.templateDetailsAccountNameSource.setText(
771                             String.format(Locale.US, groupNoText, accRow.getAccountNameMatchGroup(),
772                                     getMatchGroupText(accRow.getAccountNameMatchGroup())));
773                 }
774
775                 if (accRow.hasLiteralAccountComment()) {
776                     b.templateDetailsAccountCommentLayout.setVisibility(View.VISIBLE);
777                     b.templateDetailsAccountComment.setText(accRow.getAccountComment());
778                     b.templateDetailsAccountCommentSource.setText(
779                             R.string.template_details_source_literal);
780                 }
781                 else {
782                     b.templateDetailsAccountCommentLayout.setVisibility(View.GONE);
783                     b.templateDetailsAccountCommentSource.setText(
784                             String.format(Locale.US, groupNoText,
785                                     accRow.getAccountCommentMatchGroup(),
786                                     getMatchGroupText(accRow.getAccountCommentMatchGroup())));
787                 }
788
789                 if (accRow.hasLiteralAmount()) {
790                     b.templateDetailsAccountAmountSource.setText(
791                             R.string.template_details_source_literal);
792                     b.templateDetailsAccountAmount.setVisibility(View.VISIBLE);
793                     Float amt = accRow.getAmount();
794                     b.templateDetailsAccountAmount.setText((amt == null) ? null : String.format(
795                             Data.locale.getValue(), "%,4.2f", (accRow.getAmount())));
796                     b.negateAmountSwitch.setVisibility(View.GONE);
797                     b.templateDetailsNegateAmountLabel.setVisibility(View.GONE);
798                     b.templateDetailsNegateAmountText.setVisibility(View.GONE);
799                 }
800                 else {
801                     b.templateDetailsAccountAmountSource.setText(
802                             String.format(Locale.US, groupNoText, accRow.getAmountMatchGroup(),
803                                     getMatchGroupText(accRow.getAmountMatchGroup())));
804                     b.templateDetailsAccountAmountLayout.setVisibility(View.GONE);
805                     b.negateAmountSwitch.setVisibility(View.VISIBLE);
806                     b.negateAmountSwitch.setChecked(accRow.isNegateAmount());
807                     b.templateDetailsNegateAmountText.setText(
808                             accRow.isNegateAmount() ? R.string.template_account_change_amount_sign
809                                                     : R.string.template_account_keep_amount_sign);
810                     b.templateDetailsNegateAmountLabel.setVisibility(View.VISIBLE);
811                     b.templateDetailsNegateAmountText.setVisibility(View.VISIBLE);
812                 }
813
814                 b.templateAccountNameSourceLabel.setOnClickListener(
815                         v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
816                 b.templateDetailsAccountNameSource.setOnClickListener(
817                         v -> selectAccountRowDetailSource(v, AccDetail.ACCOUNT));
818                 b.templateAccountCommentSourceLabel.setOnClickListener(
819                         v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
820                 b.templateDetailsAccountCommentSource.setOnClickListener(
821                         v -> selectAccountRowDetailSource(v, AccDetail.COMMENT));
822                 b.templateAccountAmountSourceLabel.setOnClickListener(
823                         v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
824                 b.templateDetailsAccountAmountSource.setOnClickListener(
825                         v -> selectAccountRowDetailSource(v, AccDetail.AMOUNT));
826             }
827             finally {
828                 enableUpdatePropagation();
829             }
830         }
831         private @NotNull TemplateDetailsItem.AccountRow getItem() {
832             return differ.getCurrentList()
833                          .get(getAdapterPosition())
834                          .asAccountRowItem();
835         }
836         private void selectAccountRowDetailSource(View v, AccDetail detail) {
837             TemplateDetailsItem.AccountRow accRow = getItem();
838             final TemplateDetailsItem.Header header = getHeader();
839             Logger.debug(D_TEMPLATE_UI, "header is " + header);
840             TemplateDetailSourceSelectorFragment sel =
841                     TemplateDetailSourceSelectorFragment.newInstance(1, header.getPattern(),
842                             header.getTestText());
843             sel.setOnSourceSelectedListener((literal, group) -> {
844                 if (literal) {
845                     switch (detail) {
846                         case ACCOUNT:
847                             accRow.switchToLiteralAccountName();
848                             break;
849                         case COMMENT:
850                             accRow.switchToLiteralAccountComment();
851                             break;
852                         case AMOUNT:
853                             accRow.switchToLiteralAmount();
854                             break;
855                         default:
856                             throw new IllegalStateException("Unexpected detail " + detail);
857                     }
858                 }
859                 else {
860                     switch (detail) {
861                         case ACCOUNT:
862                             accRow.setAccountNameMatchGroup(group);
863                             break;
864                         case COMMENT:
865                             accRow.setAccountCommentMatchGroup(group);
866                             break;
867                         case AMOUNT:
868                             accRow.setAmountMatchGroup(group);
869                             break;
870                         default:
871                             throw new IllegalStateException("Unexpected detail " + detail);
872                     }
873                 }
874
875                 notifyItemChanged(getAdapterPosition());
876             });
877             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
878             sel.show(activity.getSupportFragmentManager(), "template-details-source-selector");
879         }
880     }
881 }