1 /* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.deskclock.timer; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.graphics.Canvas; 22 import android.graphics.Color; 23 import android.graphics.Paint; 24 import android.graphics.Typeface; 25 import android.text.TextUtils; 26 import android.util.AttributeSet; 27 import android.view.MotionEvent; 28 import android.view.View; 29 import android.view.accessibility.AccessibilityManager; 30 31 import com.android.deskclock.LogUtils; 32 import com.android.deskclock.R; 33 import com.android.deskclock.Utils; 34 35 /** 36 * Class to measure and draw the time in the {@link com.android.deskclock.CircleTimerView}. 37 * This class manages and sums the work of the four members mBigHours, mBigMinutes, 38 * mBigSeconds and mMedHundredths. Those members are each tasked with measuring, sizing and 39 * drawing digits (and optional label) of the time set in {@link #setTime(long, boolean, boolean)} 40 */ 41 public class CountingTimerView extends View { 42 private static final String TWO_DIGITS = "%02d"; 43 private static final String ONE_DIGIT = "%01d"; 44 private static final String NEG_TWO_DIGITS = "-%02d"; 45 private static final String NEG_ONE_DIGIT = "-%01d"; 46 private static final float TEXT_SIZE_TO_WIDTH_RATIO = 0.85f; 47 // This is the ratio of the font height needed to vertically offset the font for alignment 48 // from the center. 49 private static final float FONT_VERTICAL_OFFSET = 0.14f; 50 // Ratio of the space trailing the Hours and Minutes 51 private static final float HOURS_MINUTES_SPACING = 0.4f; 52 // Ratio of the space leading the Hundredths 53 private static final float HUNDREDTHS_SPACING = 0.5f; 54 // Radial offset of the enclosing circle 55 private final float mRadiusOffset; 56 57 private String mHours, mMinutes, mSeconds, mHundredths; 58 59 private boolean mShowTimeStr = true; 60 private final Paint mPaintBigThin = new Paint(); 61 private final Paint mPaintMed = new Paint(); 62 private final float mBigFontSize, mSmallFontSize; 63 // Hours and minutes are signed for when a timer goes past the set time and thus negative 64 private final SignedTime mBigHours, mBigMinutes; 65 // Seconds are always shown with minutes, so are never signed 66 private final UnsignedTime mBigSeconds; 67 private final Hundredths mMedHundredths; 68 private float mTextHeight = 0; 69 private float mTotalTextWidth; 70 private boolean mRemeasureText = true; 71 72 private int mDefaultColor; 73 private final int mPressedColor; 74 private final int mWhiteColor; 75 private final int mAccentColor; 76 private final AccessibilityManager mAccessibilityManager; 77 78 // Fields for the text serving as a virtual button. 79 private boolean mVirtualButtonEnabled = false; 80 private boolean mVirtualButtonPressedOn = false; 81 82 Runnable mBlinkThread = new Runnable() { 83 private boolean mVisible = true; 84 @Override 85 public void run() { 86 mVisible = !mVisible; 87 CountingTimerView.this.showTime(mVisible); 88 postDelayed(mBlinkThread, 500); 89 } 90 }; 91 92 /** 93 * Class to measure and draw the digit pairs of hours, minutes, seconds or hundredths. Digits 94 * may have an optional label. for hours, minutes and seconds, this label trails the digits 95 * and for seconds, precedes the digits. 96 */ 97 static class UnsignedTime { 98 protected Paint mPaint; 99 protected float mEm; 100 protected float mWidth = 0; 101 private final String mWidest; 102 protected final float mSpacingRatio; 103 private float mLabelWidth = 0; 104 105 public UnsignedTime(Paint paint, float spacingRatio, String allDigits) { 106 mPaint = paint; 107 mSpacingRatio = spacingRatio; 108 109 if (TextUtils.isEmpty(allDigits)) { 110 LogUtils.wtf("Locale digits missing - using English"); 111 allDigits = "0123456789"; 112 } 113 114 float widths[] = new float[allDigits.length()]; 115 int ll = mPaint.getTextWidths(allDigits, widths); 116 int largest = 0; 117 for (int ii = 1; ii < ll; ii++) { 118 if (widths[ii] > widths[largest]) { 119 largest = ii; 120 } 121 } 122 123 mEm = widths[largest]; 124 mWidest = allDigits.substring(largest, largest + 1); 125 } 126 127 public UnsignedTime(UnsignedTime unsignedTime, float spacingRatio) { 128 this.mPaint = unsignedTime.mPaint; 129 this.mEm = unsignedTime.mEm; 130 this.mWidth = unsignedTime.mWidth; 131 this.mWidest = unsignedTime.mWidest; 132 this.mSpacingRatio = spacingRatio; 133 } 134 135 protected void updateWidth(final String time) { 136 mEm = mPaint.measureText(mWidest); 137 mLabelWidth = mSpacingRatio * mEm; 138 mWidth = time.length() * mEm; 139 } 140 141 protected void resetWidth() { 142 mWidth = mLabelWidth = 0; 143 } 144 145 public float calcTotalWidth(final String time) { 146 if (time != null) { 147 updateWidth(time); 148 return mWidth + mLabelWidth; 149 } else { 150 resetWidth(); 151 return 0; 152 } 153 } 154 155 public float getLabelWidth() { 156 return mLabelWidth; 157 } 158 159 /** 160 * Draws each character with a fixed spacing from time starting at ii. 161 * @param canvas the canvas on which the time segment will be drawn 162 * @param time time segment 163 * @param ii what character to start the draw 164 * @param x offset 165 * @param y offset 166 * @return X location for the next segment 167 */ 168 protected float drawTime(Canvas canvas, final String time, int ii, float x, float y) { 169 float textEm = mEm / 2f; 170 while (ii < time.length()) { 171 x += textEm; 172 canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint); 173 x += textEm; 174 ii++; 175 } 176 return x; 177 } 178 179 /** 180 * Draw this time segment and append the intra-segment spacing to the x 181 * @param canvas the canvas on which the time segment will be drawn 182 * @param time time segment 183 * @param x offset 184 * @param y offset 185 * @return X location for the next segment 186 */ 187 public float draw(Canvas canvas, final String time, float x, float y) { 188 return drawTime(canvas, time, 0, x, y) + getLabelWidth(); 189 } 190 } 191 192 /** 193 * Special derivation to handle the hundredths painting with the label in front. 194 */ 195 static class Hundredths extends UnsignedTime { 196 public Hundredths(Paint paint, float spacingRatio, final String allDigits) { 197 super(paint, spacingRatio, allDigits); 198 } 199 200 /** 201 * Draw this time segment after prepending the intra-segment spacing to the x location. 202 * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)} 203 */ 204 @Override 205 public float draw(Canvas canvas, final String time, float x, float y) { 206 return drawTime(canvas, time, 0, x + getLabelWidth(), y); 207 } 208 } 209 210 /** 211 * Special derivation to handle a negative number 212 */ 213 static class SignedTime extends UnsignedTime { 214 private float mMinusWidth = 0; 215 216 public SignedTime (UnsignedTime unsignedTime, float spacingRatio) { 217 super(unsignedTime, spacingRatio); 218 } 219 220 @Override 221 protected void updateWidth(final String time) { 222 super.updateWidth(time); 223 if (time.contains("-")) { 224 mMinusWidth = mPaint.measureText("-"); 225 mWidth += (mMinusWidth - mEm); 226 } else { 227 mMinusWidth = 0; 228 } 229 } 230 231 @Override 232 protected void resetWidth() { 233 super.resetWidth(); 234 mMinusWidth = 0; 235 } 236 237 /** 238 * Draws each character with a fixed spacing from time, handling the special negative 239 * number case. 240 * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)} 241 */ 242 @Override 243 public float draw(Canvas canvas, final String time, float x, float y) { 244 int ii = 0; 245 if (mMinusWidth != 0f) { 246 float minusWidth = mMinusWidth / 2; 247 x += minusWidth; 248 //TODO:hyphen is too thick when painted 249 canvas.drawText(time.substring(0, 1), x, y, mPaint); 250 x += minusWidth; 251 ii++; 252 } 253 return drawTime(canvas, time, ii, x, y) + getLabelWidth(); 254 } 255 } 256 257 @SuppressWarnings("unused") 258 public CountingTimerView(Context context) { 259 this(context, null); 260 } 261 262 public CountingTimerView(Context context, AttributeSet attrs) { 263 super(context, attrs); 264 mAccessibilityManager = 265 (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); 266 Resources r = context.getResources(); 267 mDefaultColor = mWhiteColor = r.getColor(R.color.clock_white); 268 mPressedColor = mAccentColor = Utils.obtainStyledColor( 269 context, R.attr.colorAccent, Color.RED); 270 mBigFontSize = r.getDimension(R.dimen.big_font_size); 271 mSmallFontSize = r.getDimension(R.dimen.small_font_size); 272 273 Typeface androidClockMonoThin = Typeface. 274 createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Thin.ttf"); 275 mPaintBigThin.setAntiAlias(true); 276 mPaintBigThin.setStyle(Paint.Style.STROKE); 277 mPaintBigThin.setTextAlign(Paint.Align.CENTER); 278 mPaintBigThin.setTypeface(androidClockMonoThin); 279 280 Typeface androidClockMonoLight = Typeface. 281 createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Light.ttf"); 282 mPaintMed.setAntiAlias(true); 283 mPaintMed.setStyle(Paint.Style.STROKE); 284 mPaintMed.setTextAlign(Paint.Align.CENTER); 285 mPaintMed.setTypeface(androidClockMonoLight); 286 287 resetTextSize(); 288 setTextColor(mDefaultColor); 289 290 // allDigits will contain ten digits: "0123456789" in the default locale 291 final String allDigits = String.format("%010d", 123456789); 292 mBigSeconds = new UnsignedTime(mPaintBigThin, 0.f, allDigits); 293 mBigHours = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING); 294 mBigMinutes = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING); 295 mMedHundredths = new Hundredths(mPaintMed, HUNDREDTHS_SPACING, allDigits); 296 297 mRadiusOffset = Utils.calculateRadiusOffset(r); 298 } 299 300 protected void resetTextSize() { 301 mTextHeight = mBigFontSize; 302 mPaintBigThin.setTextSize(mBigFontSize); 303 mPaintMed.setTextSize(mSmallFontSize); 304 } 305 306 protected void setTextColor(int textColor) { 307 mPaintBigThin.setColor(textColor); 308 mPaintMed.setColor(textColor); 309 } 310 311 /** 312 * Update the time to display. Separates that time into the hours, minutes, seconds and 313 * hundredths. If update is true, the view is invalidated so that it will draw again. 314 * 315 * @param time new time to display - in milliseconds 316 * @param showHundredths flag to show hundredths resolution 317 * @param update to invalidate the view - otherwise the time is examined to see if it is within 318 * 100 milliseconds of zero seconds and when so, invalidate the view. 319 */ 320 // TODO:showHundredths S/B attribute or setter - i.e. unchanging over object life 321 public void setTime(long time, boolean showHundredths, boolean update) { 322 int oldLength = getDigitsLength(); 323 boolean neg = false, showNeg = false; 324 String format; 325 if (time < 0) { 326 time = -time; 327 neg = showNeg = true; 328 } 329 long hundreds, seconds, minutes, hours; 330 seconds = time / 1000; 331 hundreds = (time - seconds * 1000) / 10; 332 minutes = seconds / 60; 333 seconds = seconds - minutes * 60; 334 hours = minutes / 60; 335 minutes = minutes - hours * 60; 336 if (hours > 999) { 337 hours = 0; 338 } 339 // The time can be between 0 and -1 seconds, but the "truncated" equivalent time of hours 340 // and minutes and seconds could be zero, so since we do not show fractions of seconds 341 // when counting down, do not show the minus sign. 342 // TODO:does it matter that we do not look at showHundredths? 343 if (hours == 0 && minutes == 0 && seconds == 0) { 344 showNeg = false; 345 } 346 347 // Normalize and check if it is 'time' to invalidate 348 if (!showHundredths) { 349 if (!neg && hundreds != 0) { 350 seconds++; 351 if (seconds == 60) { 352 seconds = 0; 353 minutes++; 354 if (minutes == 60) { 355 minutes = 0; 356 hours++; 357 } 358 } 359 } 360 if (hundreds < 10 || hundreds > 90) { 361 update = true; 362 } 363 } 364 365 // Hours may be empty 366 if (hours >= 10) { 367 format = showNeg ? NEG_TWO_DIGITS : TWO_DIGITS; 368 mHours = String.format(format, hours); 369 } else if (hours > 0) { 370 format = showNeg ? NEG_ONE_DIGIT : ONE_DIGIT; 371 mHours = String.format(format, hours); 372 } else { 373 mHours = null; 374 } 375 376 // Minutes are never empty and when hours are non-empty, must be two digits 377 if (minutes >= 10 || hours > 0) { 378 format = (showNeg && hours == 0) ? NEG_TWO_DIGITS : TWO_DIGITS; 379 mMinutes = String.format(format, minutes); 380 } else { 381 format = (showNeg && hours == 0) ? NEG_ONE_DIGIT : ONE_DIGIT; 382 mMinutes = String.format(format, minutes); 383 } 384 385 // Seconds are always two digits 386 mSeconds = String.format(TWO_DIGITS, seconds); 387 388 // Hundredths are optional and then two digits 389 if (showHundredths) { 390 mHundredths = String.format(TWO_DIGITS, hundreds); 391 } else { 392 mHundredths = null; 393 } 394 395 int newLength = getDigitsLength(); 396 if (oldLength != newLength) { 397 if (oldLength > newLength) { 398 resetTextSize(); 399 } 400 mRemeasureText = true; 401 } 402 403 if (update) { 404 setContentDescription(getTimeStringForAccessibility((int) hours, (int) minutes, 405 (int) seconds, showNeg, getResources())); 406 postInvalidateOnAnimation(); 407 } 408 } 409 410 private int getDigitsLength() { 411 return ((mHours == null) ? 0 : mHours.length()) 412 + ((mMinutes == null) ? 0 : mMinutes.length()) 413 + ((mSeconds == null) ? 0 : mSeconds.length()) 414 + ((mHundredths == null) ? 0 : mHundredths.length()); 415 } 416 417 private void calcTotalTextWidth() { 418 mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes) 419 + mBigSeconds.calcTotalWidth(mSeconds) 420 + mMedHundredths.calcTotalWidth(mHundredths); 421 } 422 423 /** 424 * Adjust the size of the fonts to fit within the the circle and painted object in 425 * {@link com.android.deskclock.CircleTimerView#onDraw(android.graphics.Canvas)} 426 */ 427 private void setTotalTextWidth() { 428 calcTotalTextWidth(); 429 // To determine the maximum width, we find the minimum of the height and width (since the 430 // circle we are trying to fit the text into has its radius sized to the smaller of the 431 // two. 432 int width = Math.min(getWidth(), getHeight()); 433 if (width != 0) { 434 // Shrink 'width' to account for circle stroke and other painted objects. 435 // Note on the "4 *": (1) To reduce divisions, using the diameter instead of the radius. 436 // (2) The radius of the enclosing circle is reduced by mRadiusOffset and the 437 // text needs to fit within a circle further reduced by mRadiusOffset. 438 width -= (int) (4 * mRadiusOffset + 0.5f); 439 440 final float wantDiameter2 = TEXT_SIZE_TO_WIDTH_RATIO * width * width; 441 float totalDiameter2 = getHypotenuseSquared(); 442 443 // If the hypotenuse of the bounding box is too large, reduce all the paint text sizes 444 while (totalDiameter2 > wantDiameter2) { 445 // Convergence is slightly difficult due to quantization in the mTotalTextWidth 446 // calculation. Reducing the ratio by 1% converges more quickly without excessive 447 // loss of quality. 448 float sizeRatio = 0.99f * (float) Math.sqrt(wantDiameter2/totalDiameter2); 449 mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio); 450 mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio); 451 // Recalculate the new total text height and half-width 452 mTextHeight = mPaintBigThin.getTextSize(); 453 calcTotalTextWidth(); 454 totalDiameter2 = getHypotenuseSquared(); 455 } 456 } 457 } 458 459 /** 460 * Calculate the square of the diameter to use in {@link CountingTimerView#setTotalTextWidth()} 461 */ 462 private float getHypotenuseSquared() { 463 return mTotalTextWidth * mTotalTextWidth + mTextHeight * mTextHeight; 464 } 465 466 public void blinkTimeStr(boolean blink) { 467 if (blink) { 468 removeCallbacks(mBlinkThread); 469 post(mBlinkThread); 470 } else { 471 removeCallbacks(mBlinkThread); 472 showTime(true); 473 } 474 } 475 476 public void showTime(boolean visible) { 477 mShowTimeStr = visible; 478 invalidate(); 479 } 480 481 public void setTimeStrTextColor(boolean active, boolean forceUpdate) { 482 mDefaultColor = active ? mAccentColor : mWhiteColor; 483 setTextColor(mDefaultColor); 484 if (forceUpdate) { 485 invalidate(); 486 } 487 } 488 489 private static String getTimeStringForAccessibility(int hours, int minutes, int seconds, 490 boolean showNeg, Resources r) { 491 StringBuilder s = new StringBuilder(); 492 if (showNeg) { 493 // This must be followed by a non-zero number or it will be audible as "hyphen" 494 // instead of "minus". 495 s.append("-"); 496 } 497 if (showNeg && hours == 0 && minutes == 0) { 498 // Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative 499 // time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds" 500 s.append(String.format( 501 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 502 seconds)); 503 } else if (hours == 0) { 504 s.append(String.format( 505 r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(), 506 minutes)); 507 s.append(" "); 508 s.append(String.format( 509 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 510 seconds)); 511 } else { 512 s.append(String.format( 513 r.getQuantityText(R.plurals.Nhours_description, hours).toString(), 514 hours)); 515 s.append(" "); 516 s.append(String.format( 517 r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(), 518 minutes)); 519 s.append(" "); 520 s.append(String.format( 521 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 522 seconds)); 523 } 524 return s.toString(); 525 } 526 527 public void setVirtualButtonEnabled(boolean enabled) { 528 mVirtualButtonEnabled = enabled; 529 } 530 531 private void virtualButtonPressed(boolean pressedOn) { 532 mVirtualButtonPressedOn = pressedOn; 533 invalidate(); 534 } 535 536 private boolean withinVirtualButtonBounds(float x, float y) { 537 int width = getWidth(); 538 int height = getHeight(); 539 float centerX = width / 2; 540 float centerY = height / 2; 541 float radius = Math.min(width, height) / 2; 542 543 // Within the circle button if distance to the center is less than the radius. 544 double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2)); 545 return distance < radius; 546 } 547 548 public void registerVirtualButtonAction(final Runnable runnable) { 549 if (!mAccessibilityManager.isEnabled()) { 550 this.setOnTouchListener(new OnTouchListener() { 551 @Override 552 public boolean onTouch(View v, MotionEvent event) { 553 if (mVirtualButtonEnabled) { 554 switch (event.getAction()) { 555 case MotionEvent.ACTION_DOWN: 556 if (withinVirtualButtonBounds(event.getX(), event.getY())) { 557 virtualButtonPressed(true); 558 return true; 559 } else { 560 virtualButtonPressed(false); 561 return false; 562 } 563 case MotionEvent.ACTION_CANCEL: 564 virtualButtonPressed(false); 565 return true; 566 case MotionEvent.ACTION_OUTSIDE: 567 virtualButtonPressed(false); 568 return false; 569 case MotionEvent.ACTION_UP: 570 virtualButtonPressed(false); 571 if (withinVirtualButtonBounds(event.getX(), event.getY())) { 572 runnable.run(); 573 } 574 return true; 575 } 576 } 577 return false; 578 } 579 }); 580 } else { 581 this.setOnClickListener(new OnClickListener() { 582 @Override 583 public void onClick(View v) { 584 runnable.run(); 585 } 586 }); 587 } 588 } 589 590 @Override 591 public void onDraw(Canvas canvas) { 592 // Blink functionality. 593 if (!mShowTimeStr && !mVirtualButtonPressedOn) { 594 return; 595 } 596 597 int width = getWidth(); 598 if (mRemeasureText && width != 0) { 599 setTotalTextWidth(); 600 width = getWidth(); 601 mRemeasureText = false; 602 } 603 604 int xCenter = width / 2; 605 int yCenter = getHeight() / 2; 606 607 float xTextStart = xCenter - mTotalTextWidth / 2; 608 float yTextStart = yCenter + mTextHeight/2 - (mTextHeight * FONT_VERTICAL_OFFSET); 609 610 // Text color differs based on pressed state. 611 final int textColor = mVirtualButtonPressedOn ? mPressedColor : mDefaultColor; 612 mPaintBigThin.setColor(textColor); 613 mPaintMed.setColor(textColor); 614 615 if (mHours != null) { 616 xTextStart = mBigHours.draw(canvas, mHours, xTextStart, yTextStart); 617 } 618 if (mMinutes != null) { 619 xTextStart = mBigMinutes.draw(canvas, mMinutes, xTextStart, yTextStart); 620 } 621 if (mSeconds != null) { 622 xTextStart = mBigSeconds.draw(canvas, mSeconds, xTextStart, yTextStart); 623 } 624 if (mHundredths != null) { 625 mMedHundredths.draw(canvas, mHundredths, xTextStart, yTextStart); 626 } 627 } 628 629 @Override 630 protected void onSizeChanged(int w, int h, int oldw, int oldh) { 631 super.onSizeChanged(w, h, oldw, oldh); 632 mRemeasureText = true; 633 resetTextSize(); 634 } 635 } 636