]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
move profile editor data in a model class so that it survives reconfiguration
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / ui / profiles / ProfileDetailFragment.java
1 /*
2  * Copyright © 2019 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.LinearLayout;
33 import android.widget.PopupMenu;
34 import android.widget.Switch;
35 import android.widget.TextView;
36
37 import androidx.annotation.NonNull;
38 import androidx.annotation.Nullable;
39 import androidx.appcompat.app.AppCompatActivity;
40 import androidx.fragment.app.Fragment;
41 import androidx.fragment.app.FragmentActivity;
42 import androidx.lifecycle.LifecycleOwner;
43 import androidx.lifecycle.ViewModelProvider;
44
45 import com.google.android.material.appbar.CollapsingToolbarLayout;
46 import com.google.android.material.floatingactionbutton.FloatingActionButton;
47 import com.google.android.material.textfield.TextInputLayout;
48
49 import net.ktnx.mobileledger.BuildConfig;
50 import net.ktnx.mobileledger.R;
51 import net.ktnx.mobileledger.async.SendTransactionTask;
52 import net.ktnx.mobileledger.model.Data;
53 import net.ktnx.mobileledger.model.MobileLedgerProfile;
54 import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
55 import net.ktnx.mobileledger.ui.HueRingDialog;
56 import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
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.ArrayList;
66 import java.util.Objects;
67
68 import static net.ktnx.mobileledger.utils.Colors.profileThemeId;
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     /**
86      * The content this fragment is presenting.
87      */ private MobileLedgerProfile mProfile;
88     private TextView url;
89     private TextView defaultCommodity;
90     private View defaultCommodityLayout;
91     private boolean defaultCommoditySet;
92     private TextInputLayout urlLayout;
93     private LinearLayout authParams;
94     private Switch useAuthentication;
95     private TextView userName;
96     private TextInputLayout userNameLayout;
97     private TextView password;
98     private TextInputLayout passwordLayout;
99     private TextView profileName;
100     private TextInputLayout profileNameLayout;
101     private TextView preferredAccountsFilter;
102     private TextInputLayout preferredAccountsFilterLayout;
103     private View huePickerView;
104     private View insecureWarningText;
105     private TextView futureDatesText;
106     private View futureDatesLayout;
107     private TextView apiVersionText;
108     private boolean syncingModelFromUI = false;
109     /**
110      * Mandatory empty constructor for the fragment manager to instantiate the
111      * fragment (e.g. upon screen orientation changes).
112      */
113     public ProfileDetailFragment() {
114     }
115     @Override
116     public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
117         debug("profiles", "[fragment] Creating profile details options menu");
118         super.onCreateOptionsMenu(menu, inflater);
119         inflater.inflate(R.menu.profile_details, menu);
120         final MenuItem menuDeleteProfile = menu.findItem(R.id.menuDelete);
121         menuDeleteProfile.setOnMenuItemClickListener(item -> {
122             AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
123             builder.setTitle(mProfile.getName());
124             builder.setMessage(R.string.remove_profile_dialog_message);
125             builder.setPositiveButton(R.string.Remove, (dialog, which) -> {
126                 debug("profiles",
127                         String.format("[fragment] removing profile %s", mProfile.getUuid()));
128                 mProfile.removeFromDB();
129                 ArrayList<MobileLedgerProfile> oldList = Data.profiles.getValue();
130                 if (oldList == null)
131                     throw new AssertionError();
132                 ArrayList<MobileLedgerProfile> newList = new ArrayList<>(oldList);
133                 newList.remove(mProfile);
134                 Data.profiles.setValue(newList);
135                 if (mProfile.equals(Data.profile.getValue())) {
136                     debug("profiles", "[fragment] setting current profile to 0");
137                     Data.setCurrentProfile(newList.get(0));
138                 }
139
140                 final FragmentActivity activity = getActivity();
141                 if (activity != null)
142                     activity.finish();
143             });
144             builder.show();
145             return false;
146         });
147         final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
148         menuDeleteProfile.setVisible(
149                 (mProfile != null) && (profiles != null) && (profiles.size() > 1));
150
151         if (BuildConfig.DEBUG) {
152             final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
153             menuWipeProfileData.setOnMenuItemClickListener(ignored -> onWipeDataMenuClicked());
154             menuWipeProfileData.setVisible(mProfile != null);
155         }
156     }
157     private boolean onWipeDataMenuClicked() {
158         // this is a development option, so no confirmation
159         mProfile.wipeAllData();
160         if (mProfile.equals(Data.profile.getValue()))
161             triggerProfileChange();
162         return true;
163     }
164     private void triggerProfileChange() {
165         int index = Data.getProfileIndex(mProfile);
166         MobileLedgerProfile newProfile = new MobileLedgerProfile(mProfile);
167         final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
168         if (profiles == null)
169             throw new AssertionError();
170         profiles.set(index, newProfile);
171
172         ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
173         if (viewAdapter != null)
174             viewAdapter.notifyItemChanged(index);
175
176         if (mProfile.equals(Data.profile.getValue()))
177             Data.profile.setValue(newProfile);
178     }
179     private void hookTextChangeSyncRoutine(TextView view, TextChangeSyncProc syncRoutine) {
180         view.addTextChangedListener(new TextWatcher() {
181             @Override
182             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
183             @Override
184             public void onTextChanged(CharSequence s, int start, int before, int count) {}
185             @Override
186             public void afterTextChanged(Editable s) { syncRoutine.onTextChanged(s.toString());}
187         });
188     }
189     @Override
190     public void onActivityCreated(@Nullable Bundle savedInstanceState) {
191         super.onActivityCreated(savedInstanceState);
192         Activity context = getActivity();
193         if (context == null)
194             return;
195
196         if ((getArguments() != null) && getArguments().containsKey(ARG_ITEM_ID)) {
197             int index = getArguments().getInt(ARG_ITEM_ID, -1);
198             ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
199             if ((profiles != null) && (index != -1) && (index < profiles.size()))
200                 mProfile = profiles.get(index);
201
202             Activity activity = this.getActivity();
203             if (activity == null)
204                 throw new AssertionError();
205             CollapsingToolbarLayout appBarLayout = activity.findViewById(R.id.toolbar_layout);
206             if (appBarLayout != null) {
207                 if (mProfile != null)
208                     appBarLayout.setTitle(mProfile.getName());
209                 else
210                     appBarLayout.setTitle(getResources().getString(R.string.new_profile_title));
211             }
212         }
213
214         final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
215         final ProfileDetailModel model = getModel();
216
217         model.observeDefaultCommodity(viewLifecycleOwner, c -> {
218             if (c != null)
219                 setDefaultCommodity(c.getName());
220             else
221                 resetDefaultCommodity();
222         });
223
224         FloatingActionButton fab = context.findViewById(R.id.fab);
225         fab.setOnClickListener(v -> onSaveFabClicked());
226
227         profileName = context.findViewById(R.id.profile_name);
228         hookTextChangeSyncRoutine(profileName, model::setProfileName);
229         model.observeProfileName(viewLifecycleOwner, pn -> {
230             if (!Misc.equalStrings(pn, profileName.getText()))
231                 profileName.setText(pn);
232         });
233
234         profileNameLayout = context.findViewById(R.id.profile_name_layout);
235
236         url = context.findViewById(R.id.url);
237         hookTextChangeSyncRoutine(url, model::setUrl);
238         model.observeUrl(viewLifecycleOwner, u -> {
239             if (!Misc.equalStrings(u, url.getText()))
240                 url.setText(u);
241         });
242
243         urlLayout = context.findViewById(R.id.url_layout);
244
245         defaultCommodityLayout = context.findViewById(R.id.default_commodity_layout);
246         defaultCommodityLayout.setOnClickListener(v -> {
247             CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
248                     CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
249             cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
250             final AppCompatActivity activity = (AppCompatActivity) v.getContext();
251             cpf.show(activity.getSupportFragmentManager(), "currency-selector");
252         });
253
254         Switch showCommodityByDefault = context.findViewById(R.id.profile_show_commodity);
255         showCommodityByDefault.setOnCheckedChangeListener(
256                 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
257         model.observeShowCommodityByDefault(viewLifecycleOwner, showCommodityByDefault::setChecked);
258
259         Switch postingPermitted = context.findViewById(R.id.profile_permit_posting);
260         model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
261             postingPermitted.setChecked(isChecked);
262             defaultCommodityLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
263             showCommodityByDefault.setVisibility(isChecked ? View.VISIBLE : View.GONE);
264             preferredAccountsFilterLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
265             futureDatesLayout.setVisibility(isChecked ? View.VISIBLE : View.GONE);
266         });
267         postingPermitted.setOnCheckedChangeListener(
268                 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
269
270         defaultCommodity = context.findViewById(R.id.default_commodity_text);
271
272         futureDatesLayout = context.findViewById(R.id.future_dates_layout);
273         futureDatesText = context.findViewById(R.id.future_dates_text);
274         context.findViewById(R.id.future_dates_layout)
275                .setOnClickListener(v -> {
276                    MenuInflater mi = new MenuInflater(context);
277                    PopupMenu menu = new PopupMenu(context, v);
278                    menu.inflate(R.menu.future_dates);
279                    menu.setOnMenuItemClickListener(item -> {
280                        model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
281                        return true;
282                    });
283                    menu.show();
284                });
285         model.observeFutureDates(viewLifecycleOwner,
286                 v -> futureDatesText.setText(v.getText(getResources())));
287
288         apiVersionText = context.findViewById(R.id.api_version_text);
289         model.observeApiVersion(viewLifecycleOwner, apiVer -> {
290             apiVersionText.setText(apiVer.getDescription(getResources()));
291         });
292         context.findViewById(R.id.api_version_layout)
293                .setOnClickListener(v -> {
294                    MenuInflater mi = new MenuInflater(context);
295                    PopupMenu menu = new PopupMenu(context, v);
296                    menu.inflate(R.menu.api_version);
297                    menu.setOnMenuItemClickListener(item -> {
298                        SendTransactionTask.API apiVer;
299                        switch (item.getItemId()) {
300                            case R.id.api_version_menu_html:
301                                apiVer = SendTransactionTask.API.html;
302                                break;
303                            case R.id.api_version_menu_post_1_14:
304                                apiVer = SendTransactionTask.API.post_1_14;
305                                break;
306                            case R.id.api_version_menu_pre_1_15:
307                                apiVer = SendTransactionTask.API.pre_1_15;
308                                break;
309                            case R.id.api_version_menu_auto:
310                            default:
311                                apiVer = SendTransactionTask.API.auto;
312                        }
313                        model.setApiVersion(apiVer);
314                        apiVersionText.setText(apiVer.getDescription(getResources()));
315                        return true;
316                    });
317                    menu.show();
318                });
319         authParams = context.findViewById(R.id.auth_params);
320
321         useAuthentication = context.findViewById(R.id.enable_http_auth);
322         useAuthentication.setOnCheckedChangeListener((buttonView, isChecked) -> {
323             model.setUseAuthentication(isChecked);
324             authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
325             if (isChecked)
326                 userName.requestFocus();
327             checkInsecureSchemeWithAuth();
328         });
329         model.observeUseAuthentication(viewLifecycleOwner, useAuthentication::setChecked);
330
331         userName = context.findViewById(R.id.auth_user_name);
332         model.observeUserName(viewLifecycleOwner, text -> {
333             if (!Misc.equalStrings(text, userName.getText()))
334                 userName.setText(text);
335         });
336         hookTextChangeSyncRoutine(userName, model::setAuthUserName);
337         userNameLayout = context.findViewById(R.id.auth_user_name_layout);
338
339         password = context.findViewById(R.id.password);
340         model.observePassword(viewLifecycleOwner, text -> {
341             if (!Misc.equalStrings(text, password.getText()))
342                 password.setText(text);
343         });
344         hookTextChangeSyncRoutine(password, model::setAuthPassword);
345         passwordLayout = context.findViewById(R.id.password_layout);
346
347         huePickerView = context.findViewById(R.id.btn_pick_ring_color);
348         model.observeThemeId(viewLifecycleOwner, themeId -> {
349             final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
350             final int profileColor = Colors.getPrimaryColorForHue(hue);
351             huePickerView.setBackgroundColor(profileColor);
352             huePickerView.setTag(hue);
353         });
354
355         preferredAccountsFilter = context.findViewById(R.id.preferred_accounts_filter_filter);
356         model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
357             if (!Misc.equalStrings(text, preferredAccountsFilter.getText()))
358                 preferredAccountsFilter.setText(text);
359         });
360         hookTextChangeSyncRoutine(preferredAccountsFilter, model::setPreferredAccountsFilter);
361         preferredAccountsFilterLayout =
362                 context.findViewById(R.id.preferred_accounts_accounts_filter_layout);
363
364         insecureWarningText = context.findViewById(R.id.insecure_scheme_text);
365
366         hookClearErrorOnFocusListener(profileName, profileNameLayout);
367         hookClearErrorOnFocusListener(url, urlLayout);
368         hookClearErrorOnFocusListener(userName, userNameLayout);
369         hookClearErrorOnFocusListener(password, passwordLayout);
370
371         if (savedInstanceState == null) {
372             model.setValuesFromProfile(mProfile, getArguments().getInt(ARG_HUE, -1));
373         }
374         checkInsecureSchemeWithAuth();
375
376         url.addTextChangedListener(new TextWatcher() {
377             @Override
378             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
379             @Override
380             public void onTextChanged(CharSequence s, int start, int before, int count) {}
381             @Override
382             public void afterTextChanged(Editable s) {
383                 checkInsecureSchemeWithAuth();
384             }
385         });
386
387         huePickerView.setOnClickListener(v -> {
388             HueRingDialog d = new HueRingDialog(
389                     Objects.requireNonNull(ProfileDetailFragment.this.getContext()), profileThemeId,
390                     (Integer) v.getTag());
391             d.show();
392             d.setColorSelectedListener(model::setThemeId);
393         });
394
395         profileName.requestFocus();
396     }
397     private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
398         switch (itemId) {
399             case R.id.menu_future_dates_7:
400                 return MobileLedgerProfile.FutureDates.OneWeek;
401             case R.id.menu_future_dates_14:
402                 return MobileLedgerProfile.FutureDates.TwoWeeks;
403             case R.id.menu_future_dates_30:
404                 return MobileLedgerProfile.FutureDates.OneMonth;
405             case R.id.menu_future_dates_60:
406                 return MobileLedgerProfile.FutureDates.TwoMonths;
407             case R.id.menu_future_dates_90:
408                 return MobileLedgerProfile.FutureDates.ThreeMonths;
409             case R.id.menu_future_dates_180:
410                 return MobileLedgerProfile.FutureDates.SixMonths;
411             case R.id.menu_future_dates_365:
412                 return MobileLedgerProfile.FutureDates.OneYear;
413             case R.id.menu_future_dates_all:
414                 return MobileLedgerProfile.FutureDates.All;
415             default:
416                 return MobileLedgerProfile.FutureDates.None;
417         }
418     }
419     @NotNull
420     private ProfileDetailModel getModel() {
421         return new ViewModelProvider(this).get(ProfileDetailModel.class);
422     }
423     private void onSaveFabClicked() {
424         if (!checkValidity())
425             return;
426
427         ProfileDetailModel model = getModel();
428
429         if (mProfile != null) {
430             model.updateProfile(mProfile);
431 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
432             mProfile.storeInDB();
433             debug("profiles", "profile stored in DB");
434             triggerProfileChange();
435         }
436         else {
437             mProfile = new MobileLedgerProfile();
438             model.updateProfile(mProfile);
439             mProfile.storeInDB();
440             final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
441             if (profiles == null)
442                 throw new AssertionError();
443             ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
444             newList.add(mProfile);
445             Data.profiles.setValue(newList);
446             MobileLedgerProfile.storeProfilesOrder();
447
448             // first profile ever?
449             if (newList.size() == 1)
450                 Data.profile.setValue(mProfile);
451         }
452
453         Activity activity = getActivity();
454         if (activity != null)
455             activity.finish();
456     }
457     @Override
458     public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
459                              Bundle savedInstanceState) {
460
461         return inflater.inflate(R.layout.profile_detail, container, false);
462     }
463     private boolean checkUrlValidity() {
464         boolean valid = true;
465
466         ProfileDetailModel model = getModel();
467
468         String val = model.getUrl()
469                           .trim();
470         if (val.isEmpty()) {
471             valid = false;
472             urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
473         }
474         try {
475             URL url = new URL(val);
476             String host = url.getHost();
477             if (host == null || host.isEmpty())
478                 throw new MalformedURLException("Missing host");
479             String protocol = url.getProtocol()
480                                  .toUpperCase();
481             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
482                 valid = false;
483                 urlLayout.setError(getResources().getText(R.string.err_invalid_url));
484             }
485         }
486         catch (MalformedURLException e) {
487             valid = false;
488             urlLayout.setError(getResources().getText(R.string.err_invalid_url));
489         }
490
491         return valid;
492     }
493     private void checkInsecureSchemeWithAuth() {
494         boolean showWarning = false;
495
496         final ProfileDetailModel model = getModel();
497
498         if (model.getUseAuthentication()) {
499             String urlText = model.getUrl();
500             if (urlText.startsWith("http") && !urlText.startsWith("https"))
501                 showWarning = true;
502         }
503
504         if (showWarning)
505             insecureWarningText.setVisibility(View.VISIBLE);
506         else
507             insecureWarningText.setVisibility(View.GONE);
508     }
509     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
510         view.setOnFocusChangeListener((v, hasFocus) -> {
511             if (hasFocus)
512                 layout.setError(null);
513         });
514         view.addTextChangedListener(new TextWatcher() {
515             @Override
516             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
517             }
518             @Override
519             public void onTextChanged(CharSequence s, int start, int before, int count) {
520                 layout.setError(null);
521             }
522             @Override
523             public void afterTextChanged(Editable s) {
524             }
525         });
526     }
527     private void syncModelFromUI() {
528         if (syncingModelFromUI)
529             return;
530
531         syncingModelFromUI = true;
532
533         try {
534             ProfileDetailModel model = getModel();
535
536             model.setProfileName(profileName.getText());
537             model.setUrl(url.getText());
538             model.setPreferredAccountsFilter(preferredAccountsFilter.getText());
539             model.setAuthUserName(userName.getText());
540             model.setAuthPassword(password.getText());
541         }
542         finally {
543             syncingModelFromUI = false;
544         }
545     }
546     private boolean checkValidity() {
547         boolean valid = true;
548
549         String val = String.valueOf(profileName.getText());
550         if (val.trim()
551                .isEmpty())
552         {
553             valid = false;
554             profileNameLayout.setError(getResources().getText(R.string.err_profile_name_empty));
555         }
556
557         if (!checkUrlValidity())
558             valid = false;
559
560         if (useAuthentication.isChecked()) {
561             val = String.valueOf(userName.getText());
562             if (val.trim()
563                    .isEmpty())
564             {
565                 valid = false;
566                 userNameLayout.setError(
567                         getResources().getText(R.string.err_profile_user_name_empty));
568             }
569
570             val = String.valueOf(password.getText());
571             if (val.trim()
572                    .isEmpty())
573             {
574                 valid = false;
575                 passwordLayout.setError(
576                         getResources().getText(R.string.err_profile_password_empty));
577             }
578         }
579
580         return valid;
581     }
582     private void resetDefaultCommodity() {
583         defaultCommoditySet = false;
584         defaultCommodity.setText(R.string.btn_no_currency);
585         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.ITALIC);
586     }
587     private void setDefaultCommodity(@NonNull @NotNull String name) {
588         defaultCommoditySet = true;
589         defaultCommodity.setText(name);
590         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.BOLD);
591     }
592     interface TextChangeSyncProc {
593         void onTextChanged(String text);
594     }
595 }