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.Color;
23import android.graphics.Paint;
24import android.graphics.Typeface;
25import android.text.TextUtils;
26import android.util.AttributeSet;
27import android.view.MotionEvent;
28import android.view.View;
29import android.view.accessibility.AccessibilityManager;
30
31import com.android.deskclock.LogUtils;
32import com.android.deskclock.R;
33import 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 */
41public 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