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