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 public class CountingTimerView extends View { 37 private static final String TWO_DIGITS = "%02d"; 38 private static final String ONE_DIGIT = "%01d"; 39 private static final String NEG_TWO_DIGITS = "-%02d"; 40 private static final String NEG_ONE_DIGIT = "-%01d"; 41 private static final float TEXT_SIZE_TO_WIDTH_RATIO = 0.75f; 42 // This is the ratio of the font typeface we need to offset the font by vertically to align it 43 // vertically center. 44 private static final float FONT_VERTICAL_OFFSET = 0.14f; 45 46 private String mHours, mMinutes, mSeconds, mHundredths; 47 48 private boolean mShowTimeStr = true; 49 private final Typeface mAndroidClockMonoThin, mAndroidClockMonoBold, mAndroidClockMonoLight; 50 private final Typeface mRobotoLabel; 51 private final Paint mPaintBig = new Paint(); 52 private final Paint mPaintBigThin = new Paint(); 53 private final Paint mPaintMed = new Paint(); 54 private final Paint mPaintLabel = new Paint(); 55 private final float mBigFontSize, mSmallFontSize; 56 private final SignedTime mBigHours, mBigMinutes; 57 private final UnsignedTime mBigThinSeconds; 58 private final Hundredths mMedHundredths; 59 private float mTextHeight = 0; 60 private float mTotalTextWidth; 61 private static final String HUNDREDTH_SEPERATOR = "."; 62 private boolean mRemeasureText = true; 63 64 private int mDefaultColor; 65 private final int mPressedColor; 66 private final int mWhiteColor; 67 private final int mRedColor; 68 private TextView mStopStartTextView; 69 private final AccessibilityManager mAccessibilityManager; 70 71 // Fields for the text serving as a virtual button. 72 private boolean mVirtualButtonEnabled = false; 73 private boolean mVirtualButtonPressedOn = false; 74 75 Runnable mBlinkThread = new Runnable() { 76 private boolean mVisible = true; 77 @Override 78 public void run() { 79 mVisible = !mVisible; 80 CountingTimerView.this.showTime(mVisible); 81 postDelayed(mBlinkThread, 500); 82 } 83 84 }; 85 86 class UnsignedTime { 87 protected Paint mPaint; 88 protected float mEm; 89 protected float mWidth = 0; 90 private final String mWidest; 91 protected String mLabel; 92 private float mLabelWidth = 0; 93 94 public UnsignedTime(Paint paint, final String label, String allDigits) { 95 mPaint = paint; 96 mLabel = label; 97 98 if (TextUtils.isEmpty(allDigits)) { 99 Log.wtf("Locale digits missing - using English"); 100 allDigits = "0123456789"; 101 } 102 103 float widths[] = new float[allDigits.length()]; 104 int ll = mPaint.getTextWidths(allDigits, widths); 105 int largest = 0; 106 for (int ii = 1; ii < ll; ii++) { 107 if (widths[ii] > widths[largest]) { 108 largest = ii; 109 } 110 } 111 112 mEm = widths[largest]; 113 mWidest = allDigits.substring(largest, largest + 1); 114 } 115 116 public UnsignedTime(UnsignedTime unsignedTime, final String label) { 117 this.mPaint = unsignedTime.mPaint; 118 this.mEm = unsignedTime.mEm; 119 this.mWidth = unsignedTime.mWidth; 120 this.mWidest = unsignedTime.mWidest; 121 this.mLabel = label; 122 } 123 124 protected void updateWidth(final String time) { 125 mEm = mPaint.measureText(mWidest); 126 mLabelWidth = mLabel == null ? 0 : mPaintLabel.measureText(mLabel); 127 mWidth = time.length() * mEm; 128 } 129 130 protected void resetWidth() { 131 mWidth = mLabelWidth = 0; 132 } 133 134 public float calcTotalWidth(final String time) { 135 if (time != null) { 136 updateWidth(time); 137 return mWidth + mLabelWidth; 138 } else { 139 resetWidth(); 140 return 0; 141 } 142 } 143 144 public float getWidth() { 145 return mWidth; 146 } 147 148 public float getLabelWidth() { 149 return mLabelWidth; 150 } 151 152 protected float drawTime(Canvas canvas, final String time, int ii, float x, float y) { 153 float textEm = mEm / 2f; 154 while (ii < time.length()) { 155 x += textEm; 156 canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint); 157 x += textEm; 158 ii++; 159 } 160 return x; 161 } 162 163 public float draw(Canvas canvas, final String time, float x, float y, float yLabel) { 164 x = drawTime(canvas, time, 0, x, y); 165 if (mLabel != null ) { 166 canvas.drawText(mLabel, x, yLabel, mPaintLabel); 167 } 168 return x + getLabelWidth(); 169 } 170 } 171 172 class Hundredths extends UnsignedTime { 173 public Hundredths(Paint paint, final String label, final String allDigits) { 174 super(paint, label, allDigits); 175 } 176 177 @Override 178 public float draw(Canvas canvas, final String time, float x, float y, float yLabel) { 179 if (mLabel != null) { 180 canvas.drawText(mLabel, x, yLabel, mPaintLabel); 181 } 182 return drawTime(canvas, time, 0, x + getLabelWidth(), y); 183 } 184 } 185 186 class SignedTime extends UnsignedTime { 187 private float mMinusWidth = 0; 188 189 public SignedTime(Paint paint, final String label, final String allDigits) { 190 super(paint, label, allDigits); 191 } 192 193 public SignedTime (SignedTime signedTime, final String label) { 194 super(signedTime, label); 195 } 196 197 @Override 198 protected void updateWidth(final String time) { 199 super.updateWidth(time); 200 if (time.contains("-")) { 201 mMinusWidth = mPaint.measureText("-"); 202 mWidth += (mMinusWidth - mEm); 203 } else { 204 mMinusWidth = 0; 205 } 206 } 207 208 @Override 209 protected void resetWidth() { 210 super.resetWidth(); 211 mMinusWidth = 0; 212 } 213 214 @Override 215 public float draw(Canvas canvas, final String time, float x, float y, float yLabel) { 216 int ii = 0; 217 if (mMinusWidth != 0f) { 218 float minusWidth = mMinusWidth / 2; 219 x += minusWidth; 220 canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint); 221 x += minusWidth; 222 ii++; 223 } 224 x = drawTime(canvas, time, ii, x, y); 225 if (mLabel != null) { 226 canvas.drawText(mLabel, x, yLabel, mPaintLabel); 227 } 228 return x + getLabelWidth(); 229 } 230 } 231 232 public CountingTimerView(Context context) { 233 this(context, null); 234 } 235 236 public CountingTimerView(Context context, AttributeSet attrs) { 237 super(context, attrs); 238 mAndroidClockMonoThin = Typeface.createFromAsset( 239 context.getAssets(), "fonts/AndroidClockMono-Thin.ttf"); 240 mAndroidClockMonoBold = Typeface.createFromAsset( 241 context.getAssets(), "fonts/AndroidClockMono-Bold.ttf"); 242 mAndroidClockMonoLight = Typeface.createFromAsset( 243 context.getAssets(), "fonts/AndroidClockMono-Light.ttf"); 244 mAccessibilityManager = 245 (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); 246 mRobotoLabel= Typeface.create("sans-serif-condensed", Typeface.BOLD); 247 Resources r = context.getResources(); 248 mWhiteColor = r.getColor(R.color.clock_white); 249 mDefaultColor = mWhiteColor; 250 mPressedColor = r.getColor(Utils.getPressedColorId()); 251 mRedColor = r.getColor(R.color.clock_red); 252 253 mPaintBig.setAntiAlias(true); 254 mPaintBig.setStyle(Paint.Style.STROKE); 255 mPaintBig.setTextAlign(Paint.Align.CENTER); 256 mPaintBig.setTypeface(mAndroidClockMonoBold); 257 mBigFontSize = r.getDimension(R.dimen.big_font_size); 258 mSmallFontSize = r.getDimension(R.dimen.small_font_size); 259 260 mPaintBigThin.setAntiAlias(true); 261 mPaintBigThin.setStyle(Paint.Style.STROKE); 262 mPaintBigThin.setTextAlign(Paint.Align.CENTER); 263 mPaintBigThin.setTypeface(mAndroidClockMonoThin); 264 265 mPaintMed.setAntiAlias(true); 266 mPaintMed.setStyle(Paint.Style.STROKE); 267 mPaintMed.setTextAlign(Paint.Align.CENTER); 268 mPaintMed.setTypeface(mAndroidClockMonoLight); 269 270 mPaintLabel.setAntiAlias(true); 271 mPaintLabel.setStyle(Paint.Style.STROKE); 272 mPaintLabel.setTextAlign(Paint.Align.LEFT); 273 mPaintLabel.setTypeface(mRobotoLabel); 274 mPaintLabel.setTextSize(r.getDimension(R.dimen.label_font_size)); 275 276 resetTextSize(); 277 setTextColor(mDefaultColor); 278 279 // allDigits will contain ten digits: "0123456789" in the default locale 280 final String allDigits = String.format("%010d", 123456789); 281 mBigHours = new SignedTime(mPaintBig, 282 r.getString(R.string.hours_label).toUpperCase(), allDigits); 283 mBigMinutes = new SignedTime(mBigHours, 284 r.getString(R.string.minutes_label).toUpperCase()); 285 mBigThinSeconds = new UnsignedTime(mPaintBigThin, 286 r.getString(R.string.seconds_label).toUpperCase(), allDigits); 287 mMedHundredths = new Hundredths(mPaintMed, HUNDREDTH_SEPERATOR, allDigits); 288 } 289 290 protected void resetTextSize() { 291 mPaintBig.setTextSize(mBigFontSize); 292 mTextHeight = mBigFontSize; 293 mPaintBigThin.setTextSize(mBigFontSize); 294 mPaintMed.setTextSize(mSmallFontSize); 295 } 296 297 protected void setTextColor(int textColor) { 298 mPaintBig.setColor(textColor); 299 mPaintBigThin.setColor(textColor); 300 mPaintMed.setColor(textColor); 301 mPaintLabel.setColor(textColor); 302 } 303 304 public void setTime(long time, boolean showHundredths, boolean update) { 305 boolean neg = false, showNeg = false; 306 String format = null; 307 if (time < 0) { 308 time = -time; 309 neg = showNeg = true; 310 } 311 long hundreds, seconds, minutes, hours; 312 seconds = time / 1000; 313 hundreds = (time - seconds * 1000) / 10; 314 minutes = seconds / 60; 315 seconds = seconds - minutes * 60; 316 hours = minutes / 60; 317 minutes = minutes - hours * 60; 318 if (hours > 999) { 319 hours = 0; 320 } 321 // time may less than a second below zero, since we do not show fractions of seconds 322 // when counting down, do not show the minus sign. 323 if (hours ==0 && minutes == 0 && seconds == 0) { 324 showNeg = false; 325 } 326 327 if (!showHundredths) { 328 if (!neg && hundreds != 0) { 329 seconds++; 330 if (seconds == 60) { 331 seconds = 0; 332 minutes++; 333 if (minutes == 60) { 334 minutes = 0; 335 hours++; 336 } 337 } 338 } 339 if (hundreds < 10 || hundreds > 90) { 340 update = true; 341 } 342 } 343 344 int oldLength = getDigitsLength(); 345 346 if (hours >= 10) { 347 format = showNeg ? NEG_TWO_DIGITS : TWO_DIGITS; 348 mHours = String.format(format, hours); 349 } else if (hours > 0) { 350 format = showNeg ? NEG_ONE_DIGIT : ONE_DIGIT; 351 mHours = String.format(format, hours); 352 } else { 353 mHours = null; 354 } 355 356 if (minutes >= 10 || hours > 0) { 357 format = (showNeg && hours == 0) ? NEG_TWO_DIGITS : TWO_DIGITS; 358 mMinutes = String.format(format, minutes); 359 } else { 360 format = (showNeg && hours == 0) ? NEG_ONE_DIGIT : ONE_DIGIT; 361 mMinutes = String.format(format, minutes); 362 } 363 364 mSeconds = String.format(TWO_DIGITS, seconds); 365 if (showHundredths) { 366 mHundredths = String.format(TWO_DIGITS, hundreds); 367 } else { 368 mHundredths = null; 369 } 370 371 int newLength = getDigitsLength(); 372 if (oldLength != newLength) { 373 if (oldLength > newLength) { 374 resetTextSize(); 375 } 376 mRemeasureText = true; 377 } 378 379 if (update) { 380 setContentDescription(getTimeStringForAccessibility((int) hours, (int) minutes, 381 (int) seconds, showNeg, getResources())); 382 invalidate(); 383 } 384 } 385 386 private int getDigitsLength() { 387 return ((mHours == null) ? 0 : mHours.length()) 388 + ((mMinutes == null) ? 0 : mMinutes.length()) 389 + ((mSeconds == null) ? 0 : mSeconds.length()) 390 + ((mHundredths == null) ? 0 : mHundredths.length()); 391 } 392 393 private void calcTotalTextWidth() { 394 mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes) 395 + mBigThinSeconds.calcTotalWidth(mSeconds) 396 + mMedHundredths.calcTotalWidth(mHundredths); 397 } 398 399 private void setTotalTextWidth() { 400 calcTotalTextWidth(); 401 // To determine the maximum width, we find the minimum of the height and width (since the 402 // circle we are trying to fit the text into has its radius sized to the smaller of the 403 // two. 404 int width = Math.min(getWidth(), getHeight()); 405 if (width != 0) { 406 float wantWidth = (int)(TEXT_SIZE_TO_WIDTH_RATIO * width); 407 // If the text is too wide, reduce all the paint text sizes 408 while (mTotalTextWidth > wantWidth) { 409 // Get fixed and variant parts of the total size 410 float fixedWidths = mBigHours.getLabelWidth() + mBigMinutes.getLabelWidth() 411 + mBigThinSeconds.getLabelWidth() + mMedHundredths.getLabelWidth(); 412 float varWidths = mBigHours.getWidth() + mBigMinutes.getWidth() 413 + mBigThinSeconds.getWidth() + mMedHundredths.getWidth(); 414 // Avoid divide by zero || sizeRatio == 1 || sizeRatio <= 0 415 if (varWidths == 0 || fixedWidths == 0 || fixedWidths >= wantWidth) { 416 break; 417 } 418 // Variant-section reduction 419 float sizeRatio = (wantWidth - fixedWidths) / varWidths; 420 mPaintBig.setTextSize(mPaintBig.getTextSize() * sizeRatio); 421 mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio); 422 mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio); 423 //recalculate the new total text width and half text height 424 mTextHeight = mPaintBig.getTextSize(); 425 calcTotalTextWidth(); 426 } 427 } 428 } 429 430 public void blinkTimeStr(boolean blink) { 431 if (blink) { 432 removeCallbacks(mBlinkThread); 433 post(mBlinkThread); 434 } else { 435 removeCallbacks(mBlinkThread); 436 showTime(true); 437 } 438 } 439 440 public void showTime(boolean visible) { 441 mShowTimeStr = visible; 442 invalidate(); 443 } 444 445 public void redTimeStr(boolean red, boolean forceUpdate) { 446 mDefaultColor = red ? mRedColor : mWhiteColor; 447 setTextColor(mDefaultColor); 448 if (forceUpdate) { 449 invalidate(); 450 } 451 } 452 453 public String getTimeString() { 454 // Though only called from Stopwatch Share, so hundredth are never null, 455 // protect the future and check for null mHundredths 456 if (mHundredths == null) { 457 if (mHours == null) { 458 return String.format("%s:%s", mMinutes, mSeconds); 459 } 460 return String.format("%s:%s:%s", mHours, mMinutes, mSeconds); 461 } else if (mHours == null) { 462 return String.format("%s:%s.%s", mMinutes, mSeconds, mHundredths); 463 } 464 return String.format("%s:%s:%s.%s", mHours, mMinutes, mSeconds, mHundredths); 465 } 466 467 private static String getTimeStringForAccessibility(int hours, int minutes, int seconds, 468 boolean showNeg, Resources r) { 469 StringBuilder s = new StringBuilder(); 470 if (showNeg) { 471 // This must be followed by a non-zero number or it will be audible as "hyphen" 472 // instead of "minus". 473 s.append("-"); 474 } 475 if (showNeg && hours == 0 && minutes == 0) { 476 // Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative 477 // time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds" 478 s.append(String.format( 479 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 480 seconds)); 481 } else if (hours == 0) { 482 s.append(String.format( 483 r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(), 484 minutes)); 485 s.append(" "); 486 s.append(String.format( 487 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 488 seconds)); 489 } else { 490 s.append(String.format( 491 r.getQuantityText(R.plurals.Nhours_description, hours).toString(), 492 hours)); 493 s.append(" "); 494 s.append(String.format( 495 r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(), 496 minutes)); 497 s.append(" "); 498 s.append(String.format( 499 r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(), 500 seconds)); 501 } 502 return s.toString(); 503 } 504 505 public void setVirtualButtonEnabled(boolean enabled) { 506 mVirtualButtonEnabled = enabled; 507 } 508 509 private void virtualButtonPressed(boolean pressedOn) { 510 mVirtualButtonPressedOn = pressedOn; 511 mStopStartTextView.setTextColor(pressedOn ? mPressedColor : mWhiteColor); 512 invalidate(); 513 } 514 515 private boolean withinVirtualButtonBounds(float x, float y) { 516 int width = getWidth(); 517 int height = getHeight(); 518 float centerX = width / 2; 519 float centerY = height / 2; 520 float radius = Math.min(width, height) / 2; 521 522 // Within the circle button if distance to the center is less than the radius. 523 double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2)); 524 return distance < radius; 525 } 526 527 public void registerVirtualButtonAction(final Runnable runnable) { 528 if (!mAccessibilityManager.isEnabled()) { 529 this.setOnTouchListener(new OnTouchListener() { 530 @Override 531 public boolean onTouch(View v, MotionEvent event) { 532 if (mVirtualButtonEnabled) { 533 switch (event.getAction()) { 534 case MotionEvent.ACTION_DOWN: 535 if (withinVirtualButtonBounds(event.getX(), event.getY())) { 536 virtualButtonPressed(true); 537 return true; 538 } else { 539 virtualButtonPressed(false); 540 return false; 541 } 542 case MotionEvent.ACTION_CANCEL: 543 virtualButtonPressed(false); 544 return true; 545 case MotionEvent.ACTION_OUTSIDE: 546 virtualButtonPressed(false); 547 return false; 548 case MotionEvent.ACTION_UP: 549 virtualButtonPressed(false); 550 if (withinVirtualButtonBounds(event.getX(), event.getY())) { 551 runnable.run(); 552 } 553 return true; 554 } 555 } 556 return false; 557 } 558 }); 559 } else { 560 this.setOnClickListener(new OnClickListener() { 561 @Override 562 public void onClick(View v) { 563 runnable.run(); 564 } 565 }); 566 } 567 } 568 569 @Override 570 public void onDraw(Canvas canvas) { 571 // Blink functionality. 572 if (!mShowTimeStr && !mVirtualButtonPressedOn) { 573 return; 574 } 575 576 int width = getWidth(); 577 if (mRemeasureText && width != 0) { 578 setTotalTextWidth(); 579 width = getWidth(); 580 mRemeasureText = false; 581 } 582 583 int xCenter = width / 2; 584 int yCenter = getHeight() / 2; 585 586 float textXstart = xCenter - mTotalTextWidth / 2; 587 float textYstart = yCenter + mTextHeight/2 - (mTextHeight * FONT_VERTICAL_OFFSET); 588 // align the labels vertically to the top of the rest of the text 589 float labelYStart = textYstart - (mTextHeight * (1 - 2 * FONT_VERTICAL_OFFSET)) 590 + (1 - 2 * FONT_VERTICAL_OFFSET) * mPaintLabel.getTextSize(); 591 592 // Text color differs based on pressed state. 593 int textColor; 594 if (mVirtualButtonPressedOn) { 595 textColor = mPressedColor; 596 mStopStartTextView.setTextColor(mPressedColor); 597 } else { 598 textColor = mDefaultColor; 599 } 600 mPaintBig.setColor(textColor); 601 mPaintBigThin.setColor(textColor); 602 mPaintLabel.setColor(textColor); 603 mPaintMed.setColor(textColor); 604 605 if (mHours != null) { 606 textXstart = mBigHours.draw(canvas, mHours, textXstart, textYstart, labelYStart); 607 } 608 if (mMinutes != null) { 609 textXstart = mBigMinutes.draw(canvas, mMinutes, textXstart, textYstart, labelYStart); 610 } 611 if (mSeconds != null) { 612 textXstart = mBigThinSeconds.draw(canvas, mSeconds, 613 textXstart, textYstart, labelYStart); 614 } 615 if (mHundredths != null) { 616 textXstart = mMedHundredths.draw(canvas, mHundredths, 617 textXstart, textYstart, textYstart); 618 } 619 } 620 621 public void registerStopTextView(TextView stopStartTextView) { 622 mStopStartTextView = stopStartTextView; 623 } 624 } 625