]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
TransactionAccumulator needs not keep a reference to the main model
[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.AsyncTask;
24 import android.os.Bundle;
25 import android.text.Editable;
26 import android.text.TextWatcher;
27 import android.view.LayoutInflater;
28 import android.view.Menu;
29 import android.view.MenuInflater;
30 import android.view.MenuItem;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.widget.PopupMenu;
34 import android.widget.TextView;
35
36 import androidx.annotation.NonNull;
37 import androidx.annotation.Nullable;
38 import androidx.appcompat.app.AppCompatActivity;
39 import androidx.fragment.app.Fragment;
40 import androidx.fragment.app.FragmentActivity;
41 import androidx.lifecycle.LifecycleOwner;
42 import androidx.lifecycle.ViewModelProvider;
43
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.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                        AsyncTask.execute(() -> {
130                            dao.deleteSync(profile);
131                            dao.updateOrderSync(dao.getAllOrderedSync());
132                        });
133                });
134
135             final FragmentActivity activity = getActivity();
136             if (activity != null)
137                 activity.finish();
138         });
139         builder.show();
140         return false;
141     }
142     private boolean onWipeDataMenuClicked() {
143         // this is a development option, so no confirmation
144         DB.get()
145           .getProfileDAO()
146           .getById(Objects.requireNonNull(getModel().getProfileId()
147                                                     .getValue()))
148           .observe(getViewLifecycleOwner(), profile -> {
149               if (profile != null)
150                   profile.wipeAllData();
151           });
152         return true;
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         final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
180         final ProfileDetailModel model = getModel();
181
182         model.observeDefaultCommodity(viewLifecycleOwner, c -> {
183             if (c != null)
184                 setDefaultCommodity(c);
185             else
186                 resetDefaultCommodity();
187         });
188
189         FloatingActionButton fab = context.findViewById(R.id.fabAdd);
190         fab.setOnClickListener(v -> onSaveFabClicked());
191
192         hookTextChangeSyncRoutine(binding.profileName, model::setProfileName);
193         model.observeProfileName(viewLifecycleOwner, pn -> {
194             if (!Misc.equalStrings(pn, Misc.nullIsEmpty(binding.profileName.getText())))
195                 binding.profileName.setText(pn);
196         });
197
198         hookTextChangeSyncRoutine(binding.url, model::setUrl);
199         model.observeUrl(viewLifecycleOwner, u -> {
200             if (!Misc.equalStrings(u, Misc.nullIsEmpty(binding.url.getText())))
201                 binding.url.setText(u);
202         });
203
204         binding.defaultCommodityLayout.setOnClickListener(v -> {
205             CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
206                     CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
207             cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
208             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
209             cpf.show(activity.getSupportFragmentManager(), "currency-selector");
210         });
211
212         binding.profileShowCommodity.setOnCheckedChangeListener(
213                 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
214         model.observeShowCommodityByDefault(viewLifecycleOwner,
215                 binding.profileShowCommodity::setChecked);
216
217         model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
218             binding.profilePermitPosting.setChecked(isChecked);
219             binding.postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
220         });
221         binding.profilePermitPosting.setOnCheckedChangeListener(
222                 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
223
224         model.observeShowCommentsByDefault(viewLifecycleOwner,
225                 binding.profileShowComments::setChecked);
226         binding.profileShowComments.setOnCheckedChangeListener(
227                 ((buttonView, isChecked) -> model.setShowCommentsByDefault(isChecked)));
228
229         binding.futureDatesLayout.setOnClickListener(v -> {
230             MenuInflater mi = new MenuInflater(context);
231             PopupMenu menu = new PopupMenu(context, v);
232             menu.inflate(R.menu.future_dates);
233             menu.setOnMenuItemClickListener(item -> {
234                 model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
235                 return true;
236             });
237             menu.show();
238         });
239         model.observeFutureDates(viewLifecycleOwner,
240                 v -> binding.futureDatesText.setText(v.getText(getResources())));
241
242         model.observeApiVersion(viewLifecycleOwner,
243                 apiVer -> binding.apiVersionText.setText(apiVer.getDescription(getResources())));
244         binding.apiVersionLabel.setOnClickListener(this::chooseAPIVersion);
245         binding.apiVersionText.setOnClickListener(this::chooseAPIVersion);
246
247         binding.serverVersionLabel.setOnClickListener(v -> model.triggerVersionDetection());
248         model.observeDetectedVersion(viewLifecycleOwner, ver -> {
249             if (ver == null)
250                 binding.detectedServerVersionText.setText(context.getResources()
251                                                                  .getString(
252                                                                          R.string.server_version_unknown_label));
253             else if (ver.isPre_1_20_1())
254                 binding.detectedServerVersionText.setText(context.getResources()
255                                                                  .getString(
256                                                                          R.string.detected_server_pre_1_20_1));
257             else
258                 binding.detectedServerVersionText.setText(ver.toString());
259         });
260         binding.detectedServerVersionText.setOnClickListener(v -> model.triggerVersionDetection());
261         binding.serverVersionDetectButton.setOnClickListener(v -> model.triggerVersionDetection());
262         model.observeDetectingHledgerVersion(viewLifecycleOwner,
263                 running -> binding.serverVersionDetectButton.setVisibility(
264                         running ? View.VISIBLE : View.INVISIBLE));
265
266         binding.enableHttpAuth.setOnCheckedChangeListener((buttonView, isChecked) -> {
267             boolean wasOn = model.getUseAuthentication();
268             model.setUseAuthentication(isChecked);
269             if (!wasOn && isChecked)
270                 binding.authUserName.requestFocus();
271         });
272         model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
273             binding.enableHttpAuth.setChecked(isChecked);
274             binding.authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
275             checkInsecureSchemeWithAuth();
276         });
277
278         model.observeUserName(viewLifecycleOwner, text -> {
279             if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.authUserName.getText())))
280                 binding.authUserName.setText(text);
281         });
282         hookTextChangeSyncRoutine(binding.authUserName, model::setAuthUserName);
283
284         model.observePassword(viewLifecycleOwner, text -> {
285             if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.password.getText())))
286                 binding.password.setText(text);
287         });
288         hookTextChangeSyncRoutine(binding.password, model::setAuthPassword);
289
290         model.observeThemeId(viewLifecycleOwner, themeId -> {
291             final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
292             final int profileColor = Colors.getPrimaryColorForHue(hue);
293             binding.btnPickRingColor.setBackgroundColor(profileColor);
294             binding.btnPickRingColor.setTag(hue);
295         });
296
297         model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
298             if (!Misc.equalStrings(text,
299                     Misc.nullIsEmpty(binding.preferredAccountsFilter.getText())))
300                 binding.preferredAccountsFilter.setText(text);
301         });
302         hookTextChangeSyncRoutine(binding.preferredAccountsFilter,
303                 model::setPreferredAccountsFilter);
304
305         hookClearErrorOnFocusListener(binding.profileName, binding.profileNameLayout);
306         hookClearErrorOnFocusListener(binding.url, binding.urlLayout);
307         hookClearErrorOnFocusListener(binding.authUserName, binding.authUserNameLayout);
308         hookClearErrorOnFocusListener(binding.password, binding.passwordLayout);
309
310         binding.url.addTextChangedListener(new TextWatcher() {
311             @Override
312             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
313             @Override
314             public void onTextChanged(CharSequence s, int start, int before, int count) {}
315             @Override
316             public void afterTextChanged(Editable s) {
317                 checkInsecureSchemeWithAuth();
318             }
319         });
320
321         binding.btnPickRingColor.setOnClickListener(v -> {
322             HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
323                     model.initialThemeHue, (Integer) v.getTag());
324             d.show();
325             d.setColorSelectedListener(model::setThemeId);
326         });
327
328         binding.profileName.requestFocus();
329     }
330     private void chooseAPIVersion(View v) {
331         Activity context = getActivity();
332         ProfileDetailModel model = getModel();
333         MenuInflater mi = new MenuInflater(context);
334         PopupMenu menu = new PopupMenu(context, v);
335         menu.inflate(R.menu.api_version);
336         menu.setOnMenuItemClickListener(item -> {
337             API apiVer;
338             int itemId = item.getItemId();
339             if (itemId == R.id.api_version_menu_html) {
340                 apiVer = API.html;
341             }
342             else if (itemId == R.id.api_version_menu_1_19_1) {
343                 apiVer = API.v1_19_1;
344             }
345             else if (itemId == R.id.api_version_menu_1_15) {
346                 apiVer = API.v1_15;
347             }
348             else if (itemId == R.id.api_version_menu_1_14) {
349                 apiVer = API.v1_14;
350             }
351             else {
352                 apiVer = API.auto;
353             }
354             model.setApiVersion(apiVer);
355             binding.apiVersionText.setText(apiVer.getDescription(getResources()));
356             return true;
357         });
358         menu.show();
359     }
360     private FutureDates futureDatesSettingFromMenuItemId(int itemId) {
361         if (itemId == R.id.menu_future_dates_7) {
362             return FutureDates.OneWeek;
363         }
364         else if (itemId == R.id.menu_future_dates_14) {
365             return FutureDates.TwoWeeks;
366         }
367         else if (itemId == R.id.menu_future_dates_30) {
368             return FutureDates.OneMonth;
369         }
370         else if (itemId == R.id.menu_future_dates_60) {
371             return FutureDates.TwoMonths;
372         }
373         else if (itemId == R.id.menu_future_dates_90) {
374             return FutureDates.ThreeMonths;
375         }
376         else if (itemId == R.id.menu_future_dates_180) {
377             return FutureDates.SixMonths;
378         }
379         else if (itemId == R.id.menu_future_dates_365) {
380             return FutureDates.OneYear;
381         }
382         else if (itemId == R.id.menu_future_dates_all) {
383             return FutureDates.All;
384         }
385         return FutureDates.None;
386     }
387     @NotNull
388     private ProfileDetailModel getModel() {
389         return new ViewModelProvider(requireActivity()).get(ProfileDetailModel.class);
390     }
391     private void onSaveFabClicked() {
392         if (!checkValidity())
393             return;
394
395         ProfileDetailModel model = getModel();
396         ProfileDAO dao = DB.get()
397                            .getProfileDAO();
398
399         Profile profile = new Profile();
400         model.updateProfile(profile);
401         if (profile.getId() > 0) {
402             dao.update(profile);
403             debug("profiles", "profile stored in DB");
404 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
405         }
406         else {
407             dao.insertLast(profile, null);
408         }
409
410         Activity activity = getActivity();
411         if (activity != null)
412             activity.finish();
413     }
414     private boolean checkUrlValidity() {
415         boolean valid = true;
416
417         ProfileDetailModel model = getModel();
418
419         String val = model.getUrl()
420                           .trim();
421         if (val.isEmpty()) {
422             valid = false;
423             binding.urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
424         }
425         try {
426             URL url = new URL(val);
427             String host = url.getHost();
428             if (host == null || host.isEmpty())
429                 throw new MalformedURLException("Missing host");
430             String protocol = url.getProtocol()
431                                  .toUpperCase();
432             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
433                 valid = false;
434                 binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
435             }
436         }
437         catch (MalformedURLException e) {
438             valid = false;
439             binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
440         }
441
442         return valid;
443     }
444     private void checkInsecureSchemeWithAuth() {
445         boolean showWarning = false;
446
447         final ProfileDetailModel model = getModel();
448
449         if (model.getUseAuthentication()) {
450             String urlText = model.getUrl();
451             if (urlText.startsWith("http://") ||
452                 urlText.length() >= 8 && !urlText.startsWith("https://"))
453                 showWarning = true;
454         }
455
456         if (showWarning)
457             binding.insecureSchemeText.setVisibility(View.VISIBLE);
458         else
459             binding.insecureSchemeText.setVisibility(View.GONE);
460     }
461     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
462         view.setOnFocusChangeListener((v, hasFocus) -> {
463             if (hasFocus)
464                 layout.setError(null);
465         });
466         view.addTextChangedListener(new TextWatcher() {
467             @Override
468             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
469             }
470             @Override
471             public void onTextChanged(CharSequence s, int start, int before, int count) {
472                 layout.setError(null);
473             }
474             @Override
475             public void afterTextChanged(Editable s) {
476             }
477         });
478     }
479     private void syncModelFromUI() {
480         if (syncingModelFromUI)
481             return;
482
483         syncingModelFromUI = true;
484
485         try {
486             ProfileDetailModel model = getModel();
487
488             model.setProfileName(binding.profileName.getText());
489             model.setUrl(binding.url.getText());
490             model.setPreferredAccountsFilter(binding.preferredAccountsFilter.getText());
491             model.setAuthUserName(binding.authUserName.getText());
492             model.setAuthPassword(binding.password.getText());
493         }
494         finally {
495             syncingModelFromUI = false;
496         }
497     }
498     private boolean checkValidity() {
499         boolean valid = true;
500
501         String val = String.valueOf(binding.profileName.getText());
502         if (val.trim()
503                .isEmpty())
504         {
505             valid = false;
506             binding.profileNameLayout.setError(
507                     getResources().getText(R.string.err_profile_name_empty));
508         }
509
510         if (!checkUrlValidity())
511             valid = false;
512
513         if (binding.enableHttpAuth.isChecked()) {
514             val = String.valueOf(binding.authUserName.getText());
515             if (val.trim()
516                    .isEmpty())
517             {
518                 valid = false;
519                 binding.authUserNameLayout.setError(
520                         getResources().getText(R.string.err_profile_user_name_empty));
521             }
522
523             val = String.valueOf(binding.password.getText());
524             if (val.trim()
525                    .isEmpty())
526             {
527                 valid = false;
528                 binding.passwordLayout.setError(
529                         getResources().getText(R.string.err_profile_password_empty));
530             }
531         }
532
533         return valid;
534     }
535     private void resetDefaultCommodity() {
536         defaultCommoditySet = false;
537         binding.defaultCommodityText.setText(R.string.btn_no_currency);
538         binding.defaultCommodityText.setTypeface(binding.defaultCommodityText.getTypeface(),
539                 Typeface.ITALIC);
540     }
541     private void setDefaultCommodity(@NonNull @NotNull String name) {
542         defaultCommoditySet = true;
543         binding.defaultCommodityText.setText(name);
544         binding.defaultCommodityText.setTypeface(Typeface.DEFAULT);
545     }
546     interface TextChangeSyncRoutine {
547         void onTextChanged(String text);
548     }
549 }