2 * Copyright © 2021 Damyan Ivanov.
3 * This file is part of MoLe.
4 * MoLe is free software: you can distribute it and/or modify it
5 * under the term of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your opinion), any later version.
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.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.PopupMenu;
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.floatingactionbutton.FloatingActionButton;
44 import com.google.android.material.textfield.TextInputLayout;
46 import net.ktnx.mobileledger.BuildConfig;
47 import net.ktnx.mobileledger.R;
48 import net.ktnx.mobileledger.dao.ProfileDAO;
49 import net.ktnx.mobileledger.databinding.ProfileDetailBinding;
50 import net.ktnx.mobileledger.db.DB;
51 import net.ktnx.mobileledger.db.Profile;
52 import net.ktnx.mobileledger.json.API;
53 import net.ktnx.mobileledger.model.Data;
54 import net.ktnx.mobileledger.model.FutureDates;
55 import net.ktnx.mobileledger.ui.CurrencySelectorFragment;
56 import net.ktnx.mobileledger.ui.HueRingDialog;
57 import net.ktnx.mobileledger.utils.Colors;
58 import net.ktnx.mobileledger.utils.Misc;
60 import org.jetbrains.annotations.NonNls;
61 import org.jetbrains.annotations.NotNull;
63 import java.net.MalformedURLException;
65 import java.util.List;
67 import static net.ktnx.mobileledger.utils.Logger.debug;
70 * A fragment representing a single Profile detail screen.
71 * a {@link ProfileDetailActivity}
74 public class ProfileDetailFragment extends Fragment {
76 * The fragment argument representing the item ID that this fragment
79 public static final String ARG_ITEM_ID = "item_id";
80 public static final String ARG_HUE = "hue";
83 private Profile mProfile;
84 private boolean defaultCommoditySet;
85 private boolean syncingModelFromUI = false;
86 private ProfileDetailBinding binding;
88 * Mandatory empty constructor for the fragment manager to instantiate the
89 * fragment (e.g. upon screen orientation changes).
91 public ProfileDetailFragment() {
92 super(R.layout.profile_detail);
95 public void onCreateOptionsMenu(@NotNull Menu menu, @NotNull MenuInflater inflater) {
96 debug("profiles", "[fragment] Creating profile details options menu");
97 super.onCreateOptionsMenu(menu, inflater);
98 inflater.inflate(R.menu.profile_details, menu);
99 final MenuItem menuDeleteProfile = menu.findItem(R.id.menuDelete);
100 menuDeleteProfile.setOnMenuItemClickListener(item -> onDeleteProfile());
101 final List<Profile> profiles = Data.profiles.getValue();
103 if (BuildConfig.DEBUG) {
104 final MenuItem menuWipeProfileData = menu.findItem(R.id.menuWipeData);
105 menuWipeProfileData.setOnMenuItemClickListener(ignored -> onWipeDataMenuClicked());
106 menuWipeProfileData.setVisible(mProfile != null);
109 private boolean onDeleteProfile() {
110 AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
111 builder.setTitle(mProfile.getName());
112 builder.setMessage(R.string.remove_profile_dialog_message);
113 builder.setPositiveButton(R.string.Remove, (dialog, which) -> {
114 debug("profiles", String.format("[fragment] removing profile %s", mProfile.getId()));
115 ProfileDAO dao = DB.get()
117 dao.delete(mProfile, () -> dao.updateOrderSync(dao.getAllOrderedSync()));
119 final FragmentActivity activity = getActivity();
120 if (activity != null)
126 private boolean onWipeDataMenuClicked() {
127 // this is a development option, so no confirmation
128 mProfile.wipeAllData();
131 private void hookTextChangeSyncRoutine(TextView view, TextChangeSyncRoutine syncRoutine) {
132 view.addTextChangedListener(new TextWatcher() {
134 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
136 public void onTextChanged(CharSequence s, int start, int before, int count) {}
138 public void afterTextChanged(Editable s) { syncRoutine.onTextChanged(s.toString());}
143 public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
144 @Nullable Bundle savedInstanceState) {
145 binding = ProfileDetailBinding.inflate(inflater, container, false);
147 return binding.getRoot();
150 public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) {
151 super.onViewCreated(view, savedInstanceState);
152 Activity context = getActivity();
156 final LifecycleOwner viewLifecycleOwner = getViewLifecycleOwner();
157 final ProfileDetailModel model = getModel();
159 model.observeDefaultCommodity(viewLifecycleOwner, c -> {
161 setDefaultCommodity(c);
163 resetDefaultCommodity();
166 FloatingActionButton fab = context.findViewById(R.id.fabAdd);
167 fab.setOnClickListener(v -> onSaveFabClicked());
169 hookTextChangeSyncRoutine(binding.profileName, model::setProfileName);
170 model.observeProfileName(viewLifecycleOwner, pn -> {
171 if (!Misc.equalStrings(pn, Misc.nullIsEmpty(binding.profileName.getText())))
172 binding.profileName.setText(pn);
175 hookTextChangeSyncRoutine(binding.url, model::setUrl);
176 model.observeUrl(viewLifecycleOwner, u -> {
177 if (!Misc.equalStrings(u, Misc.nullIsEmpty(binding.url.getText())))
178 binding.url.setText(u);
181 binding.defaultCommodityLayout.setOnClickListener(v -> {
182 CurrencySelectorFragment cpf = CurrencySelectorFragment.newInstance(
183 CurrencySelectorFragment.DEFAULT_COLUMN_COUNT, false);
184 cpf.setOnCurrencySelectedListener(model::setDefaultCommodity);
185 final AppCompatActivity activity = (AppCompatActivity) v.getContext();
186 cpf.show(activity.getSupportFragmentManager(), "currency-selector");
189 binding.profileShowCommodity.setOnCheckedChangeListener(
190 (buttonView, isChecked) -> model.setShowCommodityByDefault(isChecked));
191 model.observeShowCommodityByDefault(viewLifecycleOwner,
192 binding.profileShowCommodity::setChecked);
194 model.observePostingPermitted(viewLifecycleOwner, isChecked -> {
195 binding.profilePermitPosting.setChecked(isChecked);
196 binding.postingSubItems.setVisibility(isChecked ? View.VISIBLE : View.GONE);
198 binding.profilePermitPosting.setOnCheckedChangeListener(
199 ((buttonView, isChecked) -> model.setPostingPermitted(isChecked)));
201 model.observeShowCommentsByDefault(viewLifecycleOwner,
202 binding.profileShowComments::setChecked);
203 binding.profileShowComments.setOnCheckedChangeListener(
204 ((buttonView, isChecked) -> model.setShowCommentsByDefault(isChecked)));
206 binding.futureDatesLayout.setOnClickListener(v -> {
207 MenuInflater mi = new MenuInflater(context);
208 PopupMenu menu = new PopupMenu(context, v);
209 menu.inflate(R.menu.future_dates);
210 menu.setOnMenuItemClickListener(item -> {
211 model.setFutureDates(futureDatesSettingFromMenuItemId(item.getItemId()));
216 model.observeFutureDates(viewLifecycleOwner,
217 v -> binding.futureDatesText.setText(v.getText(getResources())));
219 model.observeApiVersion(viewLifecycleOwner,
220 apiVer -> binding.apiVersionText.setText(apiVer.getDescription(getResources())));
221 binding.apiVersionLabel.setOnClickListener(this::chooseAPIVersion);
222 binding.apiVersionText.setOnClickListener(this::chooseAPIVersion);
224 binding.serverVersionLabel.setOnClickListener(v -> model.triggerVersionDetection());
225 model.observeDetectedVersion(viewLifecycleOwner, ver -> {
227 binding.detectedServerVersionText.setText(context.getResources()
229 R.string.server_version_unknown_label));
230 else if (ver.isPre_1_20_1())
231 binding.detectedServerVersionText.setText(context.getResources()
233 R.string.detected_server_pre_1_20_1));
235 binding.detectedServerVersionText.setText(ver.toString());
237 binding.detectedServerVersionText.setOnClickListener(v -> model.triggerVersionDetection());
238 binding.serverVersionDetectButton.setOnClickListener(v -> model.triggerVersionDetection());
239 model.observeDetectingHledgerVersion(viewLifecycleOwner,
240 running -> binding.serverVersionDetectButton.setVisibility(
241 running ? View.VISIBLE : View.INVISIBLE));
243 binding.enableHttpAuth.setOnCheckedChangeListener((buttonView, isChecked) -> {
244 boolean wasOn = model.getUseAuthentication();
245 model.setUseAuthentication(isChecked);
246 if (!wasOn && isChecked)
247 binding.authUserName.requestFocus();
249 model.observeUseAuthentication(viewLifecycleOwner, isChecked -> {
250 binding.enableHttpAuth.setChecked(isChecked);
251 binding.authParams.setVisibility(isChecked ? View.VISIBLE : View.GONE);
252 checkInsecureSchemeWithAuth();
255 model.observeUserName(viewLifecycleOwner, text -> {
256 if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.authUserName.getText())))
257 binding.authUserName.setText(text);
259 hookTextChangeSyncRoutine(binding.authUserName, model::setAuthUserName);
261 model.observePassword(viewLifecycleOwner, text -> {
262 if (!Misc.equalStrings(text, Misc.nullIsEmpty(binding.password.getText())))
263 binding.password.setText(text);
265 hookTextChangeSyncRoutine(binding.password, model::setAuthPassword);
267 model.observeThemeId(viewLifecycleOwner, themeId -> {
268 final int hue = (themeId == -1) ? Colors.DEFAULT_HUE_DEG : themeId;
269 final int profileColor = Colors.getPrimaryColorForHue(hue);
270 binding.btnPickRingColor.setBackgroundColor(profileColor);
271 binding.btnPickRingColor.setTag(hue);
274 model.observePreferredAccountsFilter(viewLifecycleOwner, text -> {
275 if (!Misc.equalStrings(text,
276 Misc.nullIsEmpty(binding.preferredAccountsFilter.getText())))
277 binding.preferredAccountsFilter.setText(text);
279 hookTextChangeSyncRoutine(binding.preferredAccountsFilter,
280 model::setPreferredAccountsFilter);
282 hookClearErrorOnFocusListener(binding.profileName, binding.profileNameLayout);
283 hookClearErrorOnFocusListener(binding.url, binding.urlLayout);
284 hookClearErrorOnFocusListener(binding.authUserName, binding.authUserNameLayout);
285 hookClearErrorOnFocusListener(binding.password, binding.passwordLayout);
287 binding.url.addTextChangedListener(new TextWatcher() {
289 public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
291 public void onTextChanged(CharSequence s, int start, int before, int count) {}
293 public void afterTextChanged(Editable s) {
294 checkInsecureSchemeWithAuth();
298 binding.btnPickRingColor.setOnClickListener(v -> {
299 HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
300 model.initialThemeHue, (Integer) v.getTag());
302 d.setColorSelectedListener(model::setThemeId);
305 binding.profileName.requestFocus();
307 private void chooseAPIVersion(View v) {
308 Activity context = getActivity();
309 ProfileDetailModel model = getModel();
310 MenuInflater mi = new MenuInflater(context);
311 PopupMenu menu = new PopupMenu(context, v);
312 menu.inflate(R.menu.api_version);
313 menu.setOnMenuItemClickListener(item -> {
315 int itemId = item.getItemId();
316 if (itemId == R.id.api_version_menu_html) {
319 else if (itemId == R.id.api_version_menu_1_19_1) {
320 apiVer = API.v1_19_1;
322 else if (itemId == R.id.api_version_menu_1_15) {
325 else if (itemId == R.id.api_version_menu_1_14) {
331 model.setApiVersion(apiVer);
332 binding.apiVersionText.setText(apiVer.getDescription(getResources()));
337 private FutureDates futureDatesSettingFromMenuItemId(int itemId) {
338 if (itemId == R.id.menu_future_dates_7) {
339 return FutureDates.OneWeek;
341 else if (itemId == R.id.menu_future_dates_14) {
342 return FutureDates.TwoWeeks;
344 else if (itemId == R.id.menu_future_dates_30) {
345 return FutureDates.OneMonth;
347 else if (itemId == R.id.menu_future_dates_60) {
348 return FutureDates.TwoMonths;
350 else if (itemId == R.id.menu_future_dates_90) {
351 return FutureDates.ThreeMonths;
353 else if (itemId == R.id.menu_future_dates_180) {
354 return FutureDates.SixMonths;
356 else if (itemId == R.id.menu_future_dates_365) {
357 return FutureDates.OneYear;
359 else if (itemId == R.id.menu_future_dates_all) {
360 return FutureDates.All;
362 return FutureDates.None;
365 private ProfileDetailModel getModel() {
366 return new ViewModelProvider(requireActivity()).get(ProfileDetailModel.class);
368 private void onSaveFabClicked() {
369 if (!checkValidity())
372 ProfileDetailModel model = getModel();
373 ProfileDAO dao = DB.get()
376 if (mProfile != null) {
377 model.updateProfile(mProfile);
378 dao.update(mProfile, null);
379 debug("profiles", "profile stored in DB");
380 // debug("profiles", String.format("Selected item is %d", mProfile.getThemeHue()));
383 mProfile = new Profile();
384 model.updateProfile(mProfile);
385 dao.insertLast(mProfile, null);
388 Activity activity = getActivity();
389 if (activity != null)
392 private boolean checkUrlValidity() {
393 boolean valid = true;
395 ProfileDetailModel model = getModel();
397 String val = model.getUrl()
401 binding.urlLayout.setError(getResources().getText(R.string.err_profile_url_empty));
404 URL url = new URL(val);
405 String host = url.getHost();
406 if (host == null || host.isEmpty())
407 throw new MalformedURLException("Missing host");
408 String protocol = url.getProtocol()
410 if (!protocol.equals("HTTP") && !protocol.equals("HTTPS")) {
412 binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
415 catch (MalformedURLException e) {
417 binding.urlLayout.setError(getResources().getText(R.string.err_invalid_url));
422 private void checkInsecureSchemeWithAuth() {
423 boolean showWarning = false;
425 final ProfileDetailModel model = getModel();
427 if (model.getUseAuthentication()) {
428 String urlText = model.getUrl();
429 if (urlText.startsWith("http://") ||
430 urlText.length() >= 8 && !urlText.startsWith("https://"))
435 binding.insecureSchemeText.setVisibility(View.VISIBLE);
437 binding.insecureSchemeText.setVisibility(View.GONE);
439 private void hookClearErrorOnFocusListener(TextView view, TextInputLayout layout) {
440 view.setOnFocusChangeListener((v, hasFocus) -> {
442 layout.setError(null);
444 view.addTextChangedListener(new TextWatcher() {
446 public void beforeTextChanged(CharSequence s, int start, int count, int after) {
449 public void onTextChanged(CharSequence s, int start, int before, int count) {
450 layout.setError(null);
453 public void afterTextChanged(Editable s) {
457 private void syncModelFromUI() {
458 if (syncingModelFromUI)
461 syncingModelFromUI = true;
464 ProfileDetailModel model = getModel();
466 model.setProfileName(binding.profileName.getText());
467 model.setUrl(binding.url.getText());
468 model.setPreferredAccountsFilter(binding.preferredAccountsFilter.getText());
469 model.setAuthUserName(binding.authUserName.getText());
470 model.setAuthPassword(binding.password.getText());
473 syncingModelFromUI = false;
476 private boolean checkValidity() {
477 boolean valid = true;
479 String val = String.valueOf(binding.profileName.getText());
484 binding.profileNameLayout.setError(
485 getResources().getText(R.string.err_profile_name_empty));
488 if (!checkUrlValidity())
491 if (binding.enableHttpAuth.isChecked()) {
492 val = String.valueOf(binding.authUserName.getText());
497 binding.authUserNameLayout.setError(
498 getResources().getText(R.string.err_profile_user_name_empty));
501 val = String.valueOf(binding.password.getText());
506 binding.passwordLayout.setError(
507 getResources().getText(R.string.err_profile_password_empty));
513 private void resetDefaultCommodity() {
514 defaultCommoditySet = false;
515 binding.defaultCommodityText.setText(R.string.btn_no_currency);
516 binding.defaultCommodityText.setTypeface(binding.defaultCommodityText.getTypeface(),
519 private void setDefaultCommodity(@NonNull @NotNull String name) {
520 defaultCommoditySet = true;
521 binding.defaultCommodityText.setText(name);
522 binding.defaultCommodityText.setTypeface(Typeface.DEFAULT);
524 interface TextChangeSyncRoutine {
525 void onTextChanged(String text);