2 * Copyright © 2020 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.
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.
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/>.
18 package net.ktnx.mobileledger.ui.profiles;
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.Menu;
27 import android.view.MenuInflater;
28 import android.view.MenuItem;
29 import android.view.View;
30 import android.widget.LinearLayout;
31 import android.widget.PopupMenu;
32 import android.widget.Switch;
33 import android.widget.TextView;
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 import androidx.appcompat.app.AppCompatActivity;
38 import androidx.fragment.app.Fragment;
39 import androidx.fragment.app.FragmentActivity;
40 import androidx.lifecycle.LifecycleOwner;
41 import androidx.lifecycle.ViewModelProvider;
43 import com.google.android.material.appbar.CollapsingToolbarLayout;
44 import com.google.android.material.floatingactionbutton.FloatingActionButton;
45 import com.google.android.material.textfield.TextInputLayout;
47 import net.ktnx.mobileledger.BuildConfig;
48 import net.ktnx.mobileledger.R;
49 import net.ktnx.mobileledger.async.SendTransactionTask;
50 import net.ktnx.mobileledger.model.Data;
51 import net.ktnx.mobileledger.model.MobileLedgerProfile;
52 import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
53 import net.ktnx.mobileledger.ui.HueRingDialog;
54 import net.ktnx.mobileledger.ui.activity.ProfileDetailActivity;
55 import net.ktnx.mobileledger.utils.Colors;
56 import net.ktnx.mobileledger.utils.Misc;
58 import org.jetbrains.annotations.NonNls;
59 import org.jetbrains.annotations.NotNull;
61 import java.net.MalformedURLException;
63 import java.util.ArrayList;
64 import java.util.UUID;
66 import static net.ktnx.mobileledger.utils.Logger.debug;
69 * A fragment representing a single Profile detail screen.
70 * a {@link ProfileDetailActivity}
73 public class ProfileDetailFragment extends Fragment {
75 * The fragment argument representing the item ID that this fragment
78 public static final String ARG_ITEM_ID = "item_id";
79 public static final String ARG_HUE = "hue";
82 private MobileLedgerProfile mProfile;
84 private TextView defaultCommodity;
85 private boolean defaultCommoditySet;
86 private TextInputLayout urlLayout;
87 private LinearLayout authParams;
88 private Switch useAuthentication;
89 private TextView userName;
90 private TextInputLayout userNameLayout;
91 private TextView password;
92 private TextInputLayout passwordLayout;
93 private TextView profileName;
94 private TextInputLayout profileNameLayout;
95 private TextView preferredAccountsFilter;
96 private View huePickerView;
97 private View insecureWarningText;
98 private TextView futureDatesText;
99 private TextView apiVersionText;
100 private boolean syncingModelFromUI = false;
102 * Mandatory empty constructor for the fragment manager to instantiate the
103 * fragment (e.g. upon screen orientation changes).
105 public ProfileDetailFragment() {
106 super(R.layout.profile_detail);
109 public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
110 debug("profiles", "[fragment] Creating profile details options menu");
111 super.onCreateOptionsMenu(menu, inflater);
112 inflater.inflate(R.menu.profile_details, menu);
113 final MenuItem menuDeleteProfile = menu.findItem(R.id.menuDelete);
114 menuDeleteProfile.setOnMenuItemClickListener(item -> {
115 AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
116 builder.setTitle(mProfile.getName());
117 builder.setMessage(R.string.remove_profile_dialog_message);
118 builder.setPositiveButton(R.string.Remove, (dialog, which) -> {
120 String.format("[fragment] removing profile %s", mProfile.getUuid()));
121 mProfile.removeFromDB();
122 ArrayList<MobileLedgerProfile> oldList = Data.profiles.getValue();
124 throw new AssertionError();
125 ArrayList<MobileLedgerProfile> newList = new ArrayList<>(oldList);
126 newList.remove(mProfile);
127 Data.profiles.setValue(newList);
128 if (mProfile.equals(Data.getProfile())) {
129 debug("profiles", "[fragment] setting current profile to 0");
130 Data.setCurrentProfile(newList.get(0));
133 final FragmentActivity activity = getActivity();
134 if (activity != null)
140 final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
141 menuDeleteProfile.setVisible(
142 (mProfile != null) && (profiles != null) && (profiles.size() > 1));
144 if (BuildConfig.DEBUG) {
145 final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
146 menuWipeProfileData.setOnMenuItemClickListener(ignored -> onWipeDataMenuClicked());
147 menuWipeProfileData.setVisible(mProfile != null);
150 private boolean onWipeDataMenuClicked() {
151 // this is a development option, so no confirmation
152 mProfile.wipeAllData();
153 if (mProfile.equals(Data.getProfile()))
154 triggerProfileChange();
157 private void triggerProfileChange() {
158 int index = Data.getProfileIndex(mProfile);
159 MobileLedgerProfile newProfile = new MobileLedgerProfile(mProfile);
160 final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
161 if (profiles == null)
162 throw new AssertionError();
163 profiles.set(index, newProfile);
165 ProfilesRecyclerViewAdapter viewAdapter = ProfilesRecyclerViewAdapter.getInstance();
166 if (viewAdapter != null)
167 viewAdapter.notifyItemChanged(index);
169 if (mProfile.equals(Data.getProfile()))
170 Data.setCurrentProfile(newProfile);
172 private void hookTextChangeSyncRoutine(TextView view, TextChangeSyncRoutine syncRoutine) {
173 view.addTextChangedListener(new TextWatcher() {
175 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
177 public void onTextChanged(CharSequence s, int start, int before, int count) {}
179 public void afterTextChanged(Editable s) { syncRoutine.onTextChanged(s.toString());}
183 public void onActivityCreated(@Nullable Bundle savedInstanceState) {
184 super.onActivityCreated(savedInstanceState);
185 Activity context = getActivity();
189 if ((getArguments() != null) && getArguments().containsKey(ARG_ITEM_ID)) {
190 int index = getArguments().getInt(ARG_ITEM_ID, -1);
191 ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
192 if ((profiles != null) && (index != -1) && (index < profiles.size()))
193 mProfile = profiles.get(index);
195 Activity activity = this.getActivity();
196 if (activity == null)
197 throw new AssertionError();
198 CollapsingToolbarLayout appBarLayout = activity.findViewById(R.id.toolbar_layout);
199 if (appBarLayout != null) {
200 if (mProfile != null)
201 appBarLayout.setTitle(mProfile.getName());
203 appBarLayout.setTitle(getResources().getString(R.string.new_profile_title));
207 final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
208 final ProfileDetailModel model = getModel();
210 model.observeDefaultCommodity(viewLifecycleOwner, c -> {
212 setDefaultCommodity(c.getName());
214 resetDefaultCommodity();
217 FloatingActionButton fab = context.findViewById(R.id.fab);
218 fab.setOnClickListener(v -> onSaveFabClicked());
220 profileName = context.findViewById(R.id.profile_name);
221 hookTextChangeSyncRoutine(profileName, model::setProfileName);
222 model.observeProfileName(viewLifecycleOwner, pn -> {
223 if (!Misc.equalStrings(pn, profileName.getText()))
224 profileName.setText(pn);
227 profileNameLayout = context.findViewById(R.id.profile_name_layout);
229 url = context.findViewById(R.id.url);
230 hookTextChangeSyncRoutine(url, model::setUrl);
231 model.observeUrl(viewLifecycleOwner, u -> {
232 if (!Misc.equalStrings(u, url.getText()))
236 urlLayout = context.findViewById(R.id.url_layout);
238 context.findViewById(R.id.default_commodity_layout)
239 .setOnClickListener(v -> {
240 CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
241 CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
242 cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
243 final AppCompatActivity activity = (AppCompatActivity) v.getContext();
244 cpf.show(activity.getSupportFragmentManager(), "currency-selector");
247 Switch showCommodityByDefault = context.findViewById(R.id.profile_show_commodity);
248 showCommodityByDefault.setOnCheckedChangeListener(
249 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
250 model.observeShowCommodityByDefault(viewLifecycleOwner, showCommodityByDefault::setChecked);
252 View postingSubItems = context.findViewById(R.id.posting_sub_items);
254 Switch postingPermitted = context.findViewById(R.id.profile_permit_posting);
255 model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
256 postingPermitted.setChecked(isChecked);
257 postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
259 postingPermitted.setOnCheckedChangeListener(
260 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
262 Switch showCommentsByDefault = context.findViewById(R.id.profile_show_comments);
263 model.observeShowCommentsByDefault(viewLifecycleOwner, showCommentsByDefault::setChecked);
264 showCommentsByDefault.setOnCheckedChangeListener(
265 ((buttonView, isChecked) -> model.setShowCommentsByDefault(isChecked)));
267 defaultCommodity = context.findViewById(R.id.default_commodity_text);
269 futureDatesText = context.findViewById(R.id.future_dates_text);
270 context.findViewById(R.id.future_dates_layout)
271 .setOnClickListener(v -> {
272 MenuInflater mi = new MenuInflater(context);
273 PopupMenu menu = new PopupMenu(context, v);
274 menu.inflate(R.menu.future_dates);
275 menu.setOnMenuItemClickListener(item -> {
276 model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
281 model.observeFutureDates(viewLifecycleOwner,
282 v -> futureDatesText.setText(v.getText(getResources())));
284 apiVersionText = context.findViewById(R.id.api_version_text);
285 model.observeApiVersion(viewLifecycleOwner,
286 apiVer -> apiVersionText.setText(apiVer.getDescription(getResources())));
287 context.findViewById(R.id.api_version_layout)
288 .setOnClickListener(v -> {
289 MenuInflater mi = new MenuInflater(context);
290 PopupMenu menu = new PopupMenu(context, v);
291 menu.inflate(R.menu.api_version);
292 menu.setOnMenuItemClickListener(item -> {
293 SendTransactionTask.API apiVer;
294 switch (item.getItemId()) {
295 case R.id.api_version_menu_html:
296 apiVer = SendTransactionTask.API.html;
298 case R.id.api_version_menu_post_1_14:
299 apiVer = SendTransactionTask.API.post_1_14;
301 case R.id.api_version_menu_pre_1_15:
302 apiVer = SendTransactionTask.API.pre_1_15;
304 case R.id.api_version_menu_auto:
306 apiVer = SendTransactionTask.API.auto;
308 model.setApiVersion(apiVer);
309 apiVersionText.setText(apiVer.getDescription(getResources()));
314 authParams = context.findViewById(R.id.auth_params);
316 useAuthentication = context.findViewById(R.id.enable_http_auth);
317 useAuthentication.setOnCheckedChangeListener((buttonView, isChecked) -> {
318 model.setUseAuthentication(isChecked);
320 userName.requestFocus();
322 model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
323 useAuthentication.setChecked(isChecked);
324 authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
325 checkInsecureSchemeWithAuth();
328 userName = context.findViewById(R.id.auth_user_name);
329 model.observeUserName(viewLifecycleOwner, text -> {
330 if (!Misc.equalStrings(text, userName.getText()))
331 userName.setText(text);
333 hookTextChangeSyncRoutine(userName, model::setAuthUserName);
334 userNameLayout = context.findViewById(R.id.auth_user_name_layout);
336 password = context.findViewById(R.id.password);
337 model.observePassword(viewLifecycleOwner, text -> {
338 if (!Misc.equalStrings(text, password.getText()))
339 password.setText(text);
341 hookTextChangeSyncRoutine(password, model::setAuthPassword);
342 passwordLayout = context.findViewById(R.id.password_layout);
344 huePickerView = context.findViewById(R.id.btn_pick_ring_color);
345 model.observeThemeId(viewLifecycleOwner, themeId -> {
346 final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
347 final int profileColor = Colors.getPrimaryColorForHue(hue);
348 huePickerView.setBackgroundColor(profileColor);
349 huePickerView.setTag(hue);
352 preferredAccountsFilter = context.findViewById(R.id.preferred_accounts_filter_filter);
353 model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
354 if (!Misc.equalStrings(text, preferredAccountsFilter.getText()))
355 preferredAccountsFilter.setText(text);
357 hookTextChangeSyncRoutine(preferredAccountsFilter, model::setPreferredAccountsFilter);
359 insecureWarningText = context.findViewById(R.id.insecure_scheme_text);
361 hookClearErrorOnFocusListener(profileName, profileNameLayout);
362 hookClearErrorOnFocusListener(url, urlLayout);
363 hookClearErrorOnFocusListener(userName, userNameLayout);
364 hookClearErrorOnFocusListener(password, passwordLayout);
366 if (savedInstanceState == null) {
367 model.setValuesFromProfile(mProfile, getArguments().getInt(ARG_HUE, -1));
369 checkInsecureSchemeWithAuth();
371 url.addTextChangedListener(new TextWatcher() {
373 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
375 public void onTextChanged(CharSequence s, int start, int before, int count) {}
377 public void afterTextChanged(Editable s) {
378 checkInsecureSchemeWithAuth();
382 huePickerView.setOnClickListener(v -> {
383 HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
384 model.initialThemeHue, (Integer) v.getTag());
386 d.setColorSelectedListener(model::setThemeId);
389 profileName.requestFocus();
391 private MobileLedgerProfile.FutureDates futureDatesSettingFromMenuItemId(int itemId) {
393 case R.id.menu_future_dates_7:
394 return MobileLedgerProfile.FutureDates.OneWeek;
395 case R.id.menu_future_dates_14:
396 return MobileLedgerProfile.FutureDates.TwoWeeks;
397 case R.id.menu_future_dates_30:
398 return MobileLedgerProfile.FutureDates.OneMonth;
399 case R.id.menu_future_dates_60:
400 return MobileLedgerProfile.FutureDates.TwoMonths;
401 case R.id.menu_future_dates_90:
402 return MobileLedgerProfile.FutureDates.ThreeMonths;
403 case R.id.menu_future_dates_180:
404 return MobileLedgerProfile.FutureDates.SixMonths;
405 case R.id.menu_future_dates_365:
406 return MobileLedgerProfile.FutureDates.OneYear;
407 case R.id.menu_future_dates_all:
408 return MobileLedgerProfile.FutureDates.All;
410 return MobileLedgerProfile.FutureDates.None;
414 private ProfileDetailModel getModel() {
415 return new ViewModelProvider(requireActivity()).get(ProfileDetailModel.class);
417 private void onSaveFabClicked() {
418 if (!checkValidity())
421 ProfileDetailModel model = getModel();
423 if (mProfile != null) {
424 model.updateProfile(mProfile);
425 // debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
426 mProfile.storeInDB();
427 debug("profiles", "profile stored in DB");
428 triggerProfileChange();
431 mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID()));
432 model.updateProfile(mProfile);
433 mProfile.storeInDB();
434 final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
435 if (profiles == null)
436 throw new AssertionError();
437 ArrayList<MobileLedgerProfile> newList = new ArrayList<>(profiles);
438 newList.add(mProfile);
439 Data.profiles.setValue(newList);
440 MobileLedgerProfile.storeProfilesOrder();
442 // first profile ever?
443 if (newList.size() == 1)
444 Data.setCurrentProfile(mProfile);
447 Activity activity = getActivity();
448 if (activity != null)
451 private boolean checkUrlValidity() {
452 boolean valid = true;
454 ProfileDetailModel model = getModel();
456 String val = model.getUrl()
460 urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
463 URL url = new URL(val);
464 String host = url.getHost();
465 if (host == null || host.isEmpty())
466 throw new MalformedURLException("Missing host");
467 String protocol = url.getProtocol()
469 if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
471 urlLayout.setError(getResources().getText(R.string.err_invalid_url));
474 catch (MalformedURLException e) {
476 urlLayout.setError(getResources().getText(R.string.err_invalid_url));
481 private void checkInsecureSchemeWithAuth() {
482 boolean showWarning = false;
484 final ProfileDetailModel model = getModel();
486 if (model.getUseAuthentication()) {
487 String urlText = model.getUrl();
488 if (urlText.startsWith("http") && !urlText.startsWith("https"))
493 insecureWarningText.setVisibility(View.VISIBLE);
495 insecureWarningText.setVisibility(View.GONE);
497 private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
498 view.setOnFocusChangeListener((v, hasFocus) -> {
500 layout.setError(null);
502 view.addTextChangedListener(new TextWatcher() {
504 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
507 public void onTextChanged(CharSequence s, int start, int before, int count) {
508 layout.setError(null);
511 public void afterTextChanged(Editable s) {
515 private void syncModelFromUI() {
516 if (syncingModelFromUI)
519 syncingModelFromUI = true;
522 ProfileDetailModel model = getModel();
524 model.setProfileName(profileName.getText());
525 model.setUrl(url.getText());
526 model.setPreferredAccountsFilter(preferredAccountsFilter.getText());
527 model.setAuthUserName(userName.getText());
528 model.setAuthPassword(password.getText());
531 syncingModelFromUI = false;
534 private boolean checkValidity() {
535 boolean valid = true;
537 String val = String.valueOf(profileName.getText());
542 profileNameLayout.setError(getResources().getText(R.string.err_profile_name_empty));
545 if (!checkUrlValidity())
548 if (useAuthentication.isChecked()) {
549 val = String.valueOf(userName.getText());
554 userNameLayout.setError(
555 getResources().getText(R.string.err_profile_user_name_empty));
558 val = String.valueOf(password.getText());
563 passwordLayout.setError(
564 getResources().getText(R.string.err_profile_password_empty));
570 private void resetDefaultCommodity() {
571 defaultCommoditySet = false;
572 defaultCommodity.setText(R.string.btn_no_currency);
573 defaultCommodity.setTypeface(defaultCommodity.getTypeface(), Typeface.ITALIC);
575 private void setDefaultCommodity(@NonNull @NotNull String name) {
576 defaultCommoditySet = true;
577 defaultCommodity.setText(name);
578 defaultCommodity.setTypeface(Typeface.DEFAULT);
580 interface TextChangeSyncRoutine {
581 void onTextChanged(String text);