/*
* GraticulePicker.java
* Copyright (C) 2016 Nicholas Killewald
*
* This file is distributed under the terms of the BSD license.
* The source package should have a LICENSE file at the toplevel.
*/
package net.exclaimindustries.geohashdroid.widgets;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.RelativeLayout;
import net.exclaimindustries.geohashdroid.R;
import net.exclaimindustries.geohashdroid.util.GHDConstants;
import net.exclaimindustries.geohashdroid.util.Graticule;
/**
* This is the box of graticule-picking goodness that appears on the bottom of
* the map during Select-A-Graticule mode.
*/
public class GraticulePicker extends RelativeLayout {
private EditText mLat;
private EditText mLon;
private CheckBox mGlobal;
private Button mClosest;
private boolean mExternalUpdate;
private boolean mAlreadyLaidOut = false;
private boolean mWaitingToShow = false;
private GraticulePickerListener mListener;
/**
* The interface of choice for when GraticulePicker needs to talk back to
* something. Make sure something implements this, else the whole thing
* will sort of not work.
*/
public interface GraticulePickerListener {
/**
* Called when a new Graticule is picked. This is called EVERY time the
* user presses a key, so be careful. If the input is blatantly
* incomplete (i.e. empty or just a negative sign), this won't be
* called.
*
* @param g the new Graticule (null if it's a globalhash)
*/
void updateGraticule(@Nullable Graticule g);
/**
* Called when the user presses the "Find Closest" button. Later on,
* this widget should get setNewGraticule called on it with the results
* of said search (assuming it can get such a result).
*/
void findClosest();
/**
* Called when the picker is closing. For now, this just means when
* the close button is pressed. In the future, it might also mean if
* it gets swipe-to-dismiss'd. Note that this won't do its own
* dismissal. You need to handle that yourself once you get the
* callback.
*/
void graticulePickerClosing();
}
public GraticulePicker(Context c) {
this(c, null);
}
public GraticulePicker(Context c, AttributeSet attrs) {
super(c, attrs);
// Deal out a little bit of setup justice...
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(c);
boolean night = prefs.getBoolean(GHDConstants.PREF_NIGHT_MODE, false);
if(night)
setBackgroundColor(ContextCompat.getColor(c, android.R.color.black));
else
setBackgroundColor(ContextCompat.getColor(c, android.R.color.white));
int padding = getResources().getDimensionPixelSize(R.dimen.standard_padding);
setPadding(padding, padding, padding, padding);
setGravity(Gravity.CENTER_HORIZONTAL);
setClickable(true);
// Who wants a neat-looking shadow effect as if this were some sort of
// material hovering a few dp above the map? You do, assuming you're
// using Lollipop!
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
setElevation(getResources().getDimension(R.dimen.elevation_graticule_picker));
// Now then, let's get inflated.
inflate(c, R.layout.graticulepicker, this);
// Here come the widgets. Each is magical and unique.
mLat = (EditText)findViewById(R.id.grat_lat);
mLon = (EditText)findViewById(R.id.grat_lon);
mGlobal = (CheckBox)findViewById(R.id.grat_globalhash);
mClosest = (Button)findViewById(R.id.grat_closest);
ImageButton close = (ImageButton)findViewById(R.id.close);
// And how ARE they magical? Well, like this. First, any time the
// boxes are updated, send out a new Graticule to the Activity.
TextWatcher tw = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// Blah.
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// BLAH!
}
@Override
public void afterTextChanged(Editable s) {
// Action!
if(!mExternalUpdate)
dispatchGraticule();
}
};
mLat.addTextChangedListener(tw);
mLon.addTextChangedListener(tw);
// Also, when the checkbox gets changed, set/unset Globalhash mode.
mGlobal.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if(isChecked) {
// If it's checked, mLat and mLon go disabled, as you can't
// set a specific graticule.
mLat.setEnabled(false);
mLon.setEnabled(false);
} else {
mLat.setEnabled(true);
mLon.setEnabled(true);
}
if(!mExternalUpdate)
dispatchGraticule();
}
});
// Then, the Find Closest button. That one we foist off on the calling
// Activity.
mClosest.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
v.setEnabled(false);
if(mListener != null)
mListener.findClosest();
}
});
// The close button needs to, well, close.
close.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(mListener != null)
mListener.graticulePickerClosing();
}
});
// Plus, the close button needs updating if it's night. That grey is
// just a weeeeee bit too dark for the black background.
if(night) close.setImageDrawable(ContextCompat.getDrawable(c, R.drawable.cancel_button_dark));
// And then there's this again. Huh. You'd think I should make a
// parent class to handle this.
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// Got a height! Hopefully.
if(!mAlreadyLaidOut) {
mAlreadyLaidOut = true;
// Make it off-screen first, then animate it on if need be.
setGraticulePickerVisible(false);
animateGraticulePickerVisible(mWaitingToShow);
}
}
});
}
/**
* Sets a new Graticule for the EditTexts. This is called at startup and
* any time the user taps on the map to pick a new Graticule.
*
* @param g the new Graticule
*/
public void setNewGraticule(Graticule g) {
// Make sure this flag is set so we don't wind up double-updating.
mExternalUpdate = true;
// This should NEVER be a globalhash, as that isn't possible from the
// map. But, it could be null, and we're nothing if not defensive here.
if(g == null) {
mGlobal.setChecked(true);
} else {
// Update text as need be. Remember, negative zero IS valid!
mGlobal.setChecked(false);
mLat.setText(g.getLatitudeString(true));
mLon.setText(g.getLongitudeString(true));
}
// And we're done, so unset the flag.
mExternalUpdate = false;
// NOW we can dispatch the change.
dispatchGraticule();
}
/**
* Sets the globalhash checkbox to be checked or not. This may dispatch a
* new Graticule.
*
* @param global true to check, false to uncheck
*/
public void setGlobalHash(boolean global) {
mGlobal.setChecked(global);
}
/**
* Tells the widget to trigger its listener so its {@link GraticulePickerListener#updateGraticule(Graticule)}
* method will be called if need be.
*/
public void triggerListener() {
dispatchGraticule();
}
private void dispatchGraticule() {
Graticule toSend;
try {
toSend = buildGraticule();
} catch (Exception e) {
// If an exception is thrown, we don't have valid input.
return;
}
// If we got here, we can send it on its merry way!
if(mListener != null)
mListener.updateGraticule(toSend);
}
private Graticule buildGraticule() throws NullPointerException, NumberFormatException {
// First, read the inputs.
if(mGlobal.isChecked()) {
// A checked globalhash means we always send a null Graticule, no
// matter what the inputs say, even if those inputs are invalid.
return null;
} else {
// Otherwise, make a Graticule. The constructor will throw as need
// be.
return new Graticule(mLat.getText().toString(), mLon.getText().toString());
}
}
/**
* Sets the {@link GraticulePickerListener}. If this is either null or
* never called, this whole Fragment won't do much.
*
* @param listener the new listener
*/
public void setListener(GraticulePickerListener listener) {
mListener = listener;
dispatchGraticule();
}
/**
* Gets the currently-input Graticule. This will return null if there's no
* valid input yet OR if the Globalhash checkbox is ticked, so make sure to
* also check {@link #isGlobalhash()}.
*
* @return the current Graticule, or null
*/
public Graticule getGraticule() {
try {
return buildGraticule();
} catch(Exception e) {
return null;
}
}
/**
* Gets whether or not Globalhash is ticked. Note that if this is false, it
* doesn't necessarily mean there's a valid Graticule in the inputs.
*
* @return true if global, false if not
*/
public boolean isGlobalhash() {
return mGlobal.isChecked();
}
/**
* Resets the Find Closest button after it's been triggered. It'll be
* disabled otherwise.
*/
public void resetFindClosest() {
mClosest.setEnabled(true);
}
/**
* Makes the Find Closest button visible or invisible. Make it invisible if
* location permissions have been denied.
*
* @param hidden true to be {@link View#INVISIBLE}, false for {@link View#VISIBLE}
*/
public void setClosestHidden(boolean hidden) {
mClosest.setVisibility(hidden ? View.INVISIBLE : View.VISIBLE);
}
/**
* Slides the picker in to or out of view.
*
* @param visible true to slide in, false to slide out
*/
public void animateGraticulePickerVisible(boolean visible) {
if(!mAlreadyLaidOut) {
mWaitingToShow = visible;
} else {
if(!visible) {
// Slide out!
animate().translationY(getHeight()).alpha(0.0f);
} else {
// Slide in!
animate().translationY(0.0f).alpha(1.0f);
}
}
}
/**
* Slides the picker out of view (ONLY out of view), with an ending action.
*
* @param endAction action to perform after animation completes
*/
public void animateGraticulePickerOutWithEndAction(@Nullable Runnable endAction) {
animate().translationY(getHeight()).alpha(0.0f).withEndAction(endAction);
}
/**
* Makes the picker be in or out of view without animating it.
*
* @param visible true to appear, false to vanish
*/
public void setGraticulePickerVisible(boolean visible) {
if(!visible) {
setTranslationY(getHeight());
setAlpha(0.0f);
} else {
setTranslationY(0.0f);
setAlpha(1.0f);
}
}
}