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