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); } } }