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
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 */
42public 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