]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/new_transaction/NewTransactionFragment.java
offer fallback templates only when no other template matches
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / new_transaction / NewTransactionFragment.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.new_transaction;
19
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.res.Resources;
23 import android.database.AbstractCursor;
24 import android.os.Bundle;
25 import android.os.ParcelFormatException;
26 import android.renderscript.RSInvalidStateException;
27 import android.view.LayoutInflater;
28 import android.view.Menu;
29 import android.view.MenuInflater;
30 import android.view.MenuItem;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.widget.ProgressBar;
34
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 import androidx.appcompat.app.AlertDialog;
38 import androidx.fragment.app.Fragment;
39 import androidx.fragment.app.FragmentActivity;
40 import androidx.lifecycle.LiveData;
41 import androidx.lifecycle.ViewModelProvider;
42 import androidx.recyclerview.widget.LinearLayoutManager;
43 import androidx.recyclerview.widget.RecyclerView;
44
45 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
46 import com.google.android.material.floatingactionbutton.FloatingActionButton;
47 import com.google.android.material.snackbar.BaseTransientBottomBar;
48 import com.google.android.material.snackbar.Snackbar;
49
50 import net.ktnx.mobileledger.R;
51 import net.ktnx.mobileledger.db.DB;
52 import net.ktnx.mobileledger.db.TemplateAccount;
53 import net.ktnx.mobileledger.db.TemplateHeader;
54 import net.ktnx.mobileledger.json.API;
55 import net.ktnx.mobileledger.model.Data;
56 import net.ktnx.mobileledger.model.LedgerTransaction;
57 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
58 import net.ktnx.mobileledger.model.MobileLedgerProfile;
59 import net.ktnx.mobileledger.ui.QRScanCapableFragment;
60 import net.ktnx.mobileledger.ui.templates.TemplatesActivity;
61 import net.ktnx.mobileledger.utils.Logger;
62 import net.ktnx.mobileledger.utils.Misc;
63 import net.ktnx.mobileledger.utils.SimpleDate;
64
65 import org.jetbrains.annotations.NotNull;
66
67 import java.util.ArrayList;
68 import java.util.List;
69 import java.util.Locale;
70 import java.util.regex.Matcher;
71 import java.util.regex.Pattern;
72
73 /**
74  * A simple {@link Fragment} subclass.
75  * Activities that contain this fragment must implement the
76  * {@link OnNewTransactionFragmentInteractionListener} interface
77  * to handle interaction events.
78  */
79
80 // TODO: offer to undo account remove-on-swipe
81
82 public class NewTransactionFragment extends QRScanCapableFragment {
83     private NewTransactionItemsAdapter listAdapter;
84     private NewTransactionModel viewModel;
85     private FloatingActionButton fab;
86     private OnNewTransactionFragmentInteractionListener mListener;
87     private MobileLedgerProfile mProfile;
88     public NewTransactionFragment() {
89         // Required empty public constructor
90         setHasOptionsMenu(true);
91     }
92     private void startNewPatternActivity(String scanned) {
93         Intent intent = new Intent(requireContext(), TemplatesActivity.class);
94         Bundle args = new Bundle();
95         args.putString(TemplatesActivity.ARG_ADD_TEMPLATE, scanned);
96         requireContext().startActivity(intent, args);
97     }
98     private void alertNoTemplateMatch(String scanned) {
99         MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
100         builder.setCancelable(true)
101                .setMessage(R.string.no_template_matches)
102                .setPositiveButton(R.string.add_button,
103                        (dialog, which) -> startNewPatternActivity(scanned))
104                .create()
105                .show();
106     }
107     protected void onQrScanned(String text) {
108         Logger.debug("qr", String.format("Got QR scan result [%s]", text));
109
110         if (Misc.emptyIsNull(text) == null)
111             return;
112
113         LiveData<List<TemplateHeader>> allTemplates = DB.get()
114                                                         .getTemplateDAO()
115                                                         .getTemplates();
116         allTemplates.observe(getViewLifecycleOwner(), templateHeaders -> {
117             ArrayList<TemplateHeader> matchingFallbackTemplates = new ArrayList<>();
118             ArrayList<TemplateHeader> matchingTemplates = new ArrayList<>();
119
120             for (TemplateHeader ph : templateHeaders) {
121                 String patternSource = ph.getRegularExpression();
122                 if (Misc.emptyIsNull(patternSource) == null)
123                     continue;
124                 try {
125                     Pattern pattern = Pattern.compile(patternSource);
126                     Matcher matcher = pattern.matcher(text);
127                     if (!matcher.matches())
128                         continue;
129
130                     Logger.debug("pattern",
131                             String.format("Pattern '%s' [%s] matches '%s'", ph.getName(),
132                                     patternSource, text));
133                     if (ph.isFallback())
134                         matchingFallbackTemplates.add(ph);
135                     else
136                         matchingTemplates.add(ph);
137                 }
138                 catch (ParcelFormatException e) {
139                     // ignored
140                     Logger.debug("pattern",
141                             String.format("Error compiling regular expression '%s'", patternSource),
142                             e);
143                 }
144             }
145
146             if (matchingTemplates.isEmpty())
147                 matchingTemplates = matchingFallbackTemplates;
148
149             if (matchingTemplates.isEmpty())
150                 alertNoTemplateMatch(text);
151             else if (matchingTemplates.size() == 1)
152                 applyTemplate(matchingTemplates.get(0), text);
153             else
154                 chooseTemplate(matchingTemplates, text);
155         });
156     }
157     private void chooseTemplate(ArrayList<TemplateHeader> matchingTemplates, String matchedText) {
158         final String templateNameColumn = "name";
159         AbstractCursor cursor = new AbstractCursor() {
160             @Override
161             public int getCount() {
162                 return matchingTemplates.size();
163             }
164             @Override
165             public String[] getColumnNames() {
166                 return new String[]{"_id", templateNameColumn};
167             }
168             @Override
169             public String getString(int column) {
170                 if (column == 0)
171                     return String.valueOf(getPosition());
172                 return matchingTemplates.get(getPosition())
173                                         .getName();
174             }
175             @Override
176             public short getShort(int column) {
177                 if (column == 0)
178                     return (short) getPosition();
179                 return -1;
180             }
181             @Override
182             public int getInt(int column) {
183                 return getShort(column);
184             }
185             @Override
186             public long getLong(int column) {
187                 return getShort(column);
188             }
189             @Override
190             public float getFloat(int column) {
191                 return getShort(column);
192             }
193             @Override
194             public double getDouble(int column) {
195                 return getShort(column);
196             }
197             @Override
198             public boolean isNull(int column) {
199                 return false;
200             }
201             @Override
202             public int getColumnCount() {
203                 return 2;
204             }
205         };
206
207         MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
208         builder.setCancelable(true)
209                .setTitle(R.string.choose_template_to_apply)
210                .setIcon(R.drawable.ic_baseline_auto_graph_24)
211                .setSingleChoiceItems(cursor, -1, templateNameColumn, (dialog, which) -> {
212                    applyTemplate(matchingTemplates.get(which), matchedText);
213                    dialog.dismiss();
214                })
215                .create()
216                .show();
217     }
218     private void applyTemplate(TemplateHeader patternHeader, String text) {
219         Pattern pattern = Pattern.compile(patternHeader.getRegularExpression());
220
221         Matcher m = pattern.matcher(text);
222
223         if (!m.matches()) {
224             Snackbar.make(requireView(), R.string.pattern_does_not_match,
225                     BaseTransientBottomBar.LENGTH_INDEFINITE)
226                     .show();
227             return;
228         }
229
230         SimpleDate transactionDate;
231         {
232             int day = extractIntFromMatches(m, patternHeader.getDateDayMatchGroup(),
233                     patternHeader.getDateDay());
234             int month = extractIntFromMatches(m, patternHeader.getDateMonthMatchGroup(),
235                     patternHeader.getDateMonth());
236             int year = extractIntFromMatches(m, patternHeader.getDateYearMatchGroup(),
237                     patternHeader.getDateYear());
238
239             SimpleDate today = SimpleDate.today();
240             if (year <= 0)
241                 year = today.year;
242             if (month <= 0)
243                 month = today.month;
244             if (day <= 0)
245                 day = today.day;
246
247             transactionDate = new SimpleDate(year, month, day);
248
249             Logger.debug("pattern", "setting transaction date to " + transactionDate);
250         }
251
252         NewTransactionModel.Item head = viewModel.getItem(0);
253         head.ensureType(NewTransactionModel.ItemType.generalData);
254         final String transactionDescription =
255                 extractStringFromMatches(m, patternHeader.getTransactionDescriptionMatchGroup(),
256                         patternHeader.getTransactionDescription());
257         head.setDescription(transactionDescription);
258         Logger.debug("pattern", "Setting transaction description to " + transactionDescription);
259         final String transactionComment =
260                 extractStringFromMatches(m, patternHeader.getTransactionCommentMatchGroup(),
261                         patternHeader.getTransactionComment());
262         head.setTransactionComment(transactionComment);
263         Logger.debug("pattern", "Setting transaction comment to " + transactionComment);
264         head.setDate(transactionDate);
265         listAdapter.notifyItemChanged(0);
266
267         DB.get()
268           .getTemplateDAO()
269           .getTemplateWithAccounts(patternHeader.getId())
270           .observe(getViewLifecycleOwner(), entry -> {
271               int rowIndex = 0;
272               final boolean accountsInInitialState = viewModel.accountsInInitialState();
273               for (TemplateAccount acc : entry.accounts) {
274                   rowIndex++;
275
276                   String accountName = extractStringFromMatches(m, acc.getAccountNameMatchGroup(),
277                           acc.getAccountName());
278                   String accountComment =
279                           extractStringFromMatches(m, acc.getAccountCommentMatchGroup(),
280                                   acc.getAccountComment());
281                   Float amount =
282                           extractFloatFromMatches(m, acc.getAmountMatchGroup(), acc.getAmount());
283                   if (amount != null && acc.getNegateAmount() != null && acc.getNegateAmount())
284                       amount = -amount;
285
286                   if (accountsInInitialState) {
287                       NewTransactionModel.Item item = viewModel.getItem(rowIndex);
288                       if (item == null) {
289                           Logger.debug("pattern", String.format(Locale.US,
290                                   "Adding new account item [%s][c:%s][a:%s]", accountName,
291                                   accountComment, amount));
292                           final LedgerTransactionAccount ledgerAccount =
293                                   new LedgerTransactionAccount(accountName);
294                           ledgerAccount.setComment(accountComment);
295                           if (amount != null)
296                               ledgerAccount.setAmount(amount);
297                           // TODO currency
298                           viewModel.addAccount(ledgerAccount);
299                           listAdapter.notifyItemInserted(viewModel.items.size() - 1);
300                       }
301                       else {
302                           Logger.debug("pattern", String.format(Locale.US,
303                                   "Stamping account item #%d [%s][c:%s][a:%s]", rowIndex,
304                                   accountName, accountComment, amount));
305
306                           item.setAccountName(accountName);
307                           item.setComment(accountComment);
308                           if (amount != null)
309                               item.getAccount()
310                                   .setAmount(amount);
311
312                           listAdapter.notifyItemChanged(rowIndex);
313                       }
314                   }
315                   else {
316                       final LedgerTransactionAccount transactionAccount =
317                               new LedgerTransactionAccount(accountName);
318                       transactionAccount.setComment(accountComment);
319                       if (amount != null)
320                           transactionAccount.setAmount(amount);
321                       // TODO currency
322                       Logger.debug("pattern", String.format(Locale.US,
323                               "Adding trailing account item [%s][c:%s][a:%s]", accountName,
324                               accountComment, amount));
325
326                       viewModel.addAccount(transactionAccount);
327                       listAdapter.notifyItemInserted(viewModel.items.size() - 1);
328                   }
329               }
330
331               listAdapter.checkTransactionSubmittable();
332           });
333     }
334     private int extractIntFromMatches(Matcher m, Integer group, Integer literal) {
335         if (literal != null)
336             return literal;
337
338         if (group != null) {
339             int grp = group;
340             if (grp > 0 & grp <= m.groupCount())
341                 try {
342                     return Integer.parseInt(m.group(grp));
343                 }
344                 catch (NumberFormatException e) {
345                     Snackbar.make(requireView(),
346                             "Error extracting transaction date: " + e.getMessage(),
347                             BaseTransientBottomBar.LENGTH_INDEFINITE)
348                             .show();
349                 }
350         }
351
352         return 0;
353     }
354     private String extractStringFromMatches(Matcher m, Integer group, String literal) {
355         if (literal != null)
356             return literal;
357
358         if (group != null) {
359             int grp = group;
360             if (grp > 0 & grp <= m.groupCount())
361                 return m.group(grp);
362         }
363
364         return null;
365     }
366     private Float extractFloatFromMatches(Matcher m, Integer group, Float literal) {
367         if (literal != null)
368             return literal;
369
370         if (group != null) {
371             int grp = group;
372             if (grp > 0 & grp <= m.groupCount())
373                 try {
374                     return Float.valueOf(m.group(grp));
375                 }
376                 catch (NumberFormatException e) {
377                     Snackbar.make(requireView(),
378                             "Error extracting transaction amount: " + e.getMessage(),
379                             BaseTransientBottomBar.LENGTH_INDEFINITE)
380                             .show();
381                 }
382         }
383
384         return null;
385     }
386     @Override
387     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
388         super.onCreateOptionsMenu(menu, inflater);
389         final FragmentActivity activity = getActivity();
390
391         inflater.inflate(R.menu.new_transaction_fragment, menu);
392
393         menu.findItem(R.id.scan_qr)
394             .setOnMenuItemClickListener(this::onScanQrAction);
395
396         menu.findItem(R.id.action_reset_new_transaction_activity)
397             .setOnMenuItemClickListener(item -> {
398                 listAdapter.reset();
399                 return true;
400             });
401
402         final MenuItem toggleCurrencyItem = menu.findItem(R.id.toggle_currency);
403         toggleCurrencyItem.setOnMenuItemClickListener(item -> {
404             viewModel.toggleCurrencyVisible();
405             return true;
406         });
407         if (activity != null)
408             viewModel.showCurrency.observe(activity, toggleCurrencyItem::setChecked);
409
410         final MenuItem toggleCommentsItem = menu.findItem(R.id.toggle_comments);
411         toggleCommentsItem.setOnMenuItemClickListener(item -> {
412             viewModel.toggleShowComments();
413             return true;
414         });
415         if (activity != null)
416             viewModel.showComments.observe(activity, toggleCommentsItem::setChecked);
417     }
418     private boolean onScanQrAction(MenuItem item) {
419         try {
420             scanQrLauncher.launch(null);
421         }
422         catch (Exception e) {
423             Logger.debug("qr", "Error launching QR scanner", e);
424         }
425
426         return true;
427     }
428     @Override
429     public View onCreateView(LayoutInflater inflater, ViewGroup container,
430                              Bundle savedInstanceState) {
431         // Inflate the layout for this fragment
432         return inflater.inflate(R.layout.fragment_new_transaction, container, false);
433     }
434
435     @Override
436     public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
437         super.onViewCreated(view, savedInstanceState);
438         FragmentActivity activity = getActivity();
439         if (activity == null)
440             throw new RSInvalidStateException(
441                     "getActivity() returned null within onActivityCreated()");
442
443         viewModel = new ViewModelProvider(activity).get(NewTransactionModel.class);
444         viewModel.observeDataProfile(this);
445         mProfile = Data.getProfile();
446         listAdapter = new NewTransactionItemsAdapter(viewModel, mProfile);
447
448         RecyclerView list = activity.findViewById(R.id.new_transaction_accounts);
449         list.setAdapter(listAdapter);
450         list.setLayoutManager(new LinearLayoutManager(activity));
451
452         Data.observeProfile(getViewLifecycleOwner(), profile -> {
453             mProfile = profile;
454             listAdapter.setProfile(profile);
455         });
456         listAdapter.notifyDataSetChanged();
457         viewModel.isSubmittable()
458                  .observe(getViewLifecycleOwner(), isSubmittable -> {
459                      if (isSubmittable) {
460                          if (fab != null) {
461                              fab.show();
462                          }
463                      }
464                      else {
465                          if (fab != null) {
466                              fab.hide();
467                          }
468                      }
469                  });
470 //        viewModel.checkTransactionSubmittable(listAdapter);
471
472         fab = activity.findViewById(R.id.fabAdd);
473         fab.setOnClickListener(v -> onFabPressed());
474
475         boolean keep = false;
476
477         Bundle args = getArguments();
478         if (args != null) {
479             String error = args.getString("error");
480             if (error != null) {
481                 Logger.debug("new-trans-f", String.format("Got error: %s", error));
482
483                 Context context = getContext();
484                 if (context != null) {
485                     AlertDialog.Builder builder = new AlertDialog.Builder(context);
486                     final Resources resources = context.getResources();
487                     final StringBuilder message = new StringBuilder();
488                     message.append(resources.getString(R.string.err_json_send_error_head));
489                     message.append("\n\n");
490                     message.append(error);
491                     if (mProfile.getApiVersion()
492                                 .equals(API.auto))
493                         message.append(
494                                 resources.getString(R.string.err_json_send_error_unsupported));
495                     else {
496                         message.append(resources.getString(R.string.err_json_send_error_tail));
497                         builder.setPositiveButton(R.string.btn_profile_options, (dialog, which) -> {
498                             Logger.debug("error", "will start profile editor");
499                             MobileLedgerProfile.startEditProfileActivity(context, mProfile);
500                         });
501                     }
502                     builder.setMessage(message);
503                     builder.create()
504                            .show();
505                 }
506                 else {
507                     Snackbar.make(list, error, Snackbar.LENGTH_INDEFINITE)
508                             .show();
509                 }
510                 keep = true;
511             }
512         }
513
514         int focused = 0;
515         if (savedInstanceState != null) {
516             keep |= savedInstanceState.getBoolean("keep", true);
517             focused = savedInstanceState.getInt("focused", 0);
518         }
519
520         if (!keep)
521             viewModel.reset();
522         else {
523             viewModel.setFocusedItem(focused);
524         }
525
526         ProgressBar p = activity.findViewById(R.id.progressBar);
527         viewModel.observeBusyFlag(getViewLifecycleOwner(), isBusy -> {
528             if (isBusy) {
529 //                Handler h = new Handler();
530 //                h.postDelayed(() -> {
531 //                    if (viewModel.getBusyFlag())
532 //                        p.setVisibility(View.VISIBLE);
533 //
534 //                }, 10);
535                 p.setVisibility(View.VISIBLE);
536             }
537             else
538                 p.setVisibility(View.INVISIBLE);
539         });
540     }
541     @Override
542     public void onSaveInstanceState(@NonNull Bundle outState) {
543         super.onSaveInstanceState(outState);
544         outState.putBoolean("keep", true);
545         final int focusedItem = viewModel.getFocusedItem();
546         outState.putInt("focused", focusedItem);
547     }
548     private void onFabPressed() {
549         fab.hide();
550         Misc.hideSoftKeyboard(this);
551         if (mListener != null) {
552             SimpleDate date = viewModel.getDate();
553             LedgerTransaction tr =
554                     new LedgerTransaction(null, date, viewModel.getDescription(), mProfile);
555
556             tr.setComment(viewModel.getComment());
557             LedgerTransactionAccount emptyAmountAccount = null;
558             float emptyAmountAccountBalance = 0;
559             for (int i = 0; i < viewModel.getAccountCount(); i++) {
560                 LedgerTransactionAccount acc =
561                         new LedgerTransactionAccount(viewModel.getAccount(i));
562                 if (acc.getAccountName()
563                        .trim()
564                        .isEmpty())
565                     continue;
566
567                 if (acc.isAmountSet()) {
568                     emptyAmountAccountBalance += acc.getAmount();
569                 }
570                 else {
571                     emptyAmountAccount = acc;
572                 }
573
574                 tr.addAccount(acc);
575             }
576
577             if (emptyAmountAccount != null)
578                 emptyAmountAccount.setAmount(-emptyAmountAccountBalance);
579
580             mListener.onTransactionSave(tr);
581         }
582     }
583
584     @Override
585     public void onAttach(@NotNull Context context) {
586         super.onAttach(context);
587         if (context instanceof OnNewTransactionFragmentInteractionListener) {
588             mListener = (OnNewTransactionFragmentInteractionListener) context;
589         }
590         else {
591             throw new RuntimeException(
592                     context.toString() + " must implement OnFragmentInteractionListener");
593         }
594     }
595
596     @Override
597     public void onDetach() {
598         super.onDetach();
599         mListener = null;
600     }
601
602     /**
603      * This interface must be implemented by activities that contain this
604      * fragment to allow an interaction in this fragment to be communicated
605      * to the activity and potentially other fragments contained in that
606      * activity.
607      * <p>
608      * See the Android Training lesson <a href=
609      * "http://developer.android.com/training/basics/fragments/communicating.html"
610      * >Communicating with Other Fragments</a> for more information.
611      */
612     public interface OnNewTransactionFragmentInteractionListener {
613         void onTransactionSave(LedgerTransaction tr);
614     }
615 }