1/*
2 * Copyright (C) 2014 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 android.widget;
18
19import com.android.internal.R;
20import com.android.internal.widget.ExploreByTouchHelper;
21
22import android.annotation.Nullable;
23import android.content.Context;
24import android.content.res.ColorStateList;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.graphics.Canvas;
28import android.graphics.Paint;
29import android.graphics.Paint.Align;
30import android.graphics.Paint.Style;
31import android.graphics.Rect;
32import android.graphics.Typeface;
33import android.icu.text.DisplayContext;
34import android.icu.text.SimpleDateFormat;
35import android.icu.util.Calendar;
36import android.os.Bundle;
37import android.text.TextPaint;
38import android.text.format.DateFormat;
39import android.util.AttributeSet;
40import android.util.IntArray;
41import android.util.MathUtils;
42import android.util.StateSet;
43import android.view.KeyEvent;
44import android.view.MotionEvent;
45import android.view.View;
46import android.view.ViewParent;
47import android.view.accessibility.AccessibilityEvent;
48import android.view.accessibility.AccessibilityNodeInfo;
49import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
50
51import java.text.NumberFormat;
52import java.util.Locale;
53
54import libcore.icu.LocaleData;
55
56/**
57 * A calendar-like view displaying a specified month and the appropriate selectable day numbers
58 * within the specified month.
59 */
60class SimpleMonthView extends View {
61    private static final int DAYS_IN_WEEK = 7;
62    private static final int MAX_WEEKS_IN_MONTH = 6;
63
64    private static final int DEFAULT_SELECTED_DAY = -1;
65    private static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
66
67    private static final String MONTH_YEAR_FORMAT = "MMMMy";
68
69    private static final int SELECTED_HIGHLIGHT_ALPHA = 0xB0;
70
71    private final TextPaint mMonthPaint = new TextPaint();
72    private final TextPaint mDayOfWeekPaint = new TextPaint();
73    private final TextPaint mDayPaint = new TextPaint();
74    private final Paint mDaySelectorPaint = new Paint();
75    private final Paint mDayHighlightPaint = new Paint();
76    private final Paint mDayHighlightSelectorPaint = new Paint();
77
78    /** Array of single-character weekday labels ordered by column index. */
79    private final String[] mDayOfWeekLabels = new String[7];
80
81    private final Calendar mCalendar;
82    private final Locale mLocale;
83
84    private final MonthViewTouchHelper mTouchHelper;
85
86    private final NumberFormat mDayFormatter;
87
88    // Desired dimensions.
89    private final int mDesiredMonthHeight;
90    private final int mDesiredDayOfWeekHeight;
91    private final int mDesiredDayHeight;
92    private final int mDesiredCellWidth;
93    private final int mDesiredDaySelectorRadius;
94
95    private String mMonthYearLabel;
96
97    private int mMonth;
98    private int mYear;
99
100    // Dimensions as laid out.
101    private int mMonthHeight;
102    private int mDayOfWeekHeight;
103    private int mDayHeight;
104    private int mCellWidth;
105    private int mDaySelectorRadius;
106
107    private int mPaddedWidth;
108    private int mPaddedHeight;
109
110    /** The day of month for the selected day, or -1 if no day is selected. */
111    private int mActivatedDay = -1;
112
113    /**
114     * The day of month for today, or -1 if the today is not in the current
115     * month.
116     */
117    private int mToday = DEFAULT_SELECTED_DAY;
118
119    /** The first day of the week (ex. Calendar.SUNDAY) indexed from one. */
120    private int mWeekStart = DEFAULT_WEEK_START;
121
122    /** The number of days (ex. 28) in the current month. */
123    private int mDaysInMonth;
124
125    /**
126     * The day of week (ex. Calendar.SUNDAY) for the first day of the current
127     * month.
128     */
129    private int mDayOfWeekStart;
130
131    /** The day of month for the first (inclusive) enabled day. */
132    private int mEnabledDayStart = 1;
133
134    /** The day of month for the last (inclusive) enabled day. */
135    private int mEnabledDayEnd = 31;
136
137    /** Optional listener for handling day click actions. */
138    private OnDayClickListener mOnDayClickListener;
139
140    private ColorStateList mDayTextColor;
141
142    private int mHighlightedDay = -1;
143    private int mPreviouslyHighlightedDay = -1;
144    private boolean mIsTouchHighlighted = false;
145
146    public SimpleMonthView(Context context) {
147        this(context, null);
148    }
149
150    public SimpleMonthView(Context context, AttributeSet attrs) {
151        this(context, attrs, R.attr.datePickerStyle);
152    }
153
154    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr) {
155        this(context, attrs, defStyleAttr, 0);
156    }
157
158    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
159        super(context, attrs, defStyleAttr, defStyleRes);
160
161        final Resources res = context.getResources();
162        mDesiredMonthHeight = res.getDimensionPixelSize(R.dimen.date_picker_month_height);
163        mDesiredDayOfWeekHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_of_week_height);
164        mDesiredDayHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_height);
165        mDesiredCellWidth = res.getDimensionPixelSize(R.dimen.date_picker_day_width);
166        mDesiredDaySelectorRadius = res.getDimensionPixelSize(
167                R.dimen.date_picker_day_selector_radius);
168
169        // Set up accessibility components.
170        mTouchHelper = new MonthViewTouchHelper(this);
171        setAccessibilityDelegate(mTouchHelper);
172        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
173
174        mLocale = res.getConfiguration().locale;
175        mCalendar = Calendar.getInstance(mLocale);
176
177        mDayFormatter = NumberFormat.getIntegerInstance(mLocale);
178
179        updateMonthYearLabel();
180        updateDayOfWeekLabels();
181
182        initPaints(res);
183    }
184
185    private void updateMonthYearLabel() {
186        final String format = DateFormat.getBestDateTimePattern(mLocale, MONTH_YEAR_FORMAT);
187        final SimpleDateFormat formatter = new SimpleDateFormat(format, mLocale);
188        formatter.setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE);
189        mMonthYearLabel = formatter.format(mCalendar.getTime());
190    }
191
192    private void updateDayOfWeekLabels() {
193        // Use tiny (e.g. single-character) weekday names from ICU. The indices
194        // for this list correspond to Calendar days, e.g. SUNDAY is index 1.
195        final String[] tinyWeekdayNames = LocaleData.get(mLocale).tinyWeekdayNames;
196        for (int i = 0; i < DAYS_IN_WEEK; i++) {
197            mDayOfWeekLabels[i] = tinyWeekdayNames[(mWeekStart + i - 1) % DAYS_IN_WEEK + 1];
198        }
199    }
200
201    /**
202     * Applies the specified text appearance resource to a paint, returning the
203     * text color if one is set in the text appearance.
204     *
205     * @param p the paint to modify
206     * @param resId the resource ID of the text appearance
207     * @return the text color, if available
208     */
209    private ColorStateList applyTextAppearance(Paint p, int resId) {
210        final TypedArray ta = mContext.obtainStyledAttributes(null,
211                R.styleable.TextAppearance, 0, resId);
212
213        final String fontFamily = ta.getString(R.styleable.TextAppearance_fontFamily);
214        if (fontFamily != null) {
215            p.setTypeface(Typeface.create(fontFamily, 0));
216        }
217
218        p.setTextSize(ta.getDimensionPixelSize(
219                R.styleable.TextAppearance_textSize, (int) p.getTextSize()));
220
221        final ColorStateList textColor = ta.getColorStateList(R.styleable.TextAppearance_textColor);
222        if (textColor != null) {
223            final int enabledColor = textColor.getColorForState(ENABLED_STATE_SET, 0);
224            p.setColor(enabledColor);
225        }
226
227        ta.recycle();
228
229        return textColor;
230    }
231
232    public int getMonthHeight() {
233        return mMonthHeight;
234    }
235
236    public int getCellWidth() {
237        return mCellWidth;
238    }
239
240    public void setMonthTextAppearance(int resId) {
241        applyTextAppearance(mMonthPaint, resId);
242
243        invalidate();
244    }
245
246    public void setDayOfWeekTextAppearance(int resId) {
247        applyTextAppearance(mDayOfWeekPaint, resId);
248        invalidate();
249    }
250
251    public void setDayTextAppearance(int resId) {
252        final ColorStateList textColor = applyTextAppearance(mDayPaint, resId);
253        if (textColor != null) {
254            mDayTextColor = textColor;
255        }
256
257        invalidate();
258    }
259
260    /**
261     * Sets up the text and style properties for painting.
262     */
263    private void initPaints(Resources res) {
264        final String monthTypeface = res.getString(R.string.date_picker_month_typeface);
265        final String dayOfWeekTypeface = res.getString(R.string.date_picker_day_of_week_typeface);
266        final String dayTypeface = res.getString(R.string.date_picker_day_typeface);
267
268        final int monthTextSize = res.getDimensionPixelSize(
269                R.dimen.date_picker_month_text_size);
270        final int dayOfWeekTextSize = res.getDimensionPixelSize(
271                R.dimen.date_picker_day_of_week_text_size);
272        final int dayTextSize = res.getDimensionPixelSize(
273                R.dimen.date_picker_day_text_size);
274
275        mMonthPaint.setAntiAlias(true);
276        mMonthPaint.setTextSize(monthTextSize);
277        mMonthPaint.setTypeface(Typeface.create(monthTypeface, 0));
278        mMonthPaint.setTextAlign(Align.CENTER);
279        mMonthPaint.setStyle(Style.FILL);
280
281        mDayOfWeekPaint.setAntiAlias(true);
282        mDayOfWeekPaint.setTextSize(dayOfWeekTextSize);
283        mDayOfWeekPaint.setTypeface(Typeface.create(dayOfWeekTypeface, 0));
284        mDayOfWeekPaint.setTextAlign(Align.CENTER);
285        mDayOfWeekPaint.setStyle(Style.FILL);
286
287        mDaySelectorPaint.setAntiAlias(true);
288        mDaySelectorPaint.setStyle(Style.FILL);
289
290        mDayHighlightPaint.setAntiAlias(true);
291        mDayHighlightPaint.setStyle(Style.FILL);
292
293        mDayHighlightSelectorPaint.setAntiAlias(true);
294        mDayHighlightSelectorPaint.setStyle(Style.FILL);
295
296        mDayPaint.setAntiAlias(true);
297        mDayPaint.setTextSize(dayTextSize);
298        mDayPaint.setTypeface(Typeface.create(dayTypeface, 0));
299        mDayPaint.setTextAlign(Align.CENTER);
300        mDayPaint.setStyle(Style.FILL);
301    }
302
303    void setMonthTextColor(ColorStateList monthTextColor) {
304        final int enabledColor = monthTextColor.getColorForState(ENABLED_STATE_SET, 0);
305        mMonthPaint.setColor(enabledColor);
306        invalidate();
307    }
308
309    void setDayOfWeekTextColor(ColorStateList dayOfWeekTextColor) {
310        final int enabledColor = dayOfWeekTextColor.getColorForState(ENABLED_STATE_SET, 0);
311        mDayOfWeekPaint.setColor(enabledColor);
312        invalidate();
313    }
314
315    void setDayTextColor(ColorStateList dayTextColor) {
316        mDayTextColor = dayTextColor;
317        invalidate();
318    }
319
320    void setDaySelectorColor(ColorStateList dayBackgroundColor) {
321        final int activatedColor = dayBackgroundColor.getColorForState(
322                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED), 0);
323        mDaySelectorPaint.setColor(activatedColor);
324        mDayHighlightSelectorPaint.setColor(activatedColor);
325        mDayHighlightSelectorPaint.setAlpha(SELECTED_HIGHLIGHT_ALPHA);
326        invalidate();
327    }
328
329    void setDayHighlightColor(ColorStateList dayHighlightColor) {
330        final int pressedColor = dayHighlightColor.getColorForState(
331                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_PRESSED), 0);
332        mDayHighlightPaint.setColor(pressedColor);
333        invalidate();
334    }
335
336    public void setOnDayClickListener(OnDayClickListener listener) {
337        mOnDayClickListener = listener;
338    }
339
340    @Override
341    public boolean dispatchHoverEvent(MotionEvent event) {
342        // First right-of-refusal goes the touch exploration helper.
343        return mTouchHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
344    }
345
346    @Override
347    public boolean onTouchEvent(MotionEvent event) {
348        final int x = (int) (event.getX() + 0.5f);
349        final int y = (int) (event.getY() + 0.5f);
350
351        final int action = event.getAction();
352        switch (action) {
353            case MotionEvent.ACTION_DOWN:
354            case MotionEvent.ACTION_MOVE:
355                final int touchedItem = getDayAtLocation(x, y);
356                mIsTouchHighlighted = true;
357                if (mHighlightedDay != touchedItem) {
358                    mHighlightedDay = touchedItem;
359                    mPreviouslyHighlightedDay = touchedItem;
360                    invalidate();
361                }
362                if (action == MotionEvent.ACTION_DOWN && touchedItem < 0) {
363                    // Touch something that's not an item, reject event.
364                    return false;
365                }
366                break;
367
368            case MotionEvent.ACTION_UP:
369                final int clickedDay = getDayAtLocation(x, y);
370                onDayClicked(clickedDay);
371                // Fall through.
372            case MotionEvent.ACTION_CANCEL:
373                // Reset touched day on stream end.
374                mHighlightedDay = -1;
375                mIsTouchHighlighted = false;
376                invalidate();
377                break;
378        }
379        return true;
380    }
381
382    @Override
383    public boolean onKeyDown(int keyCode, KeyEvent event) {
384        // We need to handle focus change within the SimpleMonthView because we are simulating
385        // multiple Views. The arrow keys will move between days until there is no space (no
386        // day to the left, top, right, or bottom). Focus forward and back jumps out of the
387        // SimpleMonthView, skipping over other SimpleMonthViews in the parent ViewPager
388        // to the next focusable View in the hierarchy.
389        boolean focusChanged = false;
390        switch (event.getKeyCode()) {
391            case KeyEvent.KEYCODE_DPAD_LEFT:
392                if (event.hasNoModifiers()) {
393                    focusChanged = moveOneDay(isLayoutRtl());
394                }
395                break;
396            case KeyEvent.KEYCODE_DPAD_RIGHT:
397                if (event.hasNoModifiers()) {
398                    focusChanged = moveOneDay(!isLayoutRtl());
399                }
400                break;
401            case KeyEvent.KEYCODE_DPAD_UP:
402                if (event.hasNoModifiers()) {
403                    ensureFocusedDay();
404                    if (mHighlightedDay > 7) {
405                        mHighlightedDay -= 7;
406                        focusChanged = true;
407                    }
408                }
409                break;
410            case KeyEvent.KEYCODE_DPAD_DOWN:
411                if (event.hasNoModifiers()) {
412                    ensureFocusedDay();
413                    if (mHighlightedDay <= mDaysInMonth - 7) {
414                        mHighlightedDay += 7;
415                        focusChanged = true;
416                    }
417                }
418                break;
419            case KeyEvent.KEYCODE_DPAD_CENTER:
420            case KeyEvent.KEYCODE_ENTER:
421                if (mHighlightedDay != -1) {
422                    onDayClicked(mHighlightedDay);
423                    return true;
424                }
425                break;
426            case KeyEvent.KEYCODE_TAB: {
427                int focusChangeDirection = 0;
428                if (event.hasNoModifiers()) {
429                    focusChangeDirection = View.FOCUS_FORWARD;
430                } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
431                    focusChangeDirection = View.FOCUS_BACKWARD;
432                }
433                if (focusChangeDirection != 0) {
434                    final ViewParent parent = getParent();
435                    // move out of the ViewPager next/previous
436                    View nextFocus = this;
437                    do {
438                        nextFocus = nextFocus.focusSearch(focusChangeDirection);
439                    } while (nextFocus != null && nextFocus != this &&
440                            nextFocus.getParent() == parent);
441                    if (nextFocus != null) {
442                        nextFocus.requestFocus();
443                        return true;
444                    }
445                }
446                break;
447            }
448        }
449        if (focusChanged) {
450            invalidate();
451            return true;
452        } else {
453            return super.onKeyDown(keyCode, event);
454        }
455    }
456
457    private boolean moveOneDay(boolean positive) {
458        ensureFocusedDay();
459        boolean focusChanged = false;
460        if (positive) {
461            if (!isLastDayOfWeek(mHighlightedDay) && mHighlightedDay < mDaysInMonth) {
462                mHighlightedDay++;
463                focusChanged = true;
464            }
465        } else {
466            if (!isFirstDayOfWeek(mHighlightedDay) && mHighlightedDay > 1) {
467                mHighlightedDay--;
468                focusChanged = true;
469            }
470        }
471        return focusChanged;
472    }
473
474    @Override
475    protected void onFocusChanged(boolean gainFocus, @FocusDirection int direction,
476            @Nullable Rect previouslyFocusedRect) {
477        if (gainFocus) {
478            // If we've gained focus through arrow keys, we should find the day closest
479            // to the focus rect. If we've gained focus through forward/back, we should
480            // focus on the selected day if there is one.
481            final int offset = findDayOffset();
482            switch(direction) {
483                case View.FOCUS_RIGHT: {
484                    int row = findClosestRow(previouslyFocusedRect);
485                    mHighlightedDay = row == 0 ? 1 : (row * DAYS_IN_WEEK) - offset + 1;
486                    break;
487                }
488                case View.FOCUS_LEFT: {
489                    int row = findClosestRow(previouslyFocusedRect) + 1;
490                    mHighlightedDay = Math.min(mDaysInMonth, (row * DAYS_IN_WEEK) - offset);
491                    break;
492                }
493                case View.FOCUS_DOWN: {
494                    final int col = findClosestColumn(previouslyFocusedRect);
495                    final int day = col - offset + 1;
496                    mHighlightedDay = day < 1 ? day + DAYS_IN_WEEK : day;
497                    break;
498                }
499                case View.FOCUS_UP: {
500                    final int col = findClosestColumn(previouslyFocusedRect);
501                    final int maxWeeks = (offset + mDaysInMonth) / DAYS_IN_WEEK;
502                    final int day = col - offset + (DAYS_IN_WEEK * maxWeeks) + 1;
503                    mHighlightedDay = day > mDaysInMonth ? day - DAYS_IN_WEEK : day;
504                    break;
505                }
506            }
507            ensureFocusedDay();
508            invalidate();
509        }
510        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
511    }
512
513    /**
514     * Returns the row (0 indexed) closest to previouslyFocusedRect or center if null.
515     */
516    private int findClosestRow(@Nullable Rect previouslyFocusedRect) {
517        if (previouslyFocusedRect == null) {
518            return 3;
519        } else {
520            int centerY = previouslyFocusedRect.centerY();
521
522            final TextPaint p = mDayPaint;
523            final int headerHeight = mMonthHeight + mDayOfWeekHeight;
524            final int rowHeight = mDayHeight;
525
526            // Text is vertically centered within the row height.
527            final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
528            final int rowCenter = headerHeight + rowHeight / 2;
529
530            centerY -= rowCenter - halfLineHeight;
531            int row = Math.round(centerY / (float) rowHeight);
532            final int maxDay = findDayOffset() + mDaysInMonth;
533            final int maxRows = (maxDay / DAYS_IN_WEEK) - ((maxDay % DAYS_IN_WEEK == 0) ? 1 : 0);
534
535            row = MathUtils.constrain(row, 0, maxRows);
536            return row;
537        }
538    }
539
540    /**
541     * Returns the column (0 indexed) closest to the previouslyFocusedRect or center if null.
542     * The 0 index is related to the first day of the week.
543     */
544    private int findClosestColumn(@Nullable Rect previouslyFocusedRect) {
545        if (previouslyFocusedRect == null) {
546            return DAYS_IN_WEEK / 2;
547        } else {
548            int centerX = previouslyFocusedRect.centerX() - mPaddingLeft;
549            final int columnFromLeft =
550                    MathUtils.constrain(centerX / mCellWidth, 0, DAYS_IN_WEEK - 1);
551            return isLayoutRtl() ? DAYS_IN_WEEK - columnFromLeft - 1: columnFromLeft;
552        }
553    }
554
555    @Override
556    public void getFocusedRect(Rect r) {
557        if (mHighlightedDay > 0) {
558            getBoundsForDay(mHighlightedDay, r);
559        } else {
560            super.getFocusedRect(r);
561        }
562    }
563
564    @Override
565    protected void onFocusLost() {
566        if (!mIsTouchHighlighted) {
567            // Unhighlight a day.
568            mPreviouslyHighlightedDay = mHighlightedDay;
569            mHighlightedDay = -1;
570            invalidate();
571        }
572        super.onFocusLost();
573    }
574
575    /**
576     * Ensure some day is highlighted. If a day isn't highlighted, it chooses the selected day,
577     * if possible, or the first day of the month if not.
578     */
579    private void ensureFocusedDay() {
580        if (mHighlightedDay != -1) {
581            return;
582        }
583        if (mPreviouslyHighlightedDay != -1) {
584            mHighlightedDay = mPreviouslyHighlightedDay;
585            return;
586        }
587        if (mActivatedDay != -1) {
588            mHighlightedDay = mActivatedDay;
589            return;
590        }
591        mHighlightedDay = 1;
592    }
593
594    private boolean isFirstDayOfWeek(int day) {
595        final int offset = findDayOffset();
596        return (offset + day - 1) % DAYS_IN_WEEK == 0;
597    }
598
599    private boolean isLastDayOfWeek(int day) {
600        final int offset = findDayOffset();
601        return (offset + day) % DAYS_IN_WEEK == 0;
602    }
603
604    @Override
605    protected void onDraw(Canvas canvas) {
606        final int paddingLeft = getPaddingLeft();
607        final int paddingTop = getPaddingTop();
608        canvas.translate(paddingLeft, paddingTop);
609
610        drawMonth(canvas);
611        drawDaysOfWeek(canvas);
612        drawDays(canvas);
613
614        canvas.translate(-paddingLeft, -paddingTop);
615    }
616
617    private void drawMonth(Canvas canvas) {
618        final float x = mPaddedWidth / 2f;
619
620        // Vertically centered within the month header height.
621        final float lineHeight = mMonthPaint.ascent() + mMonthPaint.descent();
622        final float y = (mMonthHeight - lineHeight) / 2f;
623
624        canvas.drawText(mMonthYearLabel, x, y, mMonthPaint);
625    }
626
627    public String getMonthYearLabel() {
628        return mMonthYearLabel;
629    }
630
631    private void drawDaysOfWeek(Canvas canvas) {
632        final TextPaint p = mDayOfWeekPaint;
633        final int headerHeight = mMonthHeight;
634        final int rowHeight = mDayOfWeekHeight;
635        final int colWidth = mCellWidth;
636
637        // Text is vertically centered within the day of week height.
638        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
639        final int rowCenter = headerHeight + rowHeight / 2;
640
641        for (int col = 0; col < DAYS_IN_WEEK; col++) {
642            final int colCenter = colWidth * col + colWidth / 2;
643            final int colCenterRtl;
644            if (isLayoutRtl()) {
645                colCenterRtl = mPaddedWidth - colCenter;
646            } else {
647                colCenterRtl = colCenter;
648            }
649
650            final String label = mDayOfWeekLabels[col];
651            canvas.drawText(label, colCenterRtl, rowCenter - halfLineHeight, p);
652        }
653    }
654
655    /**
656     * Draws the month days.
657     */
658    private void drawDays(Canvas canvas) {
659        final TextPaint p = mDayPaint;
660        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
661        final int rowHeight = mDayHeight;
662        final int colWidth = mCellWidth;
663
664        // Text is vertically centered within the row height.
665        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
666        int rowCenter = headerHeight + rowHeight / 2;
667
668        for (int day = 1, col = findDayOffset(); day <= mDaysInMonth; day++) {
669            final int colCenter = colWidth * col + colWidth / 2;
670            final int colCenterRtl;
671            if (isLayoutRtl()) {
672                colCenterRtl = mPaddedWidth - colCenter;
673            } else {
674                colCenterRtl = colCenter;
675            }
676
677            int stateMask = 0;
678
679            final boolean isDayEnabled = isDayEnabled(day);
680            if (isDayEnabled) {
681                stateMask |= StateSet.VIEW_STATE_ENABLED;
682            }
683
684            final boolean isDayActivated = mActivatedDay == day;
685            final boolean isDayHighlighted = mHighlightedDay == day;
686            if (isDayActivated) {
687                stateMask |= StateSet.VIEW_STATE_ACTIVATED;
688
689                // Adjust the circle to be centered on the row.
690                final Paint paint = isDayHighlighted ? mDayHighlightSelectorPaint :
691                        mDaySelectorPaint;
692                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, paint);
693            } else if (isDayHighlighted) {
694                stateMask |= StateSet.VIEW_STATE_PRESSED;
695
696                if (isDayEnabled) {
697                    // Adjust the circle to be centered on the row.
698                    canvas.drawCircle(colCenterRtl, rowCenter,
699                            mDaySelectorRadius, mDayHighlightPaint);
700                }
701            }
702
703            final boolean isDayToday = mToday == day;
704            final int dayTextColor;
705            if (isDayToday && !isDayActivated) {
706                dayTextColor = mDaySelectorPaint.getColor();
707            } else {
708                final int[] stateSet = StateSet.get(stateMask);
709                dayTextColor = mDayTextColor.getColorForState(stateSet, 0);
710            }
711            p.setColor(dayTextColor);
712
713            canvas.drawText(mDayFormatter.format(day), colCenterRtl, rowCenter - halfLineHeight, p);
714
715            col++;
716
717            if (col == DAYS_IN_WEEK) {
718                col = 0;
719                rowCenter += rowHeight;
720            }
721        }
722    }
723
724    private boolean isDayEnabled(int day) {
725        return day >= mEnabledDayStart && day <= mEnabledDayEnd;
726    }
727
728    private boolean isValidDayOfMonth(int day) {
729        return day >= 1 && day <= mDaysInMonth;
730    }
731
732    private static boolean isValidDayOfWeek(int day) {
733        return day >= Calendar.SUNDAY && day <= Calendar.SATURDAY;
734    }
735
736    private static boolean isValidMonth(int month) {
737        return month >= Calendar.JANUARY && month <= Calendar.DECEMBER;
738    }
739
740    /**
741     * Sets the selected day.
742     *
743     * @param dayOfMonth the selected day of the month, or {@code -1} to clear
744     *                   the selection
745     */
746    public void setSelectedDay(int dayOfMonth) {
747        mActivatedDay = dayOfMonth;
748
749        // Invalidate cached accessibility information.
750        mTouchHelper.invalidateRoot();
751        invalidate();
752    }
753
754    /**
755     * Sets the first day of the week.
756     *
757     * @param weekStart which day the week should start on, valid values are
758     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
759     */
760    public void setFirstDayOfWeek(int weekStart) {
761        if (isValidDayOfWeek(weekStart)) {
762            mWeekStart = weekStart;
763        } else {
764            mWeekStart = mCalendar.getFirstDayOfWeek();
765        }
766
767        updateDayOfWeekLabels();
768
769        // Invalidate cached accessibility information.
770        mTouchHelper.invalidateRoot();
771        invalidate();
772    }
773
774    /**
775     * Sets all the parameters for displaying this week.
776     * <p>
777     * Parameters have a default value and will only update if a new value is
778     * included, except for focus month, which will always default to no focus
779     * month if no value is passed in. The only required parameter is the week
780     * start.
781     *
782     * @param selectedDay the selected day of the month, or -1 for no selection
783     * @param month the month
784     * @param year the year
785     * @param weekStart which day the week should start on, valid values are
786     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
787     * @param enabledDayStart the first enabled day
788     * @param enabledDayEnd the last enabled day
789     */
790    void setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart,
791            int enabledDayEnd) {
792        mActivatedDay = selectedDay;
793
794        if (isValidMonth(month)) {
795            mMonth = month;
796        }
797        mYear = year;
798
799        mCalendar.set(Calendar.MONTH, mMonth);
800        mCalendar.set(Calendar.YEAR, mYear);
801        mCalendar.set(Calendar.DAY_OF_MONTH, 1);
802        mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
803
804        if (isValidDayOfWeek(weekStart)) {
805            mWeekStart = weekStart;
806        } else {
807            mWeekStart = mCalendar.getFirstDayOfWeek();
808        }
809
810        // Figure out what day today is.
811        final Calendar today = Calendar.getInstance();
812        mToday = -1;
813        mDaysInMonth = getDaysInMonth(mMonth, mYear);
814        for (int i = 0; i < mDaysInMonth; i++) {
815            final int day = i + 1;
816            if (sameDay(day, today)) {
817                mToday = day;
818            }
819        }
820
821        mEnabledDayStart = MathUtils.constrain(enabledDayStart, 1, mDaysInMonth);
822        mEnabledDayEnd = MathUtils.constrain(enabledDayEnd, mEnabledDayStart, mDaysInMonth);
823
824        updateMonthYearLabel();
825        updateDayOfWeekLabels();
826
827        // Invalidate cached accessibility information.
828        mTouchHelper.invalidateRoot();
829        invalidate();
830    }
831
832    private static int getDaysInMonth(int month, int year) {
833        switch (month) {
834            case Calendar.JANUARY:
835            case Calendar.MARCH:
836            case Calendar.MAY:
837            case Calendar.JULY:
838            case Calendar.AUGUST:
839            case Calendar.OCTOBER:
840            case Calendar.DECEMBER:
841                return 31;
842            case Calendar.APRIL:
843            case Calendar.JUNE:
844            case Calendar.SEPTEMBER:
845            case Calendar.NOVEMBER:
846                return 30;
847            case Calendar.FEBRUARY:
848                return (year % 4 == 0) ? 29 : 28;
849            default:
850                throw new IllegalArgumentException("Invalid Month");
851        }
852    }
853
854    private boolean sameDay(int day, Calendar today) {
855        return mYear == today.get(Calendar.YEAR) && mMonth == today.get(Calendar.MONTH)
856                && day == today.get(Calendar.DAY_OF_MONTH);
857    }
858
859    @Override
860    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
861        final int preferredHeight = mDesiredDayHeight * MAX_WEEKS_IN_MONTH
862                + mDesiredDayOfWeekHeight + mDesiredMonthHeight
863                + getPaddingTop() + getPaddingBottom();
864        final int preferredWidth = mDesiredCellWidth * DAYS_IN_WEEK
865                + getPaddingStart() + getPaddingEnd();
866        final int resolvedWidth = resolveSize(preferredWidth, widthMeasureSpec);
867        final int resolvedHeight = resolveSize(preferredHeight, heightMeasureSpec);
868        setMeasuredDimension(resolvedWidth, resolvedHeight);
869    }
870
871    @Override
872    public void onRtlPropertiesChanged(@ResolvedLayoutDir int layoutDirection) {
873        super.onRtlPropertiesChanged(layoutDirection);
874
875        requestLayout();
876    }
877
878    @Override
879    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
880        if (!changed) {
881            return;
882        }
883
884        // Let's initialize a completely reasonable number of variables.
885        final int w = right - left;
886        final int h = bottom - top;
887        final int paddingLeft = getPaddingLeft();
888        final int paddingTop = getPaddingTop();
889        final int paddingRight = getPaddingRight();
890        final int paddingBottom = getPaddingBottom();
891        final int paddedRight = w - paddingRight;
892        final int paddedBottom = h - paddingBottom;
893        final int paddedWidth = paddedRight - paddingLeft;
894        final int paddedHeight = paddedBottom - paddingTop;
895        if (paddedWidth == mPaddedWidth || paddedHeight == mPaddedHeight) {
896            return;
897        }
898
899        mPaddedWidth = paddedWidth;
900        mPaddedHeight = paddedHeight;
901
902        // We may have been laid out smaller than our preferred size. If so,
903        // scale all dimensions to fit.
904        final int measuredPaddedHeight = getMeasuredHeight() - paddingTop - paddingBottom;
905        final float scaleH = paddedHeight / (float) measuredPaddedHeight;
906        final int monthHeight = (int) (mDesiredMonthHeight * scaleH);
907        final int cellWidth = mPaddedWidth / DAYS_IN_WEEK;
908        mMonthHeight = monthHeight;
909        mDayOfWeekHeight = (int) (mDesiredDayOfWeekHeight * scaleH);
910        mDayHeight = (int) (mDesiredDayHeight * scaleH);
911        mCellWidth = cellWidth;
912
913        // Compute the largest day selector radius that's still within the clip
914        // bounds and desired selector radius.
915        final int maxSelectorWidth = cellWidth / 2 + Math.min(paddingLeft, paddingRight);
916        final int maxSelectorHeight = mDayHeight / 2 + paddingBottom;
917        mDaySelectorRadius = Math.min(mDesiredDaySelectorRadius,
918                Math.min(maxSelectorWidth, maxSelectorHeight));
919
920        // Invalidate cached accessibility information.
921        mTouchHelper.invalidateRoot();
922    }
923
924    private int findDayOffset() {
925        final int offset = mDayOfWeekStart - mWeekStart;
926        if (mDayOfWeekStart < mWeekStart) {
927            return offset + DAYS_IN_WEEK;
928        }
929        return offset;
930    }
931
932    /**
933     * Calculates the day of the month at the specified touch position. Returns
934     * the day of the month or -1 if the position wasn't in a valid day.
935     *
936     * @param x the x position of the touch event
937     * @param y the y position of the touch event
938     * @return the day of the month at (x, y), or -1 if the position wasn't in
939     *         a valid day
940     */
941    private int getDayAtLocation(int x, int y) {
942        final int paddedX = x - getPaddingLeft();
943        if (paddedX < 0 || paddedX >= mPaddedWidth) {
944            return -1;
945        }
946
947        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
948        final int paddedY = y - getPaddingTop();
949        if (paddedY < headerHeight || paddedY >= mPaddedHeight) {
950            return -1;
951        }
952
953        // Adjust for RTL after applying padding.
954        final int paddedXRtl;
955        if (isLayoutRtl()) {
956            paddedXRtl = mPaddedWidth - paddedX;
957        } else {
958            paddedXRtl = paddedX;
959        }
960
961        final int row = (paddedY - headerHeight) / mDayHeight;
962        final int col = (paddedXRtl * DAYS_IN_WEEK) / mPaddedWidth;
963        final int index = col + row * DAYS_IN_WEEK;
964        final int day = index + 1 - findDayOffset();
965        if (!isValidDayOfMonth(day)) {
966            return -1;
967        }
968
969        return day;
970    }
971
972    /**
973     * Calculates the bounds of the specified day.
974     *
975     * @param id the day of the month
976     * @param outBounds the rect to populate with bounds
977     */
978    private boolean getBoundsForDay(int id, Rect outBounds) {
979        if (!isValidDayOfMonth(id)) {
980            return false;
981        }
982
983        final int index = id - 1 + findDayOffset();
984
985        // Compute left edge, taking into account RTL.
986        final int col = index % DAYS_IN_WEEK;
987        final int colWidth = mCellWidth;
988        final int left;
989        if (isLayoutRtl()) {
990            left = getWidth() - getPaddingRight() - (col + 1) * colWidth;
991        } else {
992            left = getPaddingLeft() + col * colWidth;
993        }
994
995        // Compute top edge.
996        final int row = index / DAYS_IN_WEEK;
997        final int rowHeight = mDayHeight;
998        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
999        final int top = getPaddingTop() + headerHeight + row * rowHeight;
1000
1001        outBounds.set(left, top, left + colWidth, top + rowHeight);
1002
1003        return true;
1004    }
1005
1006    /**
1007     * Called when the user clicks on a day. Handles callbacks to the
1008     * {@link OnDayClickListener} if one is set.
1009     *
1010     * @param day the day that was clicked
1011     */
1012    private boolean onDayClicked(int day) {
1013        if (!isValidDayOfMonth(day) || !isDayEnabled(day)) {
1014            return false;
1015        }
1016
1017        if (mOnDayClickListener != null) {
1018            final Calendar date = Calendar.getInstance();
1019            date.set(mYear, mMonth, day);
1020            mOnDayClickListener.onDayClick(this, date);
1021        }
1022
1023        // This is a no-op if accessibility is turned off.
1024        mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
1025        return true;
1026    }
1027
1028    /**
1029     * Provides a virtual view hierarchy for interfacing with an accessibility
1030     * service.
1031     */
1032    private class MonthViewTouchHelper extends ExploreByTouchHelper {
1033        private static final String DATE_FORMAT = "dd MMMM yyyy";
1034
1035        private final Rect mTempRect = new Rect();
1036        private final Calendar mTempCalendar = Calendar.getInstance();
1037
1038        public MonthViewTouchHelper(View host) {
1039            super(host);
1040        }
1041
1042        @Override
1043        protected int getVirtualViewAt(float x, float y) {
1044            final int day = getDayAtLocation((int) (x + 0.5f), (int) (y + 0.5f));
1045            if (day != -1) {
1046                return day;
1047            }
1048            return ExploreByTouchHelper.INVALID_ID;
1049        }
1050
1051        @Override
1052        protected void getVisibleVirtualViews(IntArray virtualViewIds) {
1053            for (int day = 1; day <= mDaysInMonth; day++) {
1054                virtualViewIds.add(day);
1055            }
1056        }
1057
1058        @Override
1059        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
1060            event.setContentDescription(getDayDescription(virtualViewId));
1061        }
1062
1063        @Override
1064        protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
1065            final boolean hasBounds = getBoundsForDay(virtualViewId, mTempRect);
1066
1067            if (!hasBounds) {
1068                // The day is invalid, kill the node.
1069                mTempRect.setEmpty();
1070                node.setContentDescription("");
1071                node.setBoundsInParent(mTempRect);
1072                node.setVisibleToUser(false);
1073                return;
1074            }
1075
1076            node.setText(getDayText(virtualViewId));
1077            node.setContentDescription(getDayDescription(virtualViewId));
1078            node.setBoundsInParent(mTempRect);
1079
1080            final boolean isDayEnabled = isDayEnabled(virtualViewId);
1081            if (isDayEnabled) {
1082                node.addAction(AccessibilityAction.ACTION_CLICK);
1083            }
1084
1085            node.setEnabled(isDayEnabled);
1086
1087            if (virtualViewId == mActivatedDay) {
1088                // TODO: This should use activated once that's supported.
1089                node.setChecked(true);
1090            }
1091
1092        }
1093
1094        @Override
1095        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
1096                Bundle arguments) {
1097            switch (action) {
1098                case AccessibilityNodeInfo.ACTION_CLICK:
1099                    return onDayClicked(virtualViewId);
1100            }
1101
1102            return false;
1103        }
1104
1105        /**
1106         * Generates a description for a given virtual view.
1107         *
1108         * @param id the day to generate a description for
1109         * @return a description of the virtual view
1110         */
1111        private CharSequence getDayDescription(int id) {
1112            if (isValidDayOfMonth(id)) {
1113                mTempCalendar.set(mYear, mMonth, id);
1114                return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis());
1115            }
1116
1117            return "";
1118        }
1119
1120        /**
1121         * Generates displayed text for a given virtual view.
1122         *
1123         * @param id the day to generate text for
1124         * @return the visible text of the virtual view
1125         */
1126        private CharSequence getDayText(int id) {
1127            if (isValidDayOfMonth(id)) {
1128                return mDayFormatter.format(id);
1129            }
1130
1131            return null;
1132        }
1133    }
1134
1135    /**
1136     * Handles callbacks when the user clicks on a time object.
1137     */
1138    public interface OnDayClickListener {
1139        void onDayClick(SimpleMonthView view, Calendar day);
1140    }
1141}
1142