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