package com.ftinc.scoop;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.PorterDuff;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.support.annotation.ColorInt;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StyleRes;
import android.support.annotation.UiThread;
import android.support.v7.app.AppCompatDelegate;
import android.util.Log;
import android.util.SparseArray;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.animation.Interpolator;
import com.ftinc.scoop.adapters.ColorAdapter;
import com.ftinc.scoop.binding.AnimatedBinding;
import com.ftinc.scoop.binding.IBinding;
import com.ftinc.scoop.binding.StatusBarBinding;
import com.ftinc.scoop.binding.ViewBinding;
import com.ftinc.scoop.internal.ToppingBinder;
import com.ftinc.scoop.util.AttrUtils;
import com.ftinc.scoop.util.BindingUtils;
import java.security.InvalidParameterException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static com.ftinc.scoop.SugarCone.BINDERS;
import static com.ftinc.scoop.SugarCone.NOP_VIEW_BINDER;
/**
* Project: ThemeEngineTest
* Package: com.ftinc.scoop
* Created by drew.heavner on 6/7/16.
*/
public class Scoop {
private static final String TAG = "Scoop";
/***********************************************************************************************
*
* Singleton Configuration
*
*/
private static Scoop _instance = null;
// TODO: Find a better name for this
public static Scoop getInstance(){
if(_instance == null) _instance = new Scoop();
return _instance;
}
/**
* Create a builder instance for this class to initialize the library
*
* @return the Builder to initialize the library with
*/
public static Builder waffleCone(){
return new Builder();
}
/**
* @deprecated Please just use the {@link #getInstance()} method of Scoop to access
* the bind methods
*/
@Deprecated
public static SugarCone sugarCone(){
Scoop instance = getInstance();
instance.checkInit();
return instance.mSugarCone;
}
/***********************************************************************************************
*
* Constants
*
*/
static final String PREFERENCE_FLAVOR_KEY = "com.ftinc.scoop.preference.FLAVOR_KEY";
static final String PREFERENCE_DAYNIGHT_KEY = "com.ftinc.scoop.preference.DAY_NIGHT_KEY";
/***********************************************************************************************
*
* Variables
*
*/
/**
* Static mapping of all the available base application themes to use/apply
* mapped by a developer defined ID
*/
private List<Flavor> mFlavors = new ArrayList<>();
/**
* Mapping of all the toppings that the user has binded for
*/
private SparseArray<Topping> mToppings = new SparseArray<>();
/**
* Mapping of all the bindings per class
*/
private HashMap<Class, Set<IBinding>> mBindings = new HashMap<>();
/**
* The index of the default flavor value
*/
private int mDefaultFlavorIndex = 0;
/**
* Determine if initialized
*/
private boolean mInitialized = false;
/**
* SharedPreference store to handle and save changes to the base theme configuration
*/
private SharedPreferences mPreferences;
/**
* SugarCone instance to track the deeper color customizations
*/
private SugarCone mSugarCone;
/**
* Debug flag for logging
*/
private static boolean debug = false;
/**
* Private constructor to prevent initialization
*/
private Scoop(){}
/***********************************************************************************************
*
* Private Helper Methods
*
*/
/**
* Initialize this helper class with the provided builder
* @param builder
*/
private void initialize(Builder builder){
// Validate builder
if(builder.prefs != null && !builder.flavors.isEmpty()){
// Set Preference Storage
mPreferences = builder.prefs;
// Set Flavors
mFlavors = new ArrayList<>(builder.flavors);
// Set the default flavor if configured
if(builder.defaultFlavor != null){
mDefaultFlavorIndex = mFlavors.indexOf(builder.defaultFlavor);
}
// Deprecated
mSugarCone = new SugarCone();
// Set init flag
mInitialized = true;
}else {
throw new IllegalStateException("SharedPreferences and at least one flavor must be set");
}
}
/**
* Get the index of the current configured flavor
*
* @return the index of the current flavor to apply
*/
private int getCurrentFlavorIndex(){
checkInit();
// Get the selected flavor index from the preference storage
int flavorIndex = mPreferences.getInt(PREFERENCE_FLAVOR_KEY, mDefaultFlavorIndex);
// Verify that index is valid
if(flavorIndex > -1 && flavorIndex < mFlavors.size()){
return flavorIndex;
}
return mDefaultFlavorIndex;
}
/**
* Get the current selected scoop of flavor
*
* @param excludeDefault whether or not to return null if the current selected is the default theme
* @return the current scoop of flavor
*/
private Flavor getCurrentFlavor(boolean excludeDefault){
int index = getCurrentFlavorIndex();
if(index != mDefaultFlavorIndex || !excludeDefault) {
return mFlavors.get(index);
}
return null;
}
/**
* Verify the initialization state of the utility
*/
private void checkInit(){
if(!mInitialized) throw new IllegalStateException("Scoop needs to be initialized first!");
}
@NonNull @UiThread
private ToppingBinder<Object> getViewBinder(@NonNull Object target) {
Class<?> targetClass = target.getClass();
if (debug) Log.d(TAG, "Looking up topping binder for " + targetClass.getName());
return findViewBinderForClass(targetClass);
}
@NonNull @UiThread
private ToppingBinder<Object> findViewBinderForClass(Class<?> cls) {
ToppingBinder<Object> viewBinder = BINDERS.get(cls);
if (viewBinder != null) {
if (debug) Log.d(TAG, "HIT: Cached in topping binder map.");
return viewBinder;
}
String clsName = cls.getName();
if (clsName.startsWith("android.") || clsName.startsWith("java.")) {
if (debug) Log.d(TAG, "MISS: Reached framework class. Abandoning search.");
return NOP_VIEW_BINDER;
}
//noinspection TryWithIdenticalCatches Resolves to API 19+ only type.
try {
Class<?> viewBindingClass = Class.forName(clsName + "_ToppingBinder");
//noinspection unchecked
viewBinder = (ToppingBinder<Object>) viewBindingClass.newInstance();
if (debug) Log.d(TAG, "HIT: Loaded topping binder class.");
} catch (ClassNotFoundException e) {
if (debug) Log.d(TAG, "Not found. Trying superclass " + cls.getSuperclass().getName());
viewBinder = findViewBinderForClass(cls.getSuperclass());
} catch (InstantiationException e) {
throw new RuntimeException("Unable to create topping binder for " + clsName, e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Unable to create topping binder for " + clsName, e);
}
BINDERS.put(cls, viewBinder);
return viewBinder;
}
/**
* Get the set of bindings for a given class
*
* @param clazz the class key for the bindings to look up
* @return the set of bindings for the class
*/
private Set<IBinding> getBindings(Class clazz){
Set<IBinding> bindings = mBindings.get(clazz);
if(bindings == null){
bindings = new HashSet<>();
mBindings.put(clazz, bindings);
}
return bindings;
}
/**
* Find the {@link Topping} object for it's given Id or create one if not found
*
* @param toppingId the id of the topping to get
* @return the topping associated with the id
*/
private Topping getOrCreateTopping(int toppingId){
Topping topping = mToppings.get(toppingId);
if(topping == null){
topping = new Topping(toppingId);
mToppings.put(toppingId, topping);
}
return topping;
}
private void autoUpdateBinding(IBinding binding, Topping topping){
if(topping.getColor() != 0) {
if (binding instanceof AnimatedBinding) {
((AnimatedBinding) binding).update(topping, false);
} else {
binding.update(topping);
}
}
}
/***********************************************************************************************
*
* Public Methods
*
*/
/**
* Enable debug logging
*/
public static void setDebug(boolean flag){
debug = flag;
}
/**
* Get the selected day night mode to use with certain themes
*
* @return the day night mode to use
*/
@AppCompatDelegate.NightMode
public int getDayNightMode(){
checkInit();
return mPreferences.getInt(PREFERENCE_DAYNIGHT_KEY, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
}
/**
* Get the list of available flavors that you can scoop from
*
* @return
*/
public List<Flavor> getFlavors(){
return Collections.unmodifiableList(mFlavors);
}
/**
* Get the current flavor to apply
*
* @return one scoop of ice cream
*/
public Flavor getCurrentFlavor(){
return getCurrentFlavor(false);
}
/**
* Apply the current {@link Flavor} to the given activity based on the user's selected preference.
*
* @param activity the activity to apply the selected theme configuration to
*/
@SuppressWarnings("WrongConstant")
public void apply(Activity activity){
Flavor flavor = getCurrentFlavor(true);
if(flavor != null){
// Apply DayNight mode setting if applicable
if(flavor.isDayNight()){
AppCompatDelegate.setDefaultNightMode(getDayNightMode());
}
// Apply theme
apply(activity, flavor.getStyleResource());
}
}
/**
* Apply the current {@link Flavor}s Dialog theme to the activity to give it a Dialog like
* appearance based on the user selected preference
*
* @param activity the activity to apply the dialog theme to
*/
@SuppressWarnings("WrongConstant")
public void applyDialog(Activity activity){
Flavor flavor = getCurrentFlavor(true);
if(flavor != null && flavor.getDialogStyleResource() > -1){
// Apply DayNight mode setting if applicable
if(flavor.isDayNight()){
AppCompatDelegate.setDefaultNightMode(getDayNightMode());
}
// Apply theme
apply(activity, flavor.getDialogStyleResource());
}
}
/**
* Apply the desired theme to an activity and it's window
*
* @param activity the activity to apply to
* @param theme the theme to apply
*/
private void apply(Activity activity, @StyleRes int theme){
// Apply theme
activity.setTheme(theme);
// Ensure window background get's properly set
int color = AttrUtils.getColorAttr(activity, android.R.attr.colorBackground);
activity.getWindow().setBackgroundDrawable(new ColorDrawable(color));
}
/**
* Apply the attributed menu item tint to all the icons if the attribute {@link R.attr#toolbarItemTint}
*
* @param context the application context to derive the attr color from
* @param menu the menu to apply to
*/
public void apply(Context context, Menu menu){
Flavor flavor = getCurrentFlavor();
if(menu != null && menu.size() > 0 && flavor != null){
int tint = AttrUtils.getColorAttr(context, flavor.getStyleResource(), R.attr.toolbarItemTint);
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
Drawable icon = item.getIcon();
if(icon != null){
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
icon.setTint(tint);
}else{
icon.setColorFilter(tint, PorterDuff.Mode.SRC_ATOP);
}
}
}
}
}
/**
* Choose a given flavor
*
* @param item the flavor to scoop
*/
public void choose(Flavor item) {
checkInit();
int index = mFlavors.indexOf(item);
mPreferences.edit().putInt(PREFERENCE_FLAVOR_KEY, index).apply();
}
/**
* Choose the DayNight mode you want to use for selected day/night mode themes
*
* @param mode the daynight mode you wish to use
*/
public void chooseDayNightMode(@AppCompatDelegate.NightMode int mode){
checkInit();
mPreferences.edit().putInt(PREFERENCE_DAYNIGHT_KEY, mode).apply();
}
/***********************************************************************************************
*
* Topping Binding Methods
*
*/
/**
* Bind all the annotated elements to a given activity
*
* @see BindTopping
* @see BindToppingStatus
*
* @param activity the activity to bind to
*/
public void bind(Activity activity){
// Get the pre-genereated bindings
List<IBinding> bindings = getViewBinder(activity).bind(activity);
// Iterate and verify topping creation and auto-applying
for (IBinding binding : bindings) {
Topping topping = getOrCreateTopping(binding.getToppingId());
autoUpdateBinding(binding, topping);
}
// add to system
Set<IBinding> _bindings = getBindings(activity.getClass());
_bindings.addAll(bindings);
}
/**
* Bind a view to a topping on a given object
*
* @param obj the class the view belongs to
* @param toppingId the id of the topping to bind to
* @param view the view to bind
* @return self for chaining
*/
public Scoop bind(Object obj, int toppingId, View view){
return bind(obj, toppingId, view, null);
}
/**
* Bind a view to a topping on a given object with a specified color adapter
*
* @param obj the classs the view belongs to
* @param toppingId the id of the topping
* @param view the view to bind
* @param colorAdapter the color adapter to bind with
* @return self for chaining
*/
public Scoop bind(Object obj, int toppingId, View view, @Nullable ColorAdapter colorAdapter){
return bind(obj, toppingId, view, colorAdapter, null);
}
/**
* Bind a view to a topping on a given object with a specified color adapter and change animation
* interpolator
*
* @param obj the class the view belongs to
* @param toppingId the id of the topping
* @param view the view to bind
* @param colorAdapter the color adapter to bind with
* @param interpolator the interpolator to use when switching colors
* @return self for chaining
*/
public Scoop bind(Object obj, int toppingId, View view, @Nullable ColorAdapter colorAdapter, @Nullable Interpolator interpolator){
// Get a default color adapter if not supplied
if(colorAdapter == null){
colorAdapter = BindingUtils.getColorAdapter(view.getClass());
}
// Generate Binding
IBinding binding = new ViewBinding(toppingId, view, colorAdapter, interpolator);
// Bind
return bind(obj, toppingId, binding);
}
/**
* Bind the status bar of an activity to a topping so that it's color is updated when the
* user/developer updates the color for that topping id
*
* @param activity the activity whoes status bar to bind to
* @param toppingId the id of the topping to bind with
* @return self for chaining
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public Scoop bindStatusBar(Activity activity, int toppingId){
return bindStatusBar(activity, toppingId, null);
}
/**
* Bind the status bar of an activity to a topping so that it's color is updated when the
* user/developer updates the color for that topping id and animation it's color change using
* the provided interpolator
*
* @param activity the activity whoes status bar to bind to
* @param toppingId the id of the topping to bind with
* @param interpolator the interpolator that defines how the animation for the color change will run
* @return self for chaining
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public Scoop bindStatusBar(Activity activity, int toppingId, @Nullable Interpolator interpolator){
IBinding binding = new StatusBarBinding(toppingId, activity, interpolator);
return bind(activity, toppingId, binding);
}
/**
* Provide a custom binding to a certain topping id on a given object. This allows you to
* customize the changes between color on certain properties, i.e. Toppings, to define it
* to your use case
*
* @param obj the object to bind on
* @param toppingId the topping id to bind to
* @param binding the binding that defines how your custom properties are updated
* @return self for chaining
*/
public Scoop bind(Object obj, int toppingId, IBinding binding){
// Find or Create Topping
Topping topping = getOrCreateTopping(toppingId);
// If topping has a color set, auto-apply to binding
autoUpdateBinding(binding, topping);
// Store binding
Set<IBinding> bindings = getBindings(obj.getClass());
bindings.add(binding);
return this;
}
/**
* Unbind all bindings on a certain class
*
* @param obj the class/object that you previously made bindings to (i.e. an Activity, or Fragment)
*/
public void unbind(Object obj){
Set<IBinding> bindings = getBindings(obj.getClass());
for (IBinding binding : bindings) {
binding.unbind();
}
// Clear the bindings out of the map
mBindings.remove(obj.getClass());
}
/**
* Update a topping, i.e. color property, with a new color and therefore sending it out to
* all your bindings
*
* @param toppingId the id of the topping you wish to update
* @param color the updated color to update to
* @return self for chaining.
*/
public Scoop update(int toppingId, @ColorInt int color){
// Get the topping
Topping topping = mToppings.get(toppingId);
if(topping != null){
topping.updateColor(color);
// Update bindings
Collection<Set<IBinding>> bindings = mBindings.values();
for (Set<IBinding> bindingSet : bindings) {
for (IBinding binding : bindingSet) {
if(binding.getToppingId() == toppingId) {
binding.update(topping);
}
}
}
}else{
throw new InvalidParameterException("Nothing has been bound to the Topping of the given id (" + toppingId + ").");
}
return this;
}
/***********************************************************************************************
*
* Initialization Builder
*
*/
public static class Builder{
private SharedPreferences prefs;
private Flavor defaultFlavor;
private final List<Flavor> flavors;
Builder(){
flavors = new ArrayList<>();
}
public Builder addFlavor(String name,
@StyleRes int styleResourceId){
return addFlavor(name, styleResourceId, -1, false);
}
public Builder addDayNightFlavor(String name,
@StyleRes int styleResourceId){
return addFlavor(name, styleResourceId, -1, false, true);
}
public Builder addFlavor(String name,
@StyleRes int styleResourceId,
boolean isDefault){
return addFlavor(name, styleResourceId, -1, isDefault);
}
public Builder addDayNightFlavor(String name,
@StyleRes int styleResourceId,
boolean isDefault){
return addFlavor(name, styleResourceId, -1, isDefault, true);
}
public Builder addFlavor(String name,
@StyleRes int styleResourceId,
@StyleRes int dialogStyleResourceId){
return addFlavor(name, styleResourceId, dialogStyleResourceId, false);
}
public Builder addFlavor(String name,
@StyleRes int styleResourceId,
@StyleRes int dialogStyleResourceId,
boolean isDefault){
return addFlavor(name, styleResourceId, dialogStyleResourceId, isDefault, false);
}
public Builder addFlavor(String name,
@StyleRes int styleResourceId,
@StyleRes int dialogStyleResourceId,
boolean isDefault,
boolean isDayNight){
Flavor flavor = new Flavor(name, styleResourceId, dialogStyleResourceId, isDayNight);
if(isDefault) defaultFlavor = flavor;
return addFlavor(flavor);
}
public Builder addFlavor(Flavor... flavor){
flavors.addAll(Arrays.asList(flavor));
return this;
}
/**
* @deprecated Toppings no longer need to be pre-instantiated
*/
@Deprecated
public Builder addToppings(Topping... toppings){
// This does nothing now
return this;
}
public Builder setSharedPreferences(SharedPreferences prefs){
this.prefs = prefs;
return this;
}
public void initialize(){
Scoop.getInstance()
.initialize(this);
}
}
}