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