]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemHolder.java
NT: convert row to ConstraintLayout
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / activity / NewTransactionItemHolder.java
1 /*
2  * Copyright © 2019 Damyan Ivanov.
3  * This file is part of MoLe.
4  * MoLe is free software: you can distribute it and/or modify it
5  * under the term of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your opinion), any later version.
8  *
9  * MoLe is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License terms for details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
16  */
17
18 package net.ktnx.mobileledger.ui.activity;
19
20 import android.annotation.SuppressLint;
21 import android.os.Build;
22 import android.text.Editable;
23 import android.text.TextWatcher;
24 import android.text.method.DigitsKeyListener;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.view.inputmethod.EditorInfo;
28 import android.widget.AutoCompleteTextView;
29 import android.widget.FrameLayout;
30 import android.widget.LinearLayout;
31 import android.widget.TextView;
32
33 import androidx.annotation.NonNull;
34 import androidx.lifecycle.Observer;
35 import androidx.recyclerview.widget.RecyclerView;
36
37 import net.ktnx.mobileledger.R;
38 import net.ktnx.mobileledger.async.DescriptionSelectedCallback;
39 import net.ktnx.mobileledger.model.Data;
40 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
41 import net.ktnx.mobileledger.model.MobileLedgerProfile;
42 import net.ktnx.mobileledger.ui.AutoCompleteTextViewWithClear;
43 import net.ktnx.mobileledger.ui.DatePickerFragment;
44 import net.ktnx.mobileledger.utils.Logger;
45 import net.ktnx.mobileledger.utils.MLDB;
46 import net.ktnx.mobileledger.utils.Misc;
47
48 import java.text.DecimalFormatSymbols;
49 import java.util.Calendar;
50 import java.util.Date;
51 import java.util.GregorianCalendar;
52 import java.util.Locale;
53
54 class NewTransactionItemHolder extends RecyclerView.ViewHolder
55         implements DatePickerFragment.DatePickedListener, DescriptionSelectedCallback {
56     private final String decimalSeparator;
57     private final String decimalDot;
58     private NewTransactionModel.Item item;
59     private TextView tvDate;
60     private AutoCompleteTextView tvDescription;
61     private AutoCompleteTextView tvAccount;
62     private TextView tvAmount;
63     private LinearLayout lHead;
64     private ViewGroup lAccount;
65     private FrameLayout lPadding;
66     private MobileLedgerProfile mProfile;
67     private Date date;
68     private Observer<Date> dateObserver;
69     private Observer<String> descriptionObserver;
70     private Observer<String> hintObserver;
71     private Observer<Integer> focusedAccountObserver;
72     private Observer<Integer> accountCountObserver;
73     private Observer<Boolean> editableObserver;
74     private boolean inUpdate = false;
75     private boolean syncingData = false;
76     NewTransactionItemHolder(@NonNull View itemView, NewTransactionItemsAdapter adapter) {
77         super(itemView);
78         tvAccount = itemView.findViewById(R.id.account_row_acc_name);
79         tvAmount = itemView.findViewById(R.id.account_row_acc_amounts);
80         tvDate = itemView.findViewById(R.id.new_transaction_date);
81         tvDescription = itemView.findViewById(R.id.new_transaction_description);
82         lHead = itemView.findViewById(R.id.ntr_data);
83         lAccount = itemView.findViewById(R.id.ntr_account);
84         lPadding = itemView.findViewById(R.id.ntr_padding);
85
86         tvDescription.setNextFocusForwardId(View.NO_ID);
87         tvAccount.setNextFocusForwardId(View.NO_ID);
88         tvAmount.setNextFocusForwardId(View.NO_ID); // magic!
89
90         tvDate.setOnClickListener(v -> pickTransactionDate());
91
92         mProfile = Data.profile.getValue();
93         if (mProfile == null)
94             throw new AssertionError();
95
96         View.OnFocusChangeListener focusMonitor = (v, hasFocus) -> {
97             if (hasFocus) {
98                 boolean wasSyncing = syncingData;
99                 syncingData = true;
100                 try {
101                     final int pos = getAdapterPosition();
102                     adapter.updateFocusedItem(pos);
103                     if (v instanceof AutoCompleteTextViewWithClear) {
104                         adapter.noteFocusIsOnAccount(pos);
105                     }
106                     else {
107                         adapter.noteFocusIsOnAmount(pos);
108                     }
109                 }
110                 finally {
111                     syncingData = wasSyncing;
112                 }
113             }
114         };
115
116         tvDescription.setOnFocusChangeListener(focusMonitor);
117         tvAccount.setOnFocusChangeListener(focusMonitor);
118         tvAmount.setOnFocusChangeListener(focusMonitor);
119
120         MLDB.hookAutocompletionAdapter(tvDescription.getContext(), tvDescription,
121                 MLDB.DESCRIPTION_HISTORY_TABLE, "description", false, adapter, mProfile);
122         MLDB.hookAutocompletionAdapter(tvAccount.getContext(), tvAccount, MLDB.ACCOUNTS_TABLE,
123                 "name", true, this, mProfile);
124
125         // FIXME: react on configuration (locale) changes
126         decimalSeparator = String.valueOf(DecimalFormatSymbols.getInstance()
127                                                               .getMonetaryDecimalSeparator());
128         decimalDot = ".";
129
130         final TextWatcher tw = new TextWatcher() {
131             @Override
132             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
133             }
134
135             @Override
136             public void onTextChanged(CharSequence s, int start, int before, int count) {
137             }
138
139             @Override
140             public void afterTextChanged(Editable s) {
141 //                debug("input", "text changed");
142                 if (inUpdate)
143                     return;
144
145                 Logger.debug("textWatcher", "calling syncData()");
146                 syncData();
147                 Logger.debug("textWatcher",
148                         "syncData() returned, checking if transaction is submittable");
149                 adapter.model.checkTransactionSubmittable(adapter);
150                 Logger.debug("textWatcher", "done");
151             }
152         };
153         final TextWatcher amountWatcher = new TextWatcher() {
154             @Override
155             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
156             }
157             @Override
158             public void onTextChanged(CharSequence s, int start, int before, int count) {
159
160             }
161             @Override
162             public void afterTextChanged(Editable s) {
163                 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
164                     // only one decimal separator is allowed
165                     // plus and minus are allowed only at the beginning
166                     String val = s.toString();
167                     if (val.isEmpty())
168                         tvAmount.setKeyListener(DigitsKeyListener.getInstance(
169                                 "0123456789+-" + decimalSeparator + decimalDot));
170                     else if (val.contains(decimalSeparator) || val.contains(decimalDot))
171                         tvAmount.setKeyListener(DigitsKeyListener.getInstance("0123456789"));
172                     else
173                         tvAmount.setKeyListener(DigitsKeyListener.getInstance(
174                                 "0123456789" + decimalSeparator + decimalDot));
175
176                     syncData();
177                     adapter.model.checkTransactionSubmittable(adapter);
178                 }
179             }
180         };
181         tvDescription.addTextChangedListener(tw);
182         tvAccount.addTextChangedListener(tw);
183         tvAmount.addTextChangedListener(amountWatcher);
184
185         // FIXME: react on locale changes
186         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
187             tvAmount.setKeyListener(DigitsKeyListener.getInstance(Locale.getDefault(), true, true));
188         else
189             tvAmount.setKeyListener(
190                     DigitsKeyListener.getInstance("0123456789+-" + decimalSeparator + decimalDot));
191
192         dateObserver = date -> {
193             if (syncingData)
194                 return;
195             syncingData = true;
196             try {
197                 tvDate.setText(item.getFormattedDate());
198             }
199             finally {
200                 syncingData = false;
201             }
202         };
203         descriptionObserver = description -> {
204             if (syncingData)
205                 return;
206             syncingData = true;
207             try {
208                 tvDescription.setText(description);
209             }
210             finally {
211                 syncingData = false;
212             }
213         };
214         hintObserver = hint -> {
215             if (syncingData)
216                 return;
217             syncingData = true;
218             try {
219                 if (hint == null)
220                     tvAmount.setHint(R.string.zero_amount);
221                 else
222                     tvAmount.setHint(hint);
223             }
224             finally {
225                 syncingData = false;
226             }
227         };
228         editableObserver = this::setEditable;
229         focusedAccountObserver = index -> {
230             if ((index != null) && index.equals(getAdapterPosition())) {
231                 switch (item.getType()) {
232                     case generalData:
233                         // bad idea - double pop-up, and not really necessary.
234                         // the user can tap the input to get the calendar
235                         //if (!tvDate.hasFocus()) tvDate.requestFocus();
236                         boolean focused = tvDescription.requestFocus();
237                         tvDescription.dismissDropDown();
238                         if (focused)
239                             Misc.showSoftKeyboard(
240                                     (NewTransactionActivity) tvDescription.getContext());
241                         break;
242                     case transactionRow:
243                         // do nothing if a row element already has the focus
244                         if (!itemView.hasFocus()) {
245                             if (item.focusIsOnAmount()) {
246                                 tvAmount.requestFocus();
247                             }
248                             else {
249                                 focused = tvAccount.requestFocus();
250                                 tvAccount.dismissDropDown();
251                                 if (focused)
252                                     Misc.showSoftKeyboard(
253                                             (NewTransactionActivity) tvAccount.getContext());
254                             }
255                         }
256
257                         break;
258                 }
259             }
260         };
261         accountCountObserver = count -> {
262             final int adapterPosition = getAdapterPosition();
263             final int layoutPosition = getLayoutPosition();
264             Logger.debug("holder",
265                     String.format(Locale.US, "count=%d; pos=%d, layoutPos=%d [%s]", count,
266                             adapterPosition, layoutPosition, item.getType()
267                                                                  .toString()
268                                                                  .concat(item.getType() ==
269                                                                          NewTransactionModel.ItemType.transactionRow
270                                                                          ? String.format(Locale.US,
271                                                                          "'%s'=%s",
272                                                                          item.getAccount()
273                                                                              .getAccountName(),
274                                                                          item.getAccount()
275                                                                              .isAmountSet()
276                                                                          ? String.format(Locale.US,
277                                                                                  "%.2f",
278                                                                                  item.getAccount()
279                                                                                      .getAmount())
280                                                                          : "unset") : "")));
281             if (adapterPosition == count)
282                 tvAmount.setImeOptions(EditorInfo.IME_ACTION_DONE);
283             else
284                 tvAmount.setImeOptions(EditorInfo.IME_ACTION_NEXT);
285         };
286     }
287     private void setEditable(Boolean editable) {
288         tvDate.setEnabled(editable);
289         tvDescription.setEnabled(editable);
290         tvAccount.setEnabled(editable);
291         tvAmount.setEnabled(editable);
292     }
293     private void beginUpdates() {
294         if (inUpdate)
295             throw new RuntimeException("Already in update mode");
296         inUpdate = true;
297     }
298     private void endUpdates() {
299         if (!inUpdate)
300             throw new RuntimeException("Not in update mode");
301         inUpdate = false;
302     }
303     /**
304      * syncData()
305      * <p>
306      * Stores the data from the UI elements into the model item
307      */
308     private void syncData() {
309         if (item == null)
310             return;
311
312         if (syncingData) {
313             Logger.debug("new-trans", "skipping syncData() loop");
314             return;
315         }
316
317         syncingData = true;
318
319         try {
320             switch (item.getType()) {
321                 case generalData:
322                     item.setDate(String.valueOf(tvDate.getText()));
323                     item.setDescription(String.valueOf(tvDescription.getText()));
324                     break;
325                 case transactionRow:
326                     item.getAccount()
327                         .setAccountName(String.valueOf(tvAccount.getText()));
328
329                     // TODO: handle multiple amounts
330                     String amount = String.valueOf(tvAmount.getText());
331                     amount = amount.trim();
332
333                     if (amount.isEmpty()) {
334                         item.getAccount()
335                             .resetAmount();
336                     }
337                     else {
338                         try {
339                             amount = amount.replace(decimalSeparator, decimalDot);
340                             item.getAccount()
341                                 .setAmount(Float.parseFloat(amount));
342                         }
343                         catch (NumberFormatException e) {
344                             Logger.debug("new-trans", String.format(
345                                     "assuming amount is not set due to number format exception. " +
346                                     "input was '%s'", amount));
347                             item.getAccount()
348                                 .resetAmount();
349                         }
350                     }
351
352                     break;
353                 case bottomFiller:
354                     throw new RuntimeException("Should not happen");
355             }
356         }
357         finally {
358             syncingData = false;
359         }
360     }
361     private void pickTransactionDate() {
362         DatePickerFragment picker = new DatePickerFragment();
363         picker.setFutureDates(mProfile.getFutureDates());
364         picker.setOnDatePickedListener(this);
365         picker.show(((NewTransactionActivity) tvDate.getContext()).getSupportFragmentManager(),
366                 "datePicker");
367     }
368     /**
369      * setData
370      *
371      * @param item updates the UI elements with the data from the model item
372      */
373     @SuppressLint("DefaultLocale")
374     public void setData(NewTransactionModel.Item item) {
375         beginUpdates();
376         try {
377             if (this.item != null && !this.item.equals(item)) {
378                 this.item.stopObservingDate(dateObserver);
379                 this.item.stopObservingDescription(descriptionObserver);
380                 this.item.stopObservingAmountHint(hintObserver);
381                 this.item.stopObservingEditableFlag(editableObserver);
382                 this.item.getModel()
383                          .stopObservingFocusedItem(focusedAccountObserver);
384                 this.item.getModel()
385                          .stopObservingAccountCount(accountCountObserver);
386
387                 this.item = null;
388             }
389
390             switch (item.getType()) {
391                 case generalData:
392                     tvDate.setText(item.getFormattedDate());
393                     tvDescription.setText(item.getDescription());
394                     lHead.setVisibility(View.VISIBLE);
395                     lAccount.setVisibility(View.GONE);
396                     lPadding.setVisibility(View.GONE);
397                     setEditable(true);
398                     break;
399                 case transactionRow:
400                     LedgerTransactionAccount acc = item.getAccount();
401                     tvAccount.setText(acc.getAccountName());
402                     if (acc.isAmountSet()) {
403                         tvAmount.setText(String.format("%1.2f", acc.getAmount()));
404                     }
405                     else {
406                         tvAmount.setText("");
407 //                        tvAmount.setHint(R.string.zero_amount);
408                     }
409                     tvAmount.setHint(item.getAmountHint());
410                     lHead.setVisibility(View.GONE);
411                     lAccount.setVisibility(View.VISIBLE);
412                     lPadding.setVisibility(View.GONE);
413                     setEditable(true);
414                     break;
415                 case bottomFiller:
416                     lHead.setVisibility(View.GONE);
417                     lAccount.setVisibility(View.GONE);
418                     lPadding.setVisibility(View.VISIBLE);
419                     setEditable(false);
420                     break;
421             }
422
423             if (this.item == null) { // was null or has changed
424                 this.item = item;
425                 final NewTransactionActivity activity =
426                         (NewTransactionActivity) tvDescription.getContext();
427                 item.observeDate(activity, dateObserver);
428                 item.observeDescription(activity, descriptionObserver);
429                 item.observeAmountHint(activity, hintObserver);
430                 item.observeEditableFlag(activity, editableObserver);
431                 item.getModel()
432                     .observeFocusedItem(activity, focusedAccountObserver);
433                 item.getModel()
434                     .observeAccountCount(activity, accountCountObserver);
435             }
436         }
437         finally {
438             endUpdates();
439         }
440     }
441     @Override
442     public void onDatePicked(int year, int month, int day) {
443         final Calendar c = GregorianCalendar.getInstance();
444         c.set(year, month, day);
445         item.setDate(c.getTime());
446         boolean focused = tvDescription.requestFocus();
447         if (focused)
448             Misc.showSoftKeyboard((NewTransactionActivity) tvAccount.getContext());
449
450     }
451     @Override
452     public void descriptionSelected(String description) {
453         tvAccount.setText(description);
454         tvAmount.requestFocus(View.FOCUS_FORWARD);
455     }
456 }