]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
profile details: put posting params in a layout so that they can be hidden easier
[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         View postingSubItems = context.findViewById(R.id.posting_sub_items);
260
261         Switch postingPermitted = context.findViewById(R.id.profile_permit_posting);
262         model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
263             postingPermitted.setChecked(isChecked);
264             postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
265         });
266         postingPermitted.setOnCheckedChangeListener(
267                 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
268
269         defaultCommodity = context.findViewById(R.id.default_commodity_text);
270
271         futureDatesLayout = context.findViewById(R.id.future_dates_layout);
272         futureDatesText = context.findViewById(R.id.future_dates_text);
273         context.findViewById(R.id.future_dates_layout)
274                .setOnClickListener(v -> {
275                    MenuInflater mi = new MenuInflater(context);
276                    PopupMenu menu = new PopupMenu(context, v);
277                    menu.inflate(R.menu.future_dates);
278                    menu.setOnMenuItemClickListener(item -> {
279                        model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
280                        return true;
281                    });
282                    menu.show();
283                });
284         model.observeFutureDates(viewLifecycleOwner,
285                 v -> futureDatesText.setText(v.getText(getResources())));
286
287         apiVersionText = context.findViewById(R.id.api_version_text);
288         model.observeApiVersion(viewLifecycleOwner, apiVer -> {
289             apiVersionText.setText(apiVer.getDescription(getResources()));
290         });
291         context.findViewById(R.id.api_version_layout)
292                .setOnClickListener(v -> {
293                    MenuInflater mi = new MenuInflater(context);
294                    PopupMenu menu = new PopupMenu(context, v);
295                    menu.inflate(R.menu.api_version);
296                    menu.setOnMenuItemClickListener(item -> {
297                        SendTransactionTask.API apiVer;
298                        switch (item.getItemId()) {
299                            case R.id.api_version_menu_html:
300                                apiVer = SendTransactionTask.API.html;
301                                break;
302                            case R.id.api_version_menu_post_1_14:
303                                apiVer = SendTransactionTask.API.post_1_14;
304                                break;
305                            case R.id.api_version_menu_pre_1_15:
306                                apiVer = SendTransactionTask.API.pre_1_15;
307                                break;
308                            case R.id.api_version_menu_auto:
309                            default:
310                                apiVer = SendTransactionTask.API.auto;
311                        }
312                        model.setApiVersion(apiVer);
313                        apiVersionText.setText(apiVer.getDescription(getResources()));
314                        return true;
315                    });
316                    menu.show();
317                });
318         authParams = context.findViewById(R.id.auth_params);
319
320         useAuthentication = context.findViewById(R.id.enable_http_auth);
321         useAuthentication.setOnCheckedChangeListener((buttonView, isChecked) -> {
322             model.setUseAuthentication(isChecked);
323             if (isChecked)
324                 userName.requestFocus();
325         });
326         model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
327             useAuthentication.setChecked(isChecked);
328             authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
329             checkInsecureSchemeWithAuth();
330         });
331
332         userName = context.findViewById(R.id.auth_user_name);
333         model.observeUserName(viewLifecycleOwner, text -> {
334             if (!Misc.equalStrings(text, userName.getText()))
335                 userName.setText(text);
336         });
337         hookTextChangeSyncRoutine(userName, model::setAuthUserName);
338         userNameLayout = context.findViewById(R.id.auth_user_name_layout);
339
340         password = context.findViewById(R.id.password);
341         model.observePassword(viewLifecycleOwner, text -> {
342             if (!Misc.equalStrings(text, password.getText()))
343                 password.setText(text);
344         });
345         hookTextChangeSyncRoutine(password, model::setAuthPassword);
346         passwordLayout = context.findViewById(R.id.password_layout);
347
348         huePickerView = context.findViewById(R.id.btn_pick_ring_color);
349         model.observeThemeId(viewLifecycleOwner, themeId -> {
350             final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
351             final int profileColor = Colors.getPrimaryColorForHue(hue);
352             huePickerView.setBackgroundColor(profileColor);
353             huePickerView.setTag(hue);
354         });
355
356         preferredAccountsFilter = context.findViewById(R.id.preferred_accounts_filter_filter);
357         model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
358             if (!Misc.equalStrings(text, preferredAccountsFilter.getText()))
359                 preferredAccountsFilter.setText(text);
360         });
361         hookTextChangeSyncRoutine(preferredAccountsFilter, model::setPreferredAccountsFilter);
362         preferredAccountsFilterLayout =
363                 context.findViewById(R.id.preferred_accounts_accounts_filter_layout);
364
365         insecureWarningText = context.findViewById(R.id.insecure_scheme_text);
366
367         hookClearErrorOnFocusListener(profileName, profileNameLayout);
368         hookClearErrorOnFocusListener(url, urlLayout);
369         hookClearErrorOnFocusListener(userName, userNameLayout);
370         hookClearErrorOnFocusListener(password, passwordLayout);
371
372         if (savedInstanceState == null) {
373             model.setValuesFromProfile(mProfile, getArguments().getInt(ARG_HUE, -1));
374         }
375         checkInsecureSchemeWithAuth();
376
377         url.addTextChangedListener(new TextWatcher() {
378             @Override
379             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
380             @Override
381             public void onTextChanged(CharSequence s, int start, int before, int count) {}
382             @Override
383             public void afterTextChanged(Editable s) {
384                 checkInsecureSchemeWithAuth();
385             }
386         });
387
388         huePickerView.setOnClickListener(v -> {
389             HueRingDialog d = new HueRingDialog(
390                     Objects.requireNonNull(ProfileDetailFragment.this.getContext()), profileThemeId,
391                     (Integer) v.getTag());
392             d.show();
393             d.setColorSelectedListener(model::setThemeId);
394         });
395
396         profileName.requestFocus();
397     }
398     private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
399         switch (itemId) {
400             case R.id.menu_future_dates_7:
401                 return MobileLedgerProfile.FutureDates.OneWeek;
402             case R.id.menu_future_dates_14:
403                 return MobileLedgerProfile.FutureDates.TwoWeeks;
404             case R.id.menu_future_dates_30:
405                 return MobileLedgerProfile.FutureDates.OneMonth;
406             case R.id.menu_future_dates_60:
407                 return MobileLedgerProfile.FutureDates.TwoMonths;
408             case R.id.menu_future_dates_90:
409                 return MobileLedgerProfile.FutureDates.ThreeMonths;
410             case R.id.menu_future_dates_180:
411                 return MobileLedgerProfile.FutureDates.SixMonths;
412             case R.id.menu_future_dates_365:
413                 return MobileLedgerProfile.FutureDates.OneYear;
414             case R.id.menu_future_dates_all:
415                 return MobileLedgerProfile.FutureDates.All;
416             default:
417                 return MobileLedgerProfile.FutureDates.None;
418         }
419     }
420     @NotNull
421     private ProfileDetailModel getModel() {
422         return new ViewModelProvider(this).get(ProfileDetailModel.class);
423     }
424     private void onSaveFabClicked() {
425         if (!checkValidity())
426             return;
427
428         ProfileDetailModel model = getModel();
429
430         if (mProfile != null) {
431             model.updateProfile(mProfile);
432 //                debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
433             mProfile.storeInDB();
434             debug("profiles", "profile stored in DB");
435             triggerProfileChange();
436         }
437         else {
438             mProfile = new MobileLedgerProfile();
439             model.updateProfile(mProfile);
440             mProfile.storeInDB();
441             final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
442             if (profiles == null)
443                 throw new AssertionError();
444             ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
445             newList.add(mProfile);
446             Data.profiles.setValue(newList);
447             MobileLedgerProfile.storeProfilesOrder();
448
449             // first profile ever?
450             if (newList.size() == 1)
451                 Data.profile.setValue(mProfile);
452         }
453
454         Activity activity = getActivity();
455         if (activity != null)
456             activity.finish();
457     }
458     @Override
459     public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
460                              Bundle savedInstanceState) {
461
462         return inflater.inflate(R.layout.profile_detail, container, false);
463     }
464     private boolean checkUrlValidity() {
465         boolean valid = true;
466
467         ProfileDetailModel model = getModel();
468
469         String val = model.getUrl()
470                           .trim();
471         if (val.isEmpty()) {
472             valid = false;
473             urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
474         }
475         try {
476             URL url = new URL(val);
477             String host = url.getHost();
478             if (host == null || host.isEmpty())
479                 throw new MalformedURLException("Missing host");
480             String protocol = url.getProtocol()
481                                  .toUpperCase();
482             if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
483                 valid = false;
484                 urlLayout.setError(getResources().getText(R.string.err_invalid_url));
485             }
486         }
487         catch (MalformedURLException e) {
488             valid = false;
489             urlLayout.setError(getResources().getText(R.string.err_invalid_url));
490         }
491
492         return valid;
493     }
494     private void checkInsecureSchemeWithAuth() {
495         boolean showWarning = false;
496
497         final ProfileDetailModel model = getModel();
498
499         if (model.getUseAuthentication()) {
500             String urlText = model.getUrl();
501             if (urlText.startsWith("http") && !urlText.startsWith("https"))
502                 showWarning = true;
503         }
504
505         if (showWarning)
506             insecureWarningText.setVisibility(View.VISIBLE);
507         else
508             insecureWarningText.setVisibility(View.GONE);
509     }
510     private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
511         view.setOnFocusChangeListener((v, hasFocus) -> {
512             if (hasFocus)
513                 layout.setError(null);
514         });
515         view.addTextChangedListener(new TextWatcher() {
516             @Override
517             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
518             }
519             @Override
520             public void onTextChanged(CharSequence s, int start, int before, int count) {
521                 layout.setError(null);
522             }
523             @Override
524             public void afterTextChanged(Editable s) {
525             }
526         });
527     }
528     private void syncModelFromUI() {
529         if (syncingModelFromUI)
530             return;
531
532         syncingModelFromUI = true;
533
534         try {
535             ProfileDetailModel model = getModel();
536
537             model.setProfileName(profileName.getText());
538             model.setUrl(url.getText());
539             model.setPreferredAccountsFilter(preferredAccountsFilter.getText());
540             model.setAuthUserName(userName.getText());
541             model.setAuthPassword(password.getText());
542         }
543         finally {
544             syncingModelFromUI = false;
545         }
546     }
547     private boolean checkValidity() {
548         boolean valid = true;
549
550         String val = String.valueOf(profileName.getText());
551         if (val.trim()
552                .isEmpty())
553         {
554             valid = false;
555             profileNameLayout.setError(getResources().getText(R.string.err_profile_name_empty));
556         }
557
558         if (!checkUrlValidity())
559             valid = false;
560
561         if (useAuthentication.isChecked()) {
562             val = String.valueOf(userName.getText());
563             if (val.trim()
564                    .isEmpty())
565             {
566                 valid = false;
567                 userNameLayout.setError(
568                         getResources().getText(R.string.err_profile_user_name_empty));
569             }
570
571             val = String.valueOf(password.getText());
572             if (val.trim()
573                    .isEmpty())
574             {
575                 valid = false;
576                 passwordLayout.setError(
577                         getResources().getText(R.string.err_profile_password_empty));
578             }
579         }
580
581         return valid;
582     }
583     private void resetDefaultCommodity() {
584         defaultCommoditySet = false;
585         defaultCommodity.setText(R.string.btn_no_currency);
586         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.ITALIC);
587     }
588     private void setDefaultCommodity(@NonNull @NotNull String name) {
589         defaultCommoditySet = true;
590         defaultCommodity.setText(name);
591         defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.BOLD);
592     }
593     interface TextChangeSyncProc {
594         void onTextChanged(String text);
595     }
596 }