diff --git a/Habitica/build.gradle b/Habitica/build.gradle
index 3c0d94e5f..5a50b6d4d 100644
--- a/Habitica/build.gradle
+++ b/Habitica/build.gradle
@@ -117,6 +117,13 @@ dependencies {
//Analytics
compile 'com.amplitude:android-sdk:2.7.1'
+ // Fresco Image Management Library
+ compile('com.facebook.fresco:fresco:0.10.0') {
+ exclude module: 'bolts-android'
+ }
+ compile('com.facebook.fresco:animated-gif:0.10.0') {
+ exclude module: 'bolts-android'
+ }
//Tests
testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.3.1'
testCompile 'org.robolectric:robolectric:3.0'
diff --git a/Habitica/res/values/attrs_avatar_view.xml b/Habitica/res/values/attrs_avatar_view.xml
new file mode 100644
index 000000000..595b30f9d
--- /dev/null
+++ b/Habitica/res/values/attrs_avatar_view.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java
new file mode 100644
index 000000000..957b4978b
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java
@@ -0,0 +1,452 @@
+package com.habitrpg.android.habitica.ui;
+
+import com.facebook.drawee.backends.pipeline.Fresco;
+import com.facebook.drawee.controller.BaseControllerListener;
+import com.facebook.drawee.generic.GenericDraweeHierarchy;
+import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
+import com.facebook.drawee.interfaces.DraweeController;
+import com.facebook.drawee.view.DraweeHolder;
+import com.facebook.drawee.view.MultiDraweeHolder;
+import com.facebook.imagepipeline.image.ImageInfo;
+import com.habitrpg.android.habitica.R;
+import com.magicmicky.habitrpgwrapper.lib.models.HabitRPGUser;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class AvatarView extends View {
+ public static final String IMAGE_URI_ROOT = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/";
+ private static final String TAG = "AvatarView";
+ private static final Map sFilenameMap;
+ private static final Rect sFullHeroRect = new Rect(0, 0, 140, 147);
+ private static final Rect sCompactHeroRect = new Rect(0, 0, 114, 114);
+ private static final Rect sHeroOnlyRect = new Rect(0, 0, 90, 90);
+
+ static {
+ Map tempMap = new HashMap<>();
+ tempMap.put("head_special_1", "ContributorOnly-Equip-CrystalHelmet.gif");
+ tempMap.put("armor_special_1", "ContributorOnly-Equip-CrystalArmor.gif");
+ tempMap.put("weapon_special_critical", "weapon_special_critical.gif");
+ tempMap.put("Pet-Wolf-Cerberus", "Pet-Wolf-Cerberus.gif");
+ sFilenameMap = Collections.unmodifiableMap(tempMap);
+ }
+
+ private boolean mShowBackground = true;
+ private boolean mShowMount = true;
+ private boolean mShowPet = true;
+ private boolean mHasBackground;
+ private boolean mHasMount;
+ private boolean mHasPet;
+ private boolean mIsOrphan;
+ private MultiDraweeHolder mMultiDraweeHolder = new MultiDraweeHolder<>();
+ private HabitRPGUser mUser;
+ private RectF mAvatarRectF;
+ private Matrix mMatrix = new Matrix();
+ private AtomicInteger mNumberLayersInProcess = new AtomicInteger(0);
+ private Consumer mAvatarImageConsumer;
+ private Bitmap mAvatarBitmap;
+ private Canvas mAvatarCanvas;
+
+ public AvatarView(Context context) {
+ super(context);
+ init(null, 0);
+ }
+
+ public AvatarView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ public AvatarView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(attrs, defStyle);
+ }
+
+ public AvatarView(Context context, boolean showBackground, boolean showMount, boolean showPet) {
+ super(context);
+
+ mShowBackground = showBackground;
+ mShowMount = showMount;
+ mShowPet = showPet;
+ mIsOrphan = true;
+ }
+
+ private void init(AttributeSet attrs, int defStyle) {
+ // Load attributes
+ final TypedArray a = getContext().obtainStyledAttributes(
+ attrs, R.styleable.AvatarView, defStyle, 0);
+
+ try {
+ mShowBackground = a.getBoolean(R.styleable.AvatarView_showBackground, true);
+ mShowMount = a.getBoolean(R.styleable.AvatarView_showMount, true);
+ mShowPet = a.getBoolean(R.styleable.AvatarView_showPet, true);
+ } finally {
+ a.recycle();
+ }
+ }
+
+ private void showLayers(@NonNull Map layerMap) {
+ if (mMultiDraweeHolder.size() > 0) return;
+ int i = 0;
+
+ mNumberLayersInProcess.set(layerMap.size());
+
+ for (Map.Entry entry : layerMap.entrySet()) {
+ final LayerType layerKey = entry.getKey();
+ final String layerName = entry.getValue();
+ final int layerNumber = i++;
+
+ GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(getResources())
+ .setFadeDuration(0)
+ .build();
+
+ DraweeHolder draweeHolder = DraweeHolder.create(hierarchy, getContext());
+ draweeHolder.getTopLevelDrawable().setCallback(this);
+ mMultiDraweeHolder.add(draweeHolder);
+
+ DraweeController controller = Fresco.newDraweeControllerBuilder()
+ .setUri(IMAGE_URI_ROOT + getFileName(layerName))
+ .setControllerListener(new BaseControllerListener() {
+ @Override
+ public void onFinalImageSet(
+ String id,
+ ImageInfo imageInfo,
+ Animatable anim) {
+ if (imageInfo != null) {
+ mMultiDraweeHolder.get(layerNumber).getTopLevelDrawable().setBounds(getLayerBounds(layerKey, layerName, imageInfo));
+ onLayerComplete();
+ }
+ }
+
+ @Override
+ public void onFailure(String id, Throwable throwable) {
+ Log.e(TAG, "Error loading layer: " + layerName, throwable);
+ onLayerComplete();
+ }
+ })
+ .setAutoPlayAnimations(!mIsOrphan)
+ .build();
+ draweeHolder.setController(controller);
+ }
+
+ if (mIsOrphan) mMultiDraweeHolder.onAttach();
+ }
+
+ private Map getLayerMap() {
+ assert mUser != null;
+ return getLayerMap(mUser, true);
+ }
+
+ private Map getLayerMap(@NonNull HabitRPGUser user, boolean resetHasAttributes) {
+ EnumMap layerMap = user.getAvatarLayerMap();
+
+ if (resetHasAttributes) mHasBackground = mHasMount = mHasPet = false;
+
+ String mountName = user.getItems().getCurrentMount();
+ if (mShowMount && !TextUtils.isEmpty(mountName)) {
+ layerMap.put(LayerType.MOUNT_BODY, "Mount_Body_" + mountName);
+ layerMap.put(LayerType.MOUNT_HEAD, "Mount_Head_" + mountName);
+ if (resetHasAttributes) mHasMount = true;
+ }
+
+ String petName = user.getItems().getCurrentPet();
+ if (mShowPet && !TextUtils.isEmpty(petName)) {
+ layerMap.put(LayerType.PET, "Pet-" + petName);
+ if (resetHasAttributes) mHasPet = true;
+ }
+
+ String backgroundName = user.getPreferences().getBackground();
+ if (mShowBackground && !TextUtils.isEmpty(backgroundName)) {
+ layerMap.put(LayerType.BACKGROUND, "background_" + backgroundName);
+ if (resetHasAttributes) mHasBackground = true;
+ }
+ return layerMap;
+ }
+
+ private Rect getLayerBounds(@NonNull LayerType layerType, @NonNull String layerName, @NonNull ImageInfo layerImageInfo) {
+ PointF offset = null;
+ Rect bounds = new Rect(0, 0, layerImageInfo.getWidth(), layerImageInfo.getHeight());
+ RectF boundsF = new RectF(bounds);
+
+ // lookup layer specific offset
+ switch (layerName) {
+ case "weapon_special_critical":
+ if (mShowMount || mShowPet) {
+ // full hero box
+ if (mHasMount) {
+ offset = new PointF(13.0f, 12.0f);
+ } else if (mHasPet) {
+ offset = new PointF(13.0f, 24.5f + 12.0f);
+ } else {
+ offset = new PointF(13.0f, 28.0f + 12.0f);
+ }
+ } else if (mShowBackground) {
+ // compact hero box
+ offset = new PointF(-12.0f, 18.0f + 12.0f);
+ } else {
+ // hero only box
+ offset = new PointF(-12.0f, 12.0f);
+ }
+ break;
+ default:
+ break;
+ }
+
+ // otherwise lookup default layer type based offset
+ if (offset == null) {
+ switch (layerType) {
+ case BACKGROUND:
+ if (!(mShowMount || mShowPet)) {
+ offset = new PointF(-25.0f, 0.0f); // compact hero box
+ }
+ break;
+ case MOUNT_BODY:
+ case MOUNT_HEAD:
+ offset = new PointF(25.0f, 18.0f); // full hero box
+ break;
+ case BACK:
+ case SKIN:
+ case SHIRT:
+ case ARMOR:
+ case BODY:
+ case HEAD_0:
+ case HAIR_BASE:
+ case HAIR_BANGS:
+ case HAIR_MUSTACHE:
+ case HAIR_BEARD:
+ case EYEWEAR:
+ case HEAD:
+ case HEAD_ACCESSORY:
+ case HAIR_FLOWER:
+ case SHIELD:
+ case WEAPON:
+ case ZZZ:
+ if (mShowMount || mShowPet) {
+ // full hero box
+ if (mHasMount) {
+ offset = new PointF(25.0f, 0);
+ } else if (mHasPet) {
+ offset = new PointF(25.0f, 24.5f);
+ } else {
+ offset = new PointF(25.0f, 28.0f);
+ }
+ } else if (mShowBackground) {
+ // compact hero box
+ offset = new PointF(0.0f, 18.0f);
+ }
+ break;
+ case PET:
+ offset = new PointF(0, sFullHeroRect.height() - layerImageInfo.getHeight());
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (offset != null) {
+ Matrix translateMatrix = new Matrix();
+ translateMatrix.setTranslate(offset.x, offset.y);
+ translateMatrix.mapRect(boundsF);
+ }
+
+ // resize bounds to fit and keep original aspect ratio
+ mMatrix.mapRect(boundsF);
+ boundsF.round(bounds);
+
+ return bounds;
+ }
+
+ private String getFileName(@NonNull String imageName) {
+ if (sFilenameMap.containsKey(imageName)) {
+ return sFilenameMap.get(imageName);
+ }
+
+ return imageName + ".png";
+ }
+
+ private void onLayerComplete() {
+ if (mNumberLayersInProcess.decrementAndGet() == 0) {
+ if (mAvatarImageConsumer != null) {
+ mAvatarImageConsumer.accept(getAvatarImage());
+ }
+ }
+ }
+
+ public void onAvatarImageReady(@NonNull Consumer consumer) {
+ mAvatarImageConsumer = consumer;
+ if (mMultiDraweeHolder.size() > 0 && mNumberLayersInProcess.get() == 0) {
+ mAvatarImageConsumer.accept(getAvatarImage());
+ } else {
+ initAvatarRectMatrix();
+ showLayers(getLayerMap());
+ }
+ }
+
+ public void setUser(@NonNull HabitRPGUser user) {
+ HabitRPGUser oldUser = mUser;
+ mUser = user;
+
+ if (oldUser != null) {
+ Map currentLayerMap = getLayerMap(oldUser, false);
+ Map newLayerMap = getLayerMap(user, false);
+ if (!currentLayerMap.equals(newLayerMap)) {
+ mMultiDraweeHolder.clear();
+ mNumberLayersInProcess.set(0);
+ }
+ }
+ invalidate();
+ }
+
+ private Rect getOriginalRect() {
+ return (mShowMount || mShowPet) ? sFullHeroRect : ((mShowBackground) ? sCompactHeroRect : sHeroOnlyRect);
+ }
+
+ private Bitmap getAvatarImage() {
+ assert mUser != null;
+ assert mAvatarRectF != null;
+ Rect canvasRect = new Rect();
+ mAvatarRectF.round(canvasRect);
+ mAvatarBitmap = Bitmap.createBitmap(canvasRect.width(), canvasRect.height(), Bitmap.Config.ARGB_8888);
+ mAvatarCanvas = new Canvas(mAvatarBitmap);
+ draw(mAvatarCanvas);
+
+ return mAvatarBitmap;
+ }
+
+ private void initAvatarRectMatrix() {
+ if (mAvatarRectF == null) {
+ Rect srcRect = getOriginalRect();
+
+ if (mIsOrphan) {
+ mAvatarRectF = new RectF(srcRect);
+
+ // change scale to not be 1:1
+ // a quick fix as fresco AnimatedDrawable/ScaleTypeDrawable
+ // will not translate matrix properly
+ mMatrix.setScale(1.2f, 1.2f);
+ mMatrix.mapRect(mAvatarRectF);
+ } else {
+ // full hero box when showMount and showPet is enabled (140w * 147h)
+ // compact hero box when only showBackground is enabled (114w * 114h)
+ // hero only box when all show settings disabled (90w * 90h)
+ mAvatarRectF = new RectF(0, 0, getWidth(), getHeight());
+ mMatrix.setRectToRect(new RectF(srcRect), mAvatarRectF, Matrix.ScaleToFit.START); // TODO support other ScaleToFit
+ mAvatarRectF = new RectF(srcRect);
+ mMatrix.mapRect(mAvatarRectF);
+ }
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ initAvatarRectMatrix();
+
+ // draw only when user is set
+ if (mUser == null) return;
+
+ // request image layers if not yet processed
+ if (mMultiDraweeHolder.size() == 0) {
+ showLayers(getLayerMap());
+ }
+
+ // manually call onAttach/onDetach if view is without parent as they will never be called otherwise
+ if (mIsOrphan) mMultiDraweeHolder.onAttach();
+ mMultiDraweeHolder.draw(canvas);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mMultiDraweeHolder.onDetach();
+ }
+
+ @Override
+ public void onStartTemporaryDetach() {
+ super.onStartTemporaryDetach();
+ mMultiDraweeHolder.onDetach();
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mMultiDraweeHolder.onAttach();
+ }
+
+ @Override
+ public void onFinishTemporaryDetach() {
+ super.onFinishTemporaryDetach();
+ mMultiDraweeHolder.onAttach();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ return mMultiDraweeHolder.onTouchEvent(event) || super.onTouchEvent(event);
+ }
+
+ @Override
+ protected boolean verifyDrawable(Drawable who) {
+ return mMultiDraweeHolder.verifyDrawable(who) || super.verifyDrawable(who);
+ }
+
+ @Override
+ public void invalidateDrawable(@NonNull Drawable drawable) {
+ invalidate();
+ if (mAvatarCanvas != null) draw(mAvatarCanvas);
+ }
+
+ public enum LayerType {
+ BACKGROUND(0),
+ MOUNT_BODY(1),
+ BACK(2),
+ SKIN(3),
+ SHIRT(4),
+ ARMOR(5),
+ BODY(6),
+ HEAD_0(7),
+ HAIR_BASE(8),
+ HAIR_BANGS(9),
+ HAIR_MUSTACHE(10),
+ HAIR_BEARD(11),
+ EYEWEAR(12),
+ HEAD(13),
+ HEAD_ACCESSORY(14),
+ HAIR_FLOWER(15),
+ SHIELD(16),
+ WEAPON(17),
+ MOUNT_HEAD(18),
+ ZZZ(19),
+ PET(20);
+
+ final int order;
+
+ LayerType(int order) {
+ this.order = order;
+ }
+ }
+
+ public interface Consumer {
+ void accept(T t);
+ }
+}
diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java
index c602d8feb..8eb02b879 100644
--- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java
+++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java
@@ -3,6 +3,7 @@ package com.magicmicky.habitrpgwrapper.lib.models;
import com.google.gson.annotations.SerializedName;
import com.habitrpg.android.habitica.HabitDatabase;
+import com.habitrpg.android.habitica.ui.AvatarView;
import com.magicmicky.habitrpgwrapper.lib.models.tasks.Task;
import com.magicmicky.habitrpgwrapper.lib.models.tasks.TasksOrder;
import com.raizlabs.android.dbflow.annotation.Column;
@@ -15,8 +16,12 @@ import com.raizlabs.android.dbflow.sql.builder.Condition;
import com.raizlabs.android.dbflow.sql.language.Select;
import com.raizlabs.android.dbflow.structure.BaseModel;
+import android.text.TextUtils;
+
import java.util.ArrayList;
+import java.util.EnumMap;
import java.util.List;
+import java.util.Map;
@Table(databaseName = HabitDatabase.NAME)
public class HabitRPGUser extends BaseModel {
@@ -365,4 +370,69 @@ public class HabitRPGUser extends BaseModel {
return layerNames;
}
+
+ public EnumMap getAvatarLayerMap() {
+ EnumMap layerMap = new EnumMap<>(AvatarView.LayerType.class);
+
+ Preferences prefs = getPreferences();
+ Outfit outfit = (prefs.getCostume()) ? getItems().getGear().getCostume() : getItems().getGear().getEquipped();
+
+ if (outfit != null) {
+ if (!TextUtils.isEmpty(outfit.getBack())) {
+ layerMap.put(AvatarView.LayerType.BACK, outfit.getBack());
+ }
+ if (outfit.isAvailable(outfit.getArmor())) {
+ layerMap.put(AvatarView.LayerType.ARMOR, prefs.getSize() + "_" + outfit.getArmor());
+ }
+ if (outfit.isAvailable(outfit.getBody())) {
+ layerMap.put(AvatarView.LayerType.BODY, outfit.getBody());
+ }
+ if (outfit.isAvailable(outfit.getEyeWear())) {
+ layerMap.put(AvatarView.LayerType.EYEWEAR, outfit.getEyeWear());
+ }
+ if (outfit.isAvailable(outfit.getHead())) {
+ layerMap.put(AvatarView.LayerType.HEAD, outfit.getHead());
+ }
+ if (outfit.isAvailable(outfit.getHeadAccessory())) {
+ layerMap.put(AvatarView.LayerType.HEAD_ACCESSORY, outfit.getHeadAccessory());
+ }
+ if (outfit.isAvailable(outfit.getShield())) {
+ layerMap.put(AvatarView.LayerType.SHIELD, outfit.getShield());
+ }
+ if (outfit.isAvailable(outfit.getWeapon())) {
+ layerMap.put(AvatarView.LayerType.WEAPON, outfit.getWeapon());
+ }
+ }
+
+ layerMap.put(AvatarView.LayerType.SKIN, "skin_" + prefs.getSkin() + ((prefs.getSleep()) ? "_sleep" : ""));
+ layerMap.put(AvatarView.LayerType.SHIRT, prefs.getSize() + "_shirt_" + prefs.getShirt());
+ layerMap.put(AvatarView.LayerType.HEAD_0, "head_0");
+
+ Hair hair = prefs.getHair();
+ if (hair != null) {
+ String hairColor = hair.getColor();
+
+ if (hair.isAvailable(hair.getBase())) {
+ layerMap.put(AvatarView.LayerType.HAIR_BASE, "hair_base_" + hair.getBase() + "_" + hairColor);
+ }
+ if (hair.isAvailable(hair.getBangs())) {
+ layerMap.put(AvatarView.LayerType.HAIR_BANGS, "hair_bangs_" + hair.getBangs() + "_" + hairColor);
+ }
+ if (hair.isAvailable(hair.getMustache())) {
+ layerMap.put(AvatarView.LayerType.HAIR_MUSTACHE, "hair_mustache_" + hair.getMustache() + "_" + hairColor);
+ }
+ if (hair.isAvailable(hair.getBeard())) {
+ layerMap.put(AvatarView.LayerType.HAIR_BEARD, "hair_beard_" + hair.getBeard() + "_" + hairColor);
+ }
+ if (hair.isAvailable(hair.getFlower())) {
+ layerMap.put(AvatarView.LayerType.HAIR_FLOWER, "hair_flower_" + hair.getFlower());
+ }
+ }
+
+ if (prefs.getSleep()) {
+ layerMap.put(AvatarView.LayerType.ZZZ, "zzz");
+ }
+
+ return layerMap;
+ }
}
diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java
index 6829241d5..69dc72e6a 100644
--- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java
+++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java
@@ -75,4 +75,8 @@ public class Hair extends BaseModel {
public int getFlower() { return flower; }
public void setFlower(int flower) { this.flower = flower; }
+
+ public boolean isAvailable(int hairId) {
+ return hairId > 0;
+ }
}
diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java
index ff92c63e7..ba899a95c 100644
--- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java
+++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java
@@ -9,6 +9,8 @@ import com.raizlabs.android.dbflow.annotation.PrimaryKey;
import com.raizlabs.android.dbflow.annotation.Table;
import com.raizlabs.android.dbflow.structure.BaseModel;
+import android.text.TextUtils;
+
/**
* Created by viirus on 20/07/15.
*/
@@ -54,4 +56,7 @@ public class Outfit extends BaseModel {
public String getWeapon() {return weapon;}
public void setWeapon(String weapon) {this.weapon = weapon;}
+ public boolean isAvailable(String outfit) {
+ return !TextUtils.isEmpty(outfit) && !outfit.endsWith("base_0");
+ }
}