CountingTimerView.java revision 69ba4ed9235ad2a77177629bac241ab0e6915087
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
17package com.android.deskclock.timer;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Canvas;
22import android.graphics.Paint;
23import android.graphics.Typeface;
24import android.text.TextUtils;
25import android.util.AttributeSet;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.accessibility.AccessibilityManager;
29import android.widget.TextView;
30
31import com.android.deskclock.Log;
32import com.android.deskclock.R;
33import com.android.deskclock.Utils;
34
35
36public 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            postDelayed(mBlinkThread, 1000);
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