package net.maxbraun.mirror; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.FontMetrics; import android.graphics.Path; import android.graphics.Typeface; import android.text.format.DateFormat; import android.util.AttributeSet; import android.util.Log; import android.view.View; import net.maxbraun.mirror.Body.BodyMeasure; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.concurrent.TimeUnit; /** * A {@link View} charting the series of body measures defined by * {@link #setBodyMeasures(BodyMeasure[])}. */ public class BodyView extends View { private static final String TAG = BodyView.class.getSimpleName(); /** * The conversion factor from kilograms to pounds. */ private static final double KG_TO_LBS = 2.20462; /** * The {@link DateFormat} used with the 12 hour clock setting. */ private static final SimpleDateFormat DATE_FORMAT_12H = new SimpleDateFormat("MMMM d"); /** * The {@link DateFormat} used with the 24 hour clock setting. */ private static final SimpleDateFormat DATE_FORMAT_24H = new SimpleDateFormat("d MMMM"); /** * The {@link Paint} used to draw white dots. */ private final Paint whiteDotPaint; /** * The {@link Paint} used to draw red dots. */ private final Paint redDotPaint; /** * The {@link Paint} used to draw green dots. */ private final Paint greenDotPaint; /** * The {@link Paint} used to draw the line. */ private final Paint linePaint; /** * The {@link Paint} used to draw the labels. */ private final Paint labelPaint; /** * The {@link Path} used to daw the line, which we reuse across {@link #onDraw(Canvas)} calls. */ private final Path linePath = new Path(); private final float dotRadiusPixels; private final float labelMarginPixels; private BodyMeasure[] bodyMeasures; /** * The minimum timestamp found in {@link #bodyMeasures}. */ private long minTimestamp; /** * The maximum timestamp found in {@link #bodyMeasures}. */ private long maxTimestamp; /** * The minimum weight found in {@link #bodyMeasures}. */ private double minWeight; /** * The maximum weight found in {@link #bodyMeasures}. */ private double maxWeight; /** * The weight with the maximum timestamp found in {@link #bodyMeasures}. */ private double maxTimestampWeight; public BodyView(Context context) { this(context, null); } public BodyView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BodyView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public BodyView(final Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); final Resources resources = context.getResources(); dotRadiusPixels = getResources().getDimension(R.dimen.body_dot_radius); whiteDotPaint = new Paint() {{ setColor(Color.WHITE); setAntiAlias(true); setStyle(Style.FILL); }}; redDotPaint = new Paint() {{ setColor(resources.getColor(R.color.red)); setAntiAlias(true); setStyle(Style.FILL); }}; greenDotPaint = new Paint() {{ setColor(resources.getColor(R.color.green)); setAntiAlias(true); setStyle(Style.FILL); }}; final float lineWidthPixels = getResources().getDimension(R.dimen.body_line_width); linePaint = new Paint() {{ setColor(Color.WHITE); setAntiAlias(true); setStyle(Style.STROKE); setStrokeWidth(lineWidthPixels); setStrokeCap(Cap.ROUND); setStrokeJoin(Join.ROUND); }}; final float textSize = getResources().getDimension(R.dimen.small_text_size); labelPaint = new Paint() {{ setColor(Color.WHITE); setAntiAlias(true); setTextSize(textSize); setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL)); }}; labelMarginPixels = getResources().getDimension(R.dimen.body_label_margin); } /** * Updates the chart with the specified list of {@link BodyMeasure BodyMeasures}. */ public void setBodyMeasures(BodyMeasure[] bodyMeasures) { this.bodyMeasures = bodyMeasures; Log.d(TAG, String.format("Showing %d body measures.", (bodyMeasures != null) ? bodyMeasures.length : 0)); // Calculate the minimum and maximum timestamps and weights. if ((bodyMeasures != null) && (bodyMeasures.length > 0)) { BodyMeasure firstBodyMeasure = bodyMeasures[0]; minTimestamp = firstBodyMeasure.timestamp; maxTimestamp = minTimestamp; minWeight = firstBodyMeasure.weight; maxWeight = minWeight; maxTimestampWeight = minWeight; for (int i = 1; i < bodyMeasures.length; i++) { BodyMeasure bodyMeasure = bodyMeasures[i]; long timestamp = bodyMeasure.timestamp; double weight = bodyMeasure.weight; if (timestamp < minTimestamp) { minTimestamp = timestamp; } if (timestamp > maxTimestamp) { maxTimestamp = timestamp; maxTimestampWeight = weight; } if (weight < minWeight) { minWeight = weight; } if (weight > maxWeight) { maxWeight = weight; } } } else { minTimestamp = 0; maxTimestamp = 0; minWeight = 0.0; maxWeight = 0.0; maxTimestampWeight = 0.0; } // Trigger a redraw. invalidate(); } @Override protected void onDraw(Canvas canvas) { // Clear the canvas. canvas.drawColor(Color.TRANSPARENT); if ((bodyMeasures == null) || (bodyMeasures.length < 2)) { Log.w(TAG, "Not enough body measures."); return; } // Calculate the size of the label for the weight with the maximum timestamp now, because it // influences the right margin. String maxTimestampWeightLabel; float maxTimestampWeightLabelWidth; if ((maxTimestampWeight != minWeight) && (maxTimestampWeight != maxWeight)) { maxTimestampWeightLabel = String.format(Locale.US, "%.0f %s", getLocalizedWeight(maxTimestampWeight), getLocalizedWeightUnit()); maxTimestampWeightLabelWidth = labelPaint.measureText(maxTimestampWeightLabel); } else { maxTimestampWeightLabel = null; maxTimestampWeightLabelWidth = 0.0f; } // Calculate the margins, which leave room for the dots, the labels, and additional margins. FontMetrics fontMetrics = labelPaint.getFontMetrics(); float labelHeight = fontMetrics.descent - fontMetrics.ascent; float leftMargin = dotRadiusPixels; float topMargin = dotRadiusPixels + labelHeight + labelMarginPixels; float rightMargin = dotRadiusPixels + maxTimestampWeightLabelWidth + labelMarginPixels; float bottomMargin = dotRadiusPixels + labelHeight + labelMarginPixels; // Iterate over all measures to calculate the chart data. float maxWeightDotX = 0; float maxWeightDotY = 0; String maxWeightLabel = null; float maxWeightLabelX = 0; float maxWeightLabelY = 0; float minWeightDotX = 0; float minWeightDotY = 0; String minWeightLabel = null; float minWeightLabelX = 0; float minWeightLabelY = 0; float maxTimestampX = 0; float maxTimestampY = 0; linePath.rewind(); for (int i = 0; i < bodyMeasures.length; i++) { BodyMeasure bodyMeasure = bodyMeasures[i]; long timestamp = bodyMeasure.timestamp; double weight = bodyMeasure.weight; // Project the data point onto the available canvas. float x = project(timestamp, minTimestamp, maxTimestamp, leftMargin, canvas.getWidth() - rightMargin); float y = project((float) weight, (float) minWeight, (float) maxWeight, canvas.getHeight() - bottomMargin, topMargin); // Create a label with the weight and date, positioned as close to the data point as possible. String weightLabel = String.format(Locale.US, "%.0f %s ยท %s", getLocalizedWeight(weight), getLocalizedWeightUnit(), getLocalizedDate(timestamp)); float weightLabelWidth = labelPaint.measureText(weightLabel); float weightLabelX = Math.min(Math.max(x - 0.5f * weightLabelWidth, 0.0f), canvas.getWidth() - weightLabelWidth); // Save the dot coordinates and the label for the maximum and minimum weights, but only once. // The weight with the maximum timestamp also gets a dot. if ((weight == maxWeight) && (maxWeightLabel == null)) { maxWeightDotX = x; maxWeightDotY = y; maxWeightLabelX = weightLabelX; maxWeightLabelY = labelHeight - fontMetrics.descent; maxWeightLabel = weightLabel; } else if ((weight == minWeight) && (minWeightLabel == null)) { minWeightDotX = x; minWeightDotY = y; minWeightLabelX = weightLabelX; minWeightLabelY = canvas.getHeight() - fontMetrics.descent; minWeightLabel = weightLabel; } else if (timestamp == maxTimestamp) { maxTimestampX = x; maxTimestampY = y; } // Append to the line. if (linePath.isEmpty()) { linePath.moveTo(x, y); } else { linePath.lineTo(x, y); } } // Draw the line. canvas.drawPath(linePath, linePaint); // Draw dots and labels for the maximum and minimum weights. if (maxWeightLabel != null) { canvas.drawCircle(maxWeightDotX, maxWeightDotY, dotRadiusPixels, redDotPaint); canvas.drawText(maxWeightLabel, maxWeightLabelX, maxWeightLabelY, labelPaint); } if (minWeightLabel != null) { canvas.drawCircle(minWeightDotX, minWeightDotY, dotRadiusPixels, greenDotPaint); canvas.drawText(minWeightLabel, minWeightLabelX, minWeightLabelY, labelPaint); } // Draw a dot and a label for the weight at the maximum timestamp, unless it is identical to the // minimum or maximum weight and shouldn't get a label. if (maxTimestampWeightLabel != null) { canvas.drawCircle(maxTimestampX, maxTimestampY, dotRadiusPixels, whiteDotPaint); float maxTimestampWeightLabelX = canvas.getWidth() - maxTimestampWeightLabelWidth; float maxTimestampWeightLabelY = project((float) maxTimestampWeight, (float) minWeight, (float) maxWeight, canvas.getHeight() - bottomMargin, topMargin) + 0.5f * labelHeight - fontMetrics.descent; canvas.drawText(maxTimestampWeightLabel, maxTimestampWeightLabelX, maxTimestampWeightLabelY, labelPaint); } } /** * Projects a value linearly from one range to another. */ private static float project(float value, float minFrom, float maxFrom, float minTo, float maxTo) { return (value - minFrom) / (maxFrom - minFrom) * (maxTo - minTo) + minTo; } /** * Picks an abbreviated weight unit, depending on the {@link Locale}. */ private String getLocalizedWeightUnit() { // First approximation: pounds for US and kilograms anywhere else. return Locale.US.equals(Locale.getDefault()) ? "lb" : "kg"; } /** * Converts a weight in kilograms to pounds if necessary, depending on the {@link Locale}. */ private double getLocalizedWeight(double weightKg) { // First approximation: pounds for US and kilograms anywhere else. return Locale.US.equals(Locale.getDefault()) ? KG_TO_LBS * weightKg : weightKg; } /** * Turns a Unix epoch timestamp in seconds into a month and day format depending on the 24 hour * setting (same logic and formats as the clock). */ private String getLocalizedDate(long timestamp) { SimpleDateFormat dateFormat = DateFormat.is24HourFormat(getContext()) ? DATE_FORMAT_24H : DATE_FORMAT_12H; return dateFormat.format(new Date(TimeUnit.SECONDS.toMillis(timestamp))); } }