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