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