]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
profile editor: streamlined handling of authentication switch
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / profiles / ProfileDetailFragment.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.profiles;
19
20 import android.app.Activity;
21 import android.app.AlertDialog;
22 import android.graphics.Typeface;
23 import android.os.Bundle;
24 import android.text.Editable;
25 import android.text.TextWatcher;
26 import android.view.LayoutInflater;
27 import android.view.Menu;
28 import android.view.MenuInflater;
29 import android.view.MenuItem;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.LinearLayout;
33 import android.widget.PopupMenu;
34 import android.widget.Switch;
35 import android.widget.TextView;
36
37 import androidx.annotation.NonNull;
38 import androidx.annotation.Nullable;
39 import androidx.appcompat.app.AppCompatActivity;
40 import androidx.fragment.app.Fragment;
41 import androidx.fragment.app.FragmentActivity;
42 import androidx.lifecycle.LifecycleOwner;
43 import androidx.lifecycle.ViewModelProvider;
44
45 import com.google.android.material.appbar.CollapsingToolbarLayout;
46 import com.google.android.material.floatingactionbutton.FloatingActionButton;
47 import com.google.android.material.textfield.TextInputLayout;
48
49 import net.ktnx.mobileledger.BuildConfig;
50 import net.ktnx.mobileledger.R;
51 import net.ktnx.mobileledger.async.SendTransactionTask;
52 import net.ktnx.mobileledger.model.Data;
53 import net.ktnx.mobileledger.model.MobileLedgerProfile;
54 import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
55 import net.ktnx.mobileledger.ui.HueRingDialog;
56 import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
57 import net.ktnx.mobileledger.utils.Colors;
58 import net.ktnx.mobileledger.utils.Misc;
59
60 import org.jetbrains.annotations.NonNls;
61 import org.jetbrains.annotations.NotNull;
62
63 import java.net.MalformedURLException;
64 import java.net.URL;
65 import java.util.ArrayList;
66 import java.util.Objects;
67
68 import static net.ktnx.mobileledger.utils.Colors.profileThemeId;
69 import static net.ktnx.mobileledger.utils.Logger.debug;
70
71 /**
72  * A fragment representing a single Profile detail screen.
73  * a {@link ProfileDetailActivity}
74  * on handsets.
75  */
76 public class ProfileDetailFragment extends Fragment {
77     /**
78      * The fragment argument representing the item ID that this fragment
79      * represents.
80      */
81     public static final String ARG_ITEM_ID = "item_id";
82     public static final String ARG_HUE = "hue";
83     @NonNls
84
85     /**
86      * The content this fragment is presenting.
87      */ private MobileLedgerProfile mProfile;
88     private TextView url;
89     private TextView defaultCommodity;
90     private View defaultCommodityLayout;
91     private boolean defaultCommoditySet;
92     private TextInputLayout urlLayout;
93     private LinearLayout authParams;
94     private Switch useAuthentication;
95     private TextView userName;
96     private TextInputLayout userNameLayout;
97     private TextView password;
98     private TextInputLayout passwordLayout;
99     private TextView profileName;
100     private TextInputLayout profileNameLayout;
101     private TextView preferredAccountsFilter;
102     private TextInputLayout preferredAccountsFilterLayout;
103     private View huePickerView;
104     private View insecureWarningText;
105     private TextView futureDatesText;
106     private View futureDatesLayout;
107     private TextView apiVersionText;
108     private boolean syncingModelFromUI = false;
109     /**
110      * Mandatory empty constructor for the fragment manager to instantiate the
111      * fragment (e.g. upon screen orientation changes).
112      */
113     public ProfileDetailFragment() {
114     }
115     @Override
116     public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
117         debug("profiles", "[fragment] Creating profile details options menu");
118         super.onCreateOptionsMenu(menu, inflater);
119         inflater.inflate(R.menu.profile_details, menu);
120         final MenuItem menuDeleteProfile = menu.findItem(R.id.menuDelete);
121         menuDeleteProfile.setOnMenuItemClickListener(item -> {
122             AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
123             builder.setTitle(mProfile.getName());
124             builder.setMessage(R.string.remove_profile_dialog_message);
125             builder.setPositiveButton(R.string.Remove, (dialog, which) -> {
126                 debug("profiles",
127                         String.format("[fragment] removing profile %s", mProfile.getUuid()));
128                 mProfile.removeFromDB();
129                 ArrayList<MobileLedgerProfile> oldList = Data.profiles.getValue();
130                 if (oldList == null)
131                     throw new AssertionError();
132                 ArrayList<MobileLedgerProfile> newList = new ArrayList<>(oldList);
133                 newList.remove(mProfile);
134                 Data.profiles.setValue(newList);
135                 if (mProfile.equals(Data.profile.getValue())) {
136                     debug("profiles", "[fragment] setting current profile to 0");
137                     Data.setCurrentProfile(newList.get(0));
138                 }
139
140                 final FragmentActivity activity = getActivity();
141                 if (activity != null)
142                     activity.finish();
143             });
144             builder.show();
145             return false;
146         });
147         final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
148         menuDeleteProfile.setVisible(
149                 (mProfile != null) && (profiles != null) && (profiles.size() > 1));
150
151         if (BuildConfig.DEBUG) {
152             final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
153             menuWipeProfileData.setOnMenuItemClickListener(ignored -> onWipeDataMenuClicked());
154             menuWipeProfileData.setVisible(mProfile != null);
155         }
156     }
157     private boolean onWipeDataMenuClicked() {
158         // this is a development option, so no confirmation
159         mProfile.wipeAllData();
160         if (mProfile.equals(Data.profile.getValue()))
161             triggerProfileChange();
162         return true;
163     }
164     private void triggerProfileChange() {
165         int index = Data.getProfileIndex(mProfile);
166         MobileLedgerProfile newProfile = new MobileLedgerProfile(mProfile);
167         final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
168         if (profiles == null)
169             throw new AssertionError();
170         profiles.set(index, newProfile);
171
172         ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
173         if (viewAdapter != null)
174             viewAdapter.notifyItemChanged(index);
175
176         if (mProfile.equals(Data.profile.getValue()))
177             Data.profile.setValue(newProfile);
178     }
179     private void hookTextChangeSyncRoutine(TextView view, TextChangeSyncProc syncRoutine) {
180         view.addTextChangedListener(new TextWatcher() {
181             @Override
182             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
183             @Override
184             public void onTextChanged(CharSequence s, int start, int before, int count) {}
185             @Override
186             public void afterTextChanged(Editable s) { syncRoutine.onTextChanged(s.toString());}
187         });
188     }
189     @Override
190     public void onActivityCreated(@Nullable Bundle savedInstanceState) {
191         super.onActivityCreated(savedInstanceState);
192         Activity context = getActivity();
193         if (context == null)
194             return;
195
196         if ((getArguments() != null) && getArguments().containsKey(ARG_ITEM_ID)) {
197             int index = getArguments().getInt(ARG_ITEM_ID, -1);
198             ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
199             if ((profiles != null) && (index != -1) && (index < profiles.size()))
200                 mProfile = profiles.get(index);
201
202             Activity activity = this.getActivity();
203             if (activity == null)
204                 throw new AssertionError();
205             CollapsingToolbarLayout appBarLayout = activity.findViewById(R.id.toolbar_layout);
206             if (appBarLayout != null) {
207                 if (mProfile != null)
208                     appBarLayout.setTitle(mProfile.getName());
209                 else
210                     appBarLayout.setTitle(getResources().getString(R.string.new_profile_title));
211             }
212         }
213
214         final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
215         final ProfileDetailModel model = getModel();
216
217         model.observeDefaultCommodity(viewLifecycleOwner, c -> {
218             if (c != null)
219                 setDefaultCommodity(c.getName());
220             else
221                 resetDefaultCommodity();
222         });
223
224         FloatingActionButton fab = context.findViewById(R.id.fab);
225         fab.setOnClickListener(v -> onSaveFabClicked());
226
227         profileName = context.findViewById(R.id.profile_name);
228         hookTextChangeSyncRoutine(profileName, model::setProfileName);
229         model.observeProfileName(viewLifecycleOwner, pn -> {
230             if (!Misc.equalStrings(pn, profileName.getText()))
231                 profileName.setText(pn);
232         });
233
234         profileNameLayout = context.findViewById(R.id.profile_name_layout);
235
236         url = context.findViewById(R.id.url);
237         hookTextChangeSyncRoutine(url, model::setUrl);
238         model.observeUrl(viewLifecycleOwner, u -> {
239             if (!Misc.equalStrings(u, url.getText()))
240                 url.setText(u);
241         });
242
243         urlLayout = context.findViewById(R.id.url_layout);
244
245         defaultCommodityLayout = context.findViewById(R.id.default_commodity_layout);
246         defaultCommodityLayout.setOnClickListener(v -> {
247             CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
248                     CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
249             cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
250             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
251             cpf.show(activity.getSupportFragmentManager(), "currency-selector");
252         });
253
254         Switch showCommodityByDefault = context.findViewById(R.id.profile_show_commodity);
255         showCommodityByDefault.setOnCheckedChangeListener(
256                 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
257         model.observeShowCommodityByDefault(viewLifecycleOwner, showCommodityByDefault::setChecked);
258
259         Switch postingPermitted = context.findViewById(R.id.profile_permit_posting);
260         model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
261             postingPermitted.setChecked(isChecked);
262             defaultCommodityLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
263             showCommodityByDefault.setVisibility(isChecked ? View.VISIBLE : View.GONE);
264             preferredAccountsFilterLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
265             futureDatesLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
266         });
267         postingPermitted.setOnCheckedChangeListener(
268                 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
269
270         defaultCommodity = context.findViewById(R.id.default_commodity_text);
271
272         futureDatesLayout = context.findViewById(R.id.future_dates_layout);
273         futureDatesText = context.findViewById(R.id.future_dates_text);
274         context.findViewById(R.id.future_dates_layout)
275                .setOnClickListener(v -> {
276                    MenuInflater mi = new MenuInflater(context);
277                    PopupMenu menu = new PopupMenu(context, v);
278                    menu.inflate(R.menu.future_dates);
279                    menu.setOnMenuItemClickListener(item -> {
280                        model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
281                        return true;
282                    });
283                    menu.show();
284                });
285         model.observeFutureDates(viewLifecycleOwner,
286                 v -> futureDatesText.setText(v.getText(getResources())));
287
288         apiVersionText = context.findViewById(R.id.api_version_text);
289         model.observeApiVersion(viewLifecycleOwner, apiVer -> {
290             apiVersionText.setText(apiVer.getDescription(getResources()));
291         });
292         context.findViewById(R.id.api_version_layout)
293                .setOnClickListener(v -> {
294                    MenuInflater mi = new MenuInflater(context);
295                    PopupMenu menu = new PopupMenu(context, v);
296                    menu.inflate(R.menu.api_version);
297                    menu.setOnMenuItemClickListener(item -> {
298                        SendTransactionTask.API apiVer;
299                        switch (item.getItemId()) {
300                            case R.id.api_version_menu_html:
301                                apiVer = SendTransactionTask.API.html;
302                                break;
303                            case R.id.api_version_menu_post_1_14:
304                                apiVer = SendTransactionTask.API.post_1_14;
305                                break;
306                            case R.id.api_version_menu_pre_1_15:
307                                apiVer = SendTransactionTask.API.pre_1_15;
308                                break;
309                            case R.id.api_version_menu_auto:
310                            default:
311                                apiVer = SendTransactionTask.API.auto;
312                        }
313                        model.setApiVersion(apiVer);
314                        apiVersionText.setText(apiVer.getDescription(getResources()));
315                        return true;
316                    });
317                    menu.show();
318                });
319         authParams = context.findViewById(R.id.auth_params);
320
321         useAuthentication = context.findViewById(R.id.enable_http_auth);
322         useAuthentication.setOnCheckedChangeListener((buttonView, isChecked) -> {
323             model.setUseAuthentication(isChecked);
324             if (isChecked)
325                 userName.requestFocus();
326         });
327         model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
328             useAuthentication.setChecked(isChecked);
329             authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
330             checkInsecureSchemeWithAuth();
331         });
332
333         userName = context.findViewById(R.id.auth_user_name);
334         model.observeUserName(viewLifecycleOwner, text -> {
335             if (!Misc.equalStrings(text, userName.getText()))
336                 userName.setText(text);
337         });
338         hookTextChangeSyncRoutine(userName, model::setAuthUserName);
339         userNameLayout = context.findViewById(R.id.auth_user_name_layout);
340
341         password = context.findViewById(R.id.password);
342         model.observePassword(viewLifecycleOwner, text -> {
343             if (!Misc.equalStrings(text, password.getText()))
344                 password.setText(text);
345         });
346         hookTextChangeSyncRoutine(password, model::setAuthPassword);
347         passwordLayout = context.findViewById(R.id.password_layout);
348
349         huePickerView = context.findViewById(R.id.btn_pick_ring_color);
350         model.observeThemeId(viewLifecycleOwner, themeId -> {
351             final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
352             final int profileColor = Colors.getPrimaryColorForHue(hue);
353             huePickerView.setBackgroundColor(profileColor);
354             huePickerView.setTag(hue);
355         });
356
357         preferredAccountsFilter = context.findViewById(R.id.preferred_accounts_filter_filter);
358         model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
359             if (!Misc.equalStrings(text, preferredAccountsFilter.getText()))
360                 preferredAccountsFilter.setText(text);
361         });
362         hookTextChangeSyncRoutine(preferredAccountsFilter, model::setPreferredAccountsFilter);
363         preferredAccountsFilterLayout =
364                 context.findViewById(R.id.preferred_accounts_accounts_filter_layout);
365
366         insecureWarningText = context.findViewById(R.id.insecure_scheme_text);
367
368         hookClearErrorOnFocusListener(profileName, profileNameLayout);
369         hookClearErrorOnFocusListener(url, urlLayout);
370         hookClearErrorOnFocusListener(userName, userNameLayout);
371         hookClearErrorOnFocusListener(password, passwordLayout);
372
373         if (savedInstanceState == null) {
374             model.setValuesFromProfile(mProfile, getArguments().getInt(ARG_HUE, -1));
375         }
376         checkInsecureSchemeWithAuth();
377
378         url.addTextChangedListener(new TextWatcher() {
379             @Override
380             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
381             @Override
382             public void onTextChanged(CharSequence s, int start, int before, int count) {}
383             @Override
384             public void afterTextChanged(Editable s) {
385                 checkInsecureSchemeWithAuth();
386             }
387         });
388
389         huePickerView.setOnClickListener(v -> {
390             HueRingDialog d = new HueRingDialog(
391                     Objects.requireNonNull(ProfileDetailFragment.this.getContext()), profileThemeId,
392                     (Integer) v.getTag());
393             d.show();
394             d.setColorSelectedListener(model::setThemeId);
395         });
396
397         profileName.requestFocus();
398     }
399     private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
400         switch (itemId) {
401             case R.id.menu_future_dates_7:
402                 return MobileLedgerProfile.FutureDates.OneWeek;
403             case R.id.menu_future_dates_14:
404                 return MobileLedgerProfile.FutureDates.TwoWeeks;
405             case R.id.menu_future_dates_30:
406                 return MobileLedgerProfile.FutureDates.OneMonth;
407             case R.id.menu_future_dates_60:
408                 return MobileLedgerProfile.FutureDates.TwoMonths;
409             case R.id.menu_future_dates_90:
410                 return MobileLedgerProfile.FutureDates.ThreeMonths;
411             case R.id.menu_future_dates_180:
412                 return MobileLedgerProfile.FutureDates.SixMonths;
413             case R.id.menu_future_dates_365:
414                 return MobileLedgerProfile.FutureDates.OneYear;
415             case R.id.menu_future_dates_all:
416                 return MobileLedgerProfile.FutureDates.All;
417             default:
418                 return MobileLedgerProfile.FutureDates.None;
419         }
420     }
421     @NotNull
422     private ProfileDetailModel getModel() {
423         return new ViewModelProvider(this).get(ProfileDetailModel.class);
424     }
425     private void onSaveFabClicked() {
426         if (!checkValidity())
427             return;
428
429         ProfileDetailModel model = getModel();
430
431         if (mProfile != null) {
432             model.updateProfile(mProfile);
433 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
434             mProfile.storeInDB();
435             debug("profiles", "profile stored in DB");
436             triggerProfileChange();
437         }
438         else {
439             mProfile = new MobileLedgerProfile();
440             model.updateProfile(mProfile);
441             mProfile.storeInDB();
442             final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
443             if (profiles == null)
444                 throw new AssertionError();
445             ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
446             newList.add(mProfile);
447             Data.profiles.setValue(newList);
448             MobileLedgerProfile.storeProfilesOrder();
449
450             // first profile ever?
451             if (newList.size() == 1)
452                 Data.profile.setValue(mProfile);
453         }
454
455         Activity activity = getActivity();
456         if (activity != null)
457             activity.finish();
458     }
459     @Override
460     public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
461                              Bundle savedInstanceState) {
462
463         return inflater.inflate(R.layout.profile_detail, container, false);
464     }
465     private boolean checkUrlValidity() {
466         boolean valid = true;
467
468         ProfileDetailModel model = getModel();
469
470         String val = model.getUrl()
471                           .trim();
472         if (val.isEmpty()) {
473             valid = false;
474             urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
475         }
476         try {
477             URL url = new URL(val);
478             String host = url.getHost();
479             if (host == null || host.isEmpty())
480                 throw new MalformedURLException("Missing host");
481             String protocol = url.getProtocol()
482                                  .toUpperCase();
483             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
484                 valid = false;
485                 urlLayout.setError(getResources().getText(R.string.err_invalid_url));
486             }
487         }
488         catch (MalformedURLException e) {
489             valid = false;
490             urlLayout.setError(getResources().getText(R.string.err_invalid_url));
491         }
492
493         return valid;
494     }
495     private void checkInsecureSchemeWithAuth() {
496         boolean showWarning = false;
497
498         final ProfileDetailModel model = getModel();
499
500         if (model.getUseAuthentication()) {
501             String urlText = model.getUrl();
502             if (urlText.startsWith("http") && !urlText.startsWith("https"))
503                 showWarning = true;
504         }
505
506         if (showWarning)
507             insecureWarningText.setVisibility(View.VISIBLE);
508         else
509             insecureWarningText.setVisibility(View.GONE);
510     }
511     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
512         view.setOnFocusChangeListener((v, hasFocus) -> {
513             if (hasFocus)
514                 layout.setError(null);
515         });
516         view.addTextChangedListener(new TextWatcher() {
517             @Override
518             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
519             }
520             @Override
521             public void onTextChanged(CharSequence s, int start, int before, int count) {
522                 layout.setError(null);
523             }
524             @Override
525             public void afterTextChanged(Editable s) {
526             }
527         });
528     }
529     private void syncModelFromUI() {
530         if (syncingModelFromUI)
531             return;
532
533         syncingModelFromUI = true;
534
535         try {
536             ProfileDetailModel model = getModel();
537
538             model.setProfileName(profileName.getText());
539             model.setUrl(url.getText());
540             model.setPreferredAccountsFilter(preferredAccountsFilter.getText());
541             model.setAuthUserName(userName.getText());
542             model.setAuthPassword(password.getText());
543         }
544         finally {
545             syncingModelFromUI = false;
546         }
547     }
548     private boolean checkValidity() {
549         boolean valid = true;
550
551         String val = String.valueOf(profileName.getText());
552         if (val.trim()
553                .isEmpty())
554         {
555             valid = false;
556             profileNameLayout.setError(getResources().getText(R.string.err_profile_name_empty));
557         }
558
559         if (!checkUrlValidity())
560             valid = false;
561
562         if (useAuthentication.isChecked()) {
563             val = String.valueOf(userName.getText());
564             if (val.trim()
565                    .isEmpty())
566             {
567                 valid = false;
568                 userNameLayout.setError(
569                         getResources().getText(R.string.err_profile_user_name_empty));
570             }
571
572             val = String.valueOf(password.getText());
573             if (val.trim()
574                    .isEmpty())
575             {
576                 valid = false;
577                 passwordLayout.setError(
578                         getResources().getText(R.string.err_profile_password_empty));
579             }
580         }
581
582         return valid;
583     }
584     private void resetDefaultCommodity() {
585         defaultCommoditySet = false;
586         defaultCommodity.setText(R.string.btn_no_currency);
587         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.ITALIC);
588     }
589     private void setDefaultCommodity(@NonNull @NotNull String name) {
590         defaultCommoditySet = true;
591         defaultCommodity.setText(name);
592         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.BOLD);
593     }
594     interface TextChangeSyncProc {
595         void onTextChanged(String text);
596     }
597 }