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