blob: e2765439f957edda35611aae4aaa6c60ed9e455d [file] [log] [blame]
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.deskclock.timer;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.support.annotation.PluralsRes;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityManager;
import com.android.deskclock.LogUtils;
import com.android.deskclock.R;
import com.android.deskclock.Utils;
import com.android.deskclock.uidata.UiDataModel;
import java.util.Locale;
/**
* Class to measure and draw the time in the {@link com.android.deskclock.CircleTimerView}.
* This class manages and sums the work of the four members mBigHours, mBigMinutes,
* mBigSeconds and mMedHundredths. Those members are each tasked with measuring, sizing and
* drawing digits (and optional label) of the time set in {@link #setTime(long, boolean)}
*/
public class CountingTimerView extends View {
private static final float TEXT_SIZE_TO_WIDTH_RATIO = 0.85f;
// This is the ratio of the font height needed to vertically offset the font for alignment
// from the center.
private static final float FONT_VERTICAL_OFFSET = 0.14f;
// Ratio of the space trailing the Hours and Minutes
private static final float HOURS_MINUTES_SPACING = 0.4f;
// Ratio of the space leading the Hundredths
private static final float HUNDREDTHS_SPACING = 0.5f;
/** Reusable StringBuilder to assemble talk back announcements when the time is updated. */
private static final StringBuilder sTalkBackBuilder = new StringBuilder(50);
// Radial offset of the enclosing circle
private final float mRadiusOffset;
private String mHours, mMinutes, mSeconds, mHundredths;
private boolean mShowTimeStr = true;
private final Paint mPaintBigThin = new Paint();
private final Paint mPaintMed = new Paint();
private final float mBigFontSize, mSmallFontSize;
// Hours and minutes are signed for when a timer goes past the set time and thus negative
private final SignedTime mBigHours, mBigMinutes;
// Seconds are always shown with minutes, so are never signed
private final UnsignedTime mBigSeconds;
private final Hundredths mMedHundredths;
private float mTextHeight = 0;
private float mTotalTextWidth;
private boolean mRemeasureText = true;
private int mDefaultColor;
private final int mPressedColor;
private final int mWhiteColor;
private final int mAccentColor;
private final AccessibilityManager mAccessibilityManager;
// Fields for the text serving as a virtual button.
private boolean mVirtualButtonEnabled = false;
private boolean mVirtualButtonPressedOn = false;
// Whether or not a bounding circle exists into which the text must be made to fit.
// If no such circle exists, the entire width of this component is available for text display.
private boolean mShowBoundingCircle;
Runnable mBlinkThread = new Runnable() {
private boolean mVisible = true;
@Override
public void run() {
mVisible = !mVisible;
CountingTimerView.this.showTime(mVisible);
postDelayed(mBlinkThread, 500);
}
};
/**
* Class to measure and draw the digit pairs of hours, minutes, seconds or hundredths. Digits
* may have an optional label. for hours, minutes and seconds, this label trails the digits
* and for seconds, precedes the digits.
*/
static class UnsignedTime {
protected Paint mPaint;
protected float mEm;
protected float mWidth = 0;
private final String mWidest;
protected final float mSpacingRatio;
private float mLabelWidth = 0;
public UnsignedTime(Paint paint, float spacingRatio, String allDigits) {
mPaint = paint;
mSpacingRatio = spacingRatio;
if (TextUtils.isEmpty(allDigits)) {
LogUtils.wtf("Locale digits missing - using English");
allDigits = "0123456789";
}
float widths[] = new float[allDigits.length()];
int ll = mPaint.getTextWidths(allDigits, widths);
int largest = 0;
for (int ii = 1; ii < ll; ii++) {
if (widths[ii] > widths[largest]) {
largest = ii;
}
}
mEm = widths[largest];
mWidest = allDigits.substring(largest, largest + 1);
}
public UnsignedTime(UnsignedTime unsignedTime, float spacingRatio) {
this.mPaint = unsignedTime.mPaint;
this.mEm = unsignedTime.mEm;
this.mWidth = unsignedTime.mWidth;
this.mWidest = unsignedTime.mWidest;
this.mSpacingRatio = spacingRatio;
}
protected void updateWidth(final String time) {
mEm = mPaint.measureText(mWidest);
mLabelWidth = mSpacingRatio * mEm;
mWidth = time.length() * mEm;
}
protected void resetWidth() {
mWidth = mLabelWidth = 0;
}
public float calcTotalWidth(final String time) {
if (time != null) {
updateWidth(time);
return mWidth + mLabelWidth;
} else {
resetWidth();
return 0;
}
}
public float getLabelWidth() {
return mLabelWidth;
}
/**
* Draws each character with a fixed spacing from time starting at ii.
* @param canvas the canvas on which the time segment will be drawn
* @param time time segment
* @param ii what character to start the draw
* @param x offset
* @param y offset
* @return X location for the next segment
*/
protected float drawTime(Canvas canvas, final String time, int ii, float x, float y) {
float textEm = mEm / 2f;
while (ii < time.length()) {
x += textEm;
canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint);
x += textEm;
ii++;
}
return x;
}
/**
* Draw this time segment and append the intra-segment spacing to the x
* @param canvas the canvas on which the time segment will be drawn
* @param time time segment
* @param x offset
* @param y offset
* @return X location for the next segment
*/
public float draw(Canvas canvas, final String time, float x, float y) {
return drawTime(canvas, time, 0, x, y) + getLabelWidth();
}
}
/**
* Special derivation to handle the hundredths painting with the label in front.
*/
static class Hundredths extends UnsignedTime {
public Hundredths(Paint paint, float spacingRatio, final String allDigits) {
super(paint, spacingRatio, allDigits);
}
/**
* Draw this time segment after prepending the intra-segment spacing to the x location.
* {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
*/
@Override
public float draw(Canvas canvas, final String time, float x, float y) {
return drawTime(canvas, time, 0, x + getLabelWidth(), y);
}
}
/**
* Special derivation to handle a negative number
*/
static class SignedTime extends UnsignedTime {
private float mMinusWidth = 0;
public SignedTime (UnsignedTime unsignedTime, float spacingRatio) {
super(unsignedTime, spacingRatio);
}
@Override
protected void updateWidth(final String time) {
super.updateWidth(time);
if (time.contains("-")) {
mMinusWidth = mPaint.measureText("-");
mWidth += (mMinusWidth - mEm);
} else {
mMinusWidth = 0;
}
}
@Override
protected void resetWidth() {
super.resetWidth();
mMinusWidth = 0;
}
/**
* Draws each character with a fixed spacing from time, handling the special negative
* number case.
* {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
*/
@Override
public float draw(Canvas canvas, final String time, float x, float y) {
int ii = 0;
if (mMinusWidth != 0f) {
float minusWidth = mMinusWidth / 2;
x += minusWidth;
//TODO:hyphen is too thick when painted
canvas.drawText(time.substring(0, 1), x, y, mPaint);
x += minusWidth;
ii++;
}
return drawTime(canvas, time, ii, x, y) + getLabelWidth();
}
}
@SuppressWarnings("unused")
public CountingTimerView(Context context) {
this(context, null);
}
public CountingTimerView(Context context, AttributeSet attrs) {
super(context, attrs);
mAccessibilityManager =
(AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
Resources r = context.getResources();
mDefaultColor = mWhiteColor = Color.WHITE;
mPressedColor = mAccentColor = Utils.obtainStyledColor(
context, R.attr.colorAccent, Color.RED);
mBigFontSize = r.getDimension(R.dimen.big_font_size);
mSmallFontSize = r.getDimension(R.dimen.small_font_size);
mPaintBigThin.setAntiAlias(true);
mPaintBigThin.setStyle(Paint.Style.STROKE);
mPaintBigThin.setTextAlign(Paint.Align.CENTER);
mPaintBigThin.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL));
mPaintMed.setAntiAlias(true);
mPaintMed.setStyle(Paint.Style.STROKE);
mPaintMed.setTextAlign(Paint.Align.CENTER);
mPaintMed.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));
resetTextSize();
setTextColor(mDefaultColor);
// allDigits will contain ten digits: "0123456789" in the default locale
final String allDigits = String.format(Locale.getDefault(), "%010d", 123456789);
mBigSeconds = new UnsignedTime(mPaintBigThin, 0.f, allDigits);
mBigHours = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
mBigMinutes = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
mMedHundredths = new Hundredths(mPaintMed, HUNDREDTHS_SPACING, allDigits);
mRadiusOffset = Utils.calculateRadiusOffset(r);
}
protected void resetTextSize() {
mTextHeight = mBigFontSize;
mPaintBigThin.setTextSize(mBigFontSize);
mPaintMed.setTextSize(mSmallFontSize);
}
protected void setTextColor(int textColor) {
mPaintBigThin.setColor(textColor);
mPaintMed.setColor(textColor);
}
public void setShowBoundingCircle(boolean showBoundingCircle) {
mShowBoundingCircle = showBoundingCircle;
requestLayout();
}
/**
* Update the time to display. Separates that time into the hours, minutes, seconds and
* hundredths. If update is true, the view is invalidated so that it will draw again.
*
* @param time new time to display - in milliseconds
* @param showHundredths flag to show hundredths resolution
*/
// TODO:showHundredths S/B attribute or setter - i.e. unchanging over object life
public void setTime(long time, boolean showHundredths) {
final int oldLength = getDigitsLength();
boolean neg = false, showNeg = false;
if (time < 0) {
time = -time;
neg = showNeg = true;
}
int hours = (int) (time / DateUtils.HOUR_IN_MILLIS);
int remainder = (int) (time % DateUtils.HOUR_IN_MILLIS);
int minutes = (int) (remainder / DateUtils.MINUTE_IN_MILLIS);
remainder = (int) (remainder % DateUtils.MINUTE_IN_MILLIS);
int seconds = (int) (remainder / DateUtils.SECOND_IN_MILLIS);
remainder = (int) (remainder % DateUtils.SECOND_IN_MILLIS);
int hundredths = remainder / 10;
if (hours > 999) {
hours = 0;
}
// The time can be between 0 and -1 seconds, but the "truncated" equivalent time of hours
// and minutes and seconds could be zero, so since we do not show fractions of seconds
// when counting down, do not show the minus sign.
// TODO:does it matter that we do not look at showHundredths?
if (hours == 0 && minutes == 0 && seconds == 0) {
showNeg = false;
}
// If not showing hundredths, round up to the next second.
if (!showHundredths) {
if (!neg && hundredths != 0) {
seconds++;
if (seconds == 60) {
seconds = 0;
minutes++;
if (minutes == 60) {
minutes = 0;
hours++;
}
}
}
}
// Hours may be empty.
final UiDataModel uiDataModel = UiDataModel.getUiDataModel();
if (hours > 0) {
final int hoursLength = hours >= 10 ? 2 : 1;
mHours = uiDataModel.getFormattedNumber(showNeg, hours, hoursLength);
} else {
mHours = null;
}
// Minutes are never empty and forced to two digits when hours exist.
final boolean showNegMinutes = showNeg && hours == 0;
final int minutesLength = minutes >= 10 || hours > 0 ? 2 : 1;
mMinutes = uiDataModel.getFormattedNumber(showNegMinutes, minutes, minutesLength);
// Seconds are always two digits
mSeconds = uiDataModel.getFormattedNumber(seconds, 2);
// Hundredths are optional but forced to two digits when displayed.
if (showHundredths) {
mHundredths = uiDataModel.getFormattedNumber(hundredths, 2);
} else {
mHundredths = null;
}
int newLength = getDigitsLength();
if (oldLength != newLength) {
if (oldLength > newLength) {
resetTextSize();
}
mRemeasureText = true;
}
setContentDescription(getTimeStringForAccessibility(hours, minutes, seconds, showNeg,
getResources()));
postInvalidateOnAnimation();
}
private int getDigitsLength() {
return ((mHours == null) ? 0 : mHours.length())
+ ((mMinutes == null) ? 0 : mMinutes.length())
+ ((mSeconds == null) ? 0 : mSeconds.length())
+ ((mHundredths == null) ? 0 : mHundredths.length());
}
private void calcTotalTextWidth() {
mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes)
+ mBigSeconds.calcTotalWidth(mSeconds)
+ mMedHundredths.calcTotalWidth(mHundredths);
}
/**
* Adjust the size of the fonts to fit within the the circle and painted object in
* {@link com.android.deskclock.CircleTimerView#onDraw(android.graphics.Canvas)}
*/
private void setTotalTextWidth() {
calcTotalTextWidth();
int width;
if (mShowBoundingCircle) {
// A bounding circle exists, so the available width in which to fit the timer text is
// the smaller of the width or height, which is also equal to the circle's diameter.
width = Math.min(getWidth(), getHeight());
} else {
// A bounding circle does not exist, so pretend that the entire width of this component
// is the diameter of a theoretical bounding circle.
width = getWidth();
}
if (width != 0) {
// Shrink 'width' to account for circle stroke and other painted objects.
// Note on the "4 *": (1) To reduce divisions, using the diameter instead of the radius.
// (2) The radius of the enclosing circle is reduced by mRadiusOffset and the
// text needs to fit within a circle further reduced by mRadiusOffset.
width -= (int) (4 * mRadiusOffset + 0.5f);
final float wantDiameter2 = TEXT_SIZE_TO_WIDTH_RATIO * width * width;
float totalDiameter2 = getHypotenuseSquared();
// If the hypotenuse of the bounding box is too large, reduce all the paint text sizes
while (totalDiameter2 > wantDiameter2) {
// Convergence is slightly difficult due to quantization in the mTotalTextWidth
// calculation. Reducing the ratio by 1% converges more quickly without excessive
// loss of quality.
float sizeRatio = 0.99f * (float) Math.sqrt(wantDiameter2/totalDiameter2);
mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio);
mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio);
// Recalculate the new total text height and half-width
mTextHeight = mPaintBigThin.getTextSize();
calcTotalTextWidth();
totalDiameter2 = getHypotenuseSquared();
}
}
}
/**
* Calculate the square of the diameter to use in {@link CountingTimerView#setTotalTextWidth()}
*/
private float getHypotenuseSquared() {
return mTotalTextWidth * mTotalTextWidth + mTextHeight * mTextHeight;
}
public void blinkTimeStr(boolean blink) {
if (blink) {
removeCallbacks(mBlinkThread);
post(mBlinkThread);
} else {
removeCallbacks(mBlinkThread);
showTime(true);
}
}
public void showTime(boolean visible) {
mShowTimeStr = visible;
invalidate();
}
public void setTimeStrTextColor(boolean active, boolean forceUpdate) {
mDefaultColor = active ? mAccentColor : mWhiteColor;
setTextColor(mDefaultColor);
if (forceUpdate) {
invalidate();
}
}
private static String getTimeStringForAccessibility(int hours, int minutes, int seconds,
boolean showNeg, Resources r) {
sTalkBackBuilder.setLength(0);
if (showNeg) {
// This must be followed by a non-zero number or it will be audible as "hyphen"
// instead of "minus".
sTalkBackBuilder.append('-');
}
if (showNeg && hours == 0 && minutes == 0) {
// Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative
// time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds"
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
} else if (hours == 0) {
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nminutes_description, minutes));
sTalkBackBuilder.append(' ');
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
} else {
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nhours_description, hours));
sTalkBackBuilder.append(' ');
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nminutes_description, minutes));
sTalkBackBuilder.append(' ');
sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
}
return sTalkBackBuilder.toString();
}
private static String getQuantityString(Resources r, @PluralsRes int resId, int quantity) {
return r.getQuantityString(resId, quantity, quantity);
}
public void setVirtualButtonEnabled(boolean enabled) {
mVirtualButtonEnabled = enabled;
}
private void virtualButtonPressed(boolean pressedOn) {
mVirtualButtonPressedOn = pressedOn;
invalidate();
}
private boolean withinVirtualButtonBounds(float x, float y) {
int width = getWidth();
int height = getHeight();
float centerX = width / 2;
float centerY = height / 2;
float radius = Math.min(width, height) / 2;
// Within the circle button if distance to the center is less than the radius.
double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
return distance < radius;
}
public void registerVirtualButtonAction(final Runnable runnable) {
if (!mAccessibilityManager.isEnabled()) {
this.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (mVirtualButtonEnabled) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (withinVirtualButtonBounds(event.getX(), event.getY())) {
virtualButtonPressed(true);
return true;
} else {
virtualButtonPressed(false);
return false;
}
case MotionEvent.ACTION_CANCEL:
virtualButtonPressed(false);
return true;
case MotionEvent.ACTION_OUTSIDE:
virtualButtonPressed(false);
return false;
case MotionEvent.ACTION_UP:
virtualButtonPressed(false);
if (withinVirtualButtonBounds(event.getX(), event.getY())) {
runnable.run();
}
return true;
}
}
return false;
}
});
} else {
this.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
runnable.run();
}
});
}
}
@Override
public void onDraw(Canvas canvas) {
// Blink functionality.
if (!mShowTimeStr && !mVirtualButtonPressedOn) {
return;
}
int width = getWidth();
if (mRemeasureText && width != 0) {
setTotalTextWidth();
width = getWidth();
mRemeasureText = false;
}
int xCenter = width / 2;
int yCenter = getHeight() / 2;
float xTextStart = xCenter - mTotalTextWidth / 2;
float yTextStart = yCenter + mTextHeight/2 - (mTextHeight * FONT_VERTICAL_OFFSET);
// Text color differs based on pressed state.
final int textColor = mVirtualButtonPressedOn ? mPressedColor : mDefaultColor;
mPaintBigThin.setColor(textColor);
mPaintMed.setColor(textColor);
if (mHours != null) {
xTextStart = mBigHours.draw(canvas, mHours, xTextStart, yTextStart);
}
if (mMinutes != null) {
xTextStart = mBigMinutes.draw(canvas, mMinutes, xTextStart, yTextStart);
}
if (mSeconds != null) {
xTextStart = mBigSeconds.draw(canvas, mSeconds, xTextStart, yTextStart);
}
if (mHundredths != null) {
mMedHundredths.draw(canvas, mHundredths, xTextStart, yTextStart);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mRemeasureText = true;
resetTextSize();
}
}