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