]> git.ktnx.net Git - mobile-ledger-staging.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
convert two switch() statements to if/else
[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             int itemId = item.getItemId();
364             if (itemId == R.id.api_version_menu_html) {
365                 apiVer = API.html;
366             }
367             else if (itemId == R.id.api_version_menu_1_19_1) {
368                 apiVer = API.v1_19_1;
369             }
370             else if (itemId == R.id.api_version_menu_1_15) {
371                 apiVer = API.v1_15;
372             }
373             else if (itemId == R.id.api_version_menu_1_14) {
374                 apiVer = API.v1_14;
375             }
376             else {
377                 apiVer = API.auto;
378             }
379             model.setApiVersion(apiVer);
380             binding.apiVersionText.setText(apiVer.getDescription(getResources()));
381             return true;
382         });
383         menu.show();
384     }
385     private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
386         if (itemId == R.id.menu_future_dates_7) {
387             return MobileLedgerProfile.FutureDates.OneWeek;
388         }
389         else if (itemId == R.id.menu_future_dates_14) {
390             return MobileLedgerProfile.FutureDates.TwoWeeks;
391         }
392         else if (itemId == R.id.menu_future_dates_30) {
393             return MobileLedgerProfile.FutureDates.OneMonth;
394         }
395         else if (itemId == R.id.menu_future_dates_60) {
396             return MobileLedgerProfile.FutureDates.TwoMonths;
397         }
398         else if (itemId == R.id.menu_future_dates_90) {
399             return MobileLedgerProfile.FutureDates.ThreeMonths;
400         }
401         else if (itemId == R.id.menu_future_dates_180) {
402             return MobileLedgerProfile.FutureDates.SixMonths;
403         }
404         else if (itemId == R.id.menu_future_dates_365) {
405             return MobileLedgerProfile.FutureDates.OneYear;
406         }
407         else if (itemId == R.id.menu_future_dates_all) {
408             return MobileLedgerProfile.FutureDates.All;
409         }
410         return MobileLedgerProfile.FutureDates.None;
411     }
412     @NotNull
413     private ProfileDetailModel getModel() {
414         return new ViewModelProvider(requireActivity()).get(ProfileDetailModel.class);
415     }
416     private void onSaveFabClicked() {
417         if (!checkValidity())
418             return;
419
420         ProfileDetailModel model = getModel();
421         final ArrayList<MobileLedgerProfile> profiles =
422                 Objects.requireNonNull(Data.profiles.getValue());
423
424         if (mProfile != null) {
425             int pos = Data.profiles.getValue()
426                                    .indexOf(mProfile);
427             mProfile = new MobileLedgerProfile(mProfile);
428             model.updateProfile(mProfile);
429             mProfile.storeInDB();
430             debug("profiles", "profile stored in DB");
431             profiles.set(pos, mProfile);
432 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
433
434             final MobileLedgerProfile currentProfile = Data.getProfile();
435             if (mProfile.getUuid()
436                         .equals(currentProfile.getUuid()))
437             {
438                 Data.setCurrentProfile(mProfile);
439             }
440
441             ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
442             if (viewAdapter != null)
443                 viewAdapter.notifyItemChanged(pos);
444         }
445         else {
446             mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID()));
447             model.updateProfile(mProfile);
448             mProfile.storeInDB();
449             final ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
450             newList.add(mProfile);
451             Data.profiles.setValue(newList);
452             MobileLedgerProfile.storeProfilesOrder();
453
454             // first profile ever?
455             if (newList.size() == 1)
456                 Data.setCurrentProfile(mProfile);
457         }
458
459         Activity activity = getActivity();
460         if (activity != null)
461             activity.finish();
462     }
463     private boolean checkUrlValidity() {
464         boolean valid = true;
465
466         ProfileDetailModel model = getModel();
467
468         String val = model.getUrl()
469                           .trim();
470         if (val.isEmpty()) {
471             valid = false;
472             binding.urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
473         }
474         try {
475             URL url = new URL(val);
476             String host = url.getHost();
477             if (host == null || host.isEmpty())
478                 throw new MalformedURLException("Missing host");
479             String protocol = url.getProtocol()
480                                  .toUpperCase();
481             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
482                 valid = false;
483                 binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
484             }
485         }
486         catch (MalformedURLException e) {
487             valid = false;
488             binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
489         }
490
491         return valid;
492     }
493     private void checkInsecureSchemeWithAuth() {
494         boolean showWarning = false;
495
496         final ProfileDetailModel model = getModel();
497
498         if (model.getUseAuthentication()) {
499             String urlText = model.getUrl();
500             if (urlText.startsWith("http") && !urlText.startsWith("https"))
501                 showWarning = true;
502         }
503
504         if (showWarning)
505             binding.insecureSchemeText.setVisibility(View.VISIBLE);
506         else
507             binding.insecureSchemeText.setVisibility(View.GONE);
508     }
509     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
510         view.setOnFocusChangeListener((v, hasFocus) -> {
511             if (hasFocus)
512                 layout.setError(null);
513         });
514         view.addTextChangedListener(new TextWatcher() {
515             @Override
516             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
517             }
518             @Override
519             public void onTextChanged(CharSequence s, int start, int before, int count) {
520                 layout.setError(null);
521             }
522             @Override
523             public void afterTextChanged(Editable s) {
524             }
525         });
526     }
527     private void syncModelFromUI() {
528         if (syncingModelFromUI)
529             return;
530
531         syncingModelFromUI = true;
532
533         try {
534             ProfileDetailModel model = getModel();
535
536             model.setProfileName(binding.profileName.getText());
537             model.setUrl(binding.url.getText());
538             model.setPreferredAccountsFilter(binding.preferredAccountsFilter.getText());
539             model.setAuthUserName(binding.authUserName.getText());
540             model.setAuthPassword(binding.password.getText());
541         }
542         finally {
543             syncingModelFromUI = false;
544         }
545     }
546     private boolean checkValidity() {
547         boolean valid = true;
548
549         String val = String.valueOf(binding.profileName.getText());
550         if (val.trim()
551                .isEmpty())
552         {
553             valid = false;
554             binding.profileNameLayout.setError(
555                     getResources().getText(R.string.err_profile_name_empty));
556         }
557
558         if (!checkUrlValidity())
559             valid = false;
560
561         if (binding.enableHttpAuth.isChecked()) {
562             val = String.valueOf(binding.authUserName.getText());
563             if (val.trim()
564                    .isEmpty())
565             {
566                 valid = false;
567                 binding.authUserNameLayout.setError(
568                         getResources().getText(R.string.err_profile_user_name_empty));
569             }
570
571             val = String.valueOf(binding.password.getText());
572             if (val.trim()
573                    .isEmpty())
574             {
575                 valid = false;
576                 binding.passwordLayout.setError(
577                         getResources().getText(R.string.err_profile_password_empty));
578             }
579         }
580
581         return valid;
582     }
583     private void resetDefaultCommodity() {
584         defaultCommoditySet = false;
585         binding.defaultCommodityText.setText(R.string.btn_no_currency);
586         binding.defaultCommodityText.setTypeface(binding.defaultCommodityText.getTypeface(),
587                 Typeface.ITALIC);
588     }
589     private void setDefaultCommodity(@NonNull @NotNull String name) {
590         defaultCommoditySet = true;
591         binding.defaultCommodityText.setText(name);
592         binding.defaultCommodityText.setTypeface(Typeface.DEFAULT);
593     }
594     interface TextChangeSyncRoutine {
595         void onTextChanged(String text);
596     }
597 }