1 /*
2  * Copyright (C) 2021 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 package com.android.calendar.month
17 
18 import com.android.calendar.R
19 import com.android.calendar.Utils
20 import android.app.Service
21 import android.content.Context
22 import android.content.res.Resources
23 import android.graphics.Canvas
24 import android.graphics.Paint
25 import android.graphics.Paint.Align
26 import android.graphics.Paint.Style
27 import android.graphics.Rect
28 import android.graphics.drawable.Drawable
29 import android.text.format.DateUtils
30 import android.text.format.Time
31 import android.view.MotionEvent
32 import android.view.View
33 import android.view.accessibility.AccessibilityEvent
34 import android.view.accessibility.AccessibilityManager
35 import java.security.InvalidParameterException
36 import java.util.HashMap
37 
38 /**
39  *
40  *
41  * This is a dynamic view for drawing a single week. It can be configured to
42  * display the week number, start the week on a given day, or show a reduced
43  * number of days. It is intended for use as a single view within a ListView.
44  * See [SimpleWeeksAdapter] for usage.
45  *
46  */
47 open class SimpleWeekView(context: Context) : View(context) {
48     // affects the padding on the sides of this view
49     @JvmField protected var mPadding = 0
50     @JvmField protected var r: Rect = Rect()
51     @JvmField protected var p: Paint = Paint()
52     @JvmField protected var mMonthNumPaint: Paint = Paint()
53     @JvmField protected var mSelectedDayLine: Drawable
54 
55     // Cache the number strings so we don't have to recompute them each time
56     @JvmField protected var mDayNumbers: Array<String?>? = null
57 
58     // How many days to display
59     @JvmField protected var mNumDays = DEFAULT_NUM_DAYS
60 
61     // The number of days + a spot for week number if it is displayed
62     @JvmField protected var mNumCells = mNumDays
63 
64     // Quick lookup for checking which days are in the focus month
65     @JvmField protected var mFocusDay: BooleanArray = BooleanArray(mNumCells)
66 
67     // Quick lookup for checking which days are in an odd month (to set a different background)
68     @JvmField protected var mOddMonth: BooleanArray = BooleanArray(mNumCells)
69 
70     // The Julian day of the first day displayed by this item
71     @JvmField protected var mFirstJulianDay = -1
72 
73     // The month of the first day in this week
74     @JvmField protected var firstMonth = -1
75 
76     // The month of the last day in this week
77     @JvmField protected var lastMonth = -1
78 
79     // The position of this week, equivalent to weeks since the week of Jan 1st,
80     // 1970
81     @JvmField var mWeek = -1
82 
83     // Quick reference to the width of this view, matches parent
84     @JvmField protected var mWidth = 0
85 
86     // The height this view should draw at in pixels, set by height param
87     @JvmField protected var mHeight = DEFAULT_HEIGHT
88 
89     // Whether the week number should be shown
90     @JvmField protected var mShowWeekNum = false
91 
92     // If this view contains the selected day
93     @JvmField protected var mHasSelectedDay = false
94 
95     // If this view contains the today
96     open protected var mHasToday = false
97 
98     // Which day is selected [0-6] or -1 if no day is selected
99     @JvmField protected var mSelectedDay = DEFAULT_SELECTED_DAY
100 
101     // Which day is today [0-6] or -1 if no day is today
102     @JvmField protected var mToday: Int = DEFAULT_SELECTED_DAY
103 
104     // Which day of the week to start on [0-6]
105     @JvmField protected var mWeekStart = DEFAULT_WEEK_START
106 
107     // The left edge of the selected day
108     @JvmField protected var mSelectedLeft = -1
109 
110     // The right edge of the selected day
111     @JvmField protected var mSelectedRight = -1
112 
113     // The timezone to display times/dates in (used for determining when Today
114     // is)
115     @JvmField protected var mTimeZone: String = Time.getCurrentTimezone()
116     @JvmField protected var mBGColor: Int
117     @JvmField protected var mSelectedWeekBGColor: Int
118     @JvmField protected var mFocusMonthColor: Int
119     @JvmField protected var mOtherMonthColor: Int
120     @JvmField protected var mDaySeparatorColor: Int
121     @JvmField protected var mTodayOutlineColor: Int
122     @JvmField protected var mWeekNumColor: Int
123 
124     /**
125      * Sets all the parameters for displaying this week. The only required
126      * parameter is the week number. Other parameters have a default value and
127      * will only update if a new value is included, except for focus month,
128      * which will always default to no focus month if no value is passed in. See
129      * [.VIEW_PARAMS_HEIGHT] for more info on parameters.
130      *
131      * @param params A map of the new parameters, see
132      * [.VIEW_PARAMS_HEIGHT]
133      * @param tz The time zone this view should reference times in
134      */
setWeekParamsnull135     open fun setWeekParams(params: HashMap<String?, Int?>, tz: String) {
136         if (!params.containsKey(VIEW_PARAMS_WEEK)) {
137             throw InvalidParameterException("You must specify the week number for this view")
138         }
139         setTag(params)
140         mTimeZone = tz
141         // We keep the current value for any params not present
142         if (params.containsKey(VIEW_PARAMS_HEIGHT)) {
143             mHeight = (params.get(VIEW_PARAMS_HEIGHT))!!.toInt()
144             if (mHeight < MIN_HEIGHT) {
145                 mHeight = MIN_HEIGHT
146             }
147         }
148         if (params.containsKey(VIEW_PARAMS_SELECTED_DAY)) {
149             mSelectedDay = (params.get(VIEW_PARAMS_SELECTED_DAY))!!.toInt()
150         }
151         mHasSelectedDay = mSelectedDay != -1
152         if (params.containsKey(VIEW_PARAMS_NUM_DAYS)) {
153             mNumDays = (params.get(VIEW_PARAMS_NUM_DAYS))!!.toInt()
154         }
155         if (params.containsKey(VIEW_PARAMS_SHOW_WK_NUM)) {
156             mShowWeekNum =
157                     if (params.get(VIEW_PARAMS_SHOW_WK_NUM) != 0) {
158                         true
159                     } else {
160                         false
161                     }
162         }
163         mNumCells = if (mShowWeekNum) mNumDays + 1 else mNumDays
164 
165         // Allocate space for caching the day numbers and focus values
166         mDayNumbers = arrayOfNulls(mNumCells)
167         mFocusDay = BooleanArray(mNumCells)
168         mOddMonth = BooleanArray(mNumCells)
169         mWeek = (params.get(VIEW_PARAMS_WEEK))!!.toInt()
170         val julianMonday: Int = Utils.getJulianMondayFromWeeksSinceEpoch(mWeek)
171         val time = Time(tz)
172         time.setJulianDay(julianMonday)
173 
174         // If we're showing the week number calculate it based on Monday
175         var i = 0
176         if (mShowWeekNum) {
177             mDayNumbers!![0] = Integer.toString(time.getWeekNumber())
178             i++
179         }
180         if (params.containsKey(VIEW_PARAMS_WEEK_START)) {
181             mWeekStart = (params.get(VIEW_PARAMS_WEEK_START))!!.toInt()
182         }
183 
184         // Now adjust our starting day based on the start day of the week
185         // If the week is set to start on a Saturday the first week will be
186         // Dec 27th 1969 -Jan 2nd, 1970
187         if (time.weekDay !== mWeekStart) {
188             var diff: Int = time.weekDay - mWeekStart
189             if (diff < 0) {
190                 diff += 7
191             }
192             time.monthDay -= diff
193             time.normalize(true)
194         }
195         mFirstJulianDay = Time.getJulianDay(time.toMillis(true), time.gmtoff)
196         firstMonth = time.month
197 
198         // Figure out what day today is
199         val today = Time(tz)
200         today.setToNow()
201         mHasToday = false
202         mToday = -1
203         val focusMonth = if (params.containsKey(VIEW_PARAMS_FOCUS_MONTH)) params.get(
204                 VIEW_PARAMS_FOCUS_MONTH
205         ) else DEFAULT_FOCUS_MONTH
206         while (i < mNumCells) {
207             if (time.monthDay === 1) {
208                 firstMonth = time.month
209             }
210             mOddMonth[i] = time.month % 2 === 1
211             if (time.month === focusMonth) {
212                 mFocusDay[i] = true
213             } else {
214                 mFocusDay[i] = false
215             }
216             if (time.year === today.year && time.yearDay === today.yearDay) {
217                 mHasToday = true
218                 mToday = i
219             }
220             mDayNumbers!![i] = Integer.toString(time.monthDay++)
221             time.normalize(true)
222             i++
223         }
224         // We do one extra add at the end of the loop, if that pushed us to a
225         // new month undo it
226         if (time.monthDay === 1) {
227             time.monthDay--
228             time.normalize(true)
229         }
230         lastMonth = time.month
231         updateSelectionPositions()
232     }
233 
234     /**
235      * Sets up the text and style properties for painting. Override this if you
236      * want to use a different paint.
237      */
initViewnull238     protected open fun initView() {
239         p.setFakeBoldText(false)
240         p.setAntiAlias(true)
241         p.setTextSize(MINI_DAY_NUMBER_TEXT_SIZE.toFloat())
242         p.setStyle(Style.FILL)
243         mMonthNumPaint = Paint()
244         mMonthNumPaint.setFakeBoldText(true)
245         mMonthNumPaint.setAntiAlias(true)
246         mMonthNumPaint.setTextSize(MINI_DAY_NUMBER_TEXT_SIZE.toFloat())
247         mMonthNumPaint.setColor(mFocusMonthColor)
248         mMonthNumPaint.setStyle(Style.FILL)
249         mMonthNumPaint.setTextAlign(Align.CENTER)
250     }
251 
252     /**
253      * Returns the month of the first day in this week
254      *
255      * @return The month the first day of this view is in
256      */
getFirstMonthnull257     fun getFirstMonth(): Int {
258         return firstMonth
259     }
260 
261     /**
262      * Returns the month of the last day in this week
263      *
264      * @return The month the last day of this view is in
265      */
getLastMonthnull266     fun getLastMonth(): Int {
267         return lastMonth
268     }
269 
270     /**
271      * Returns the julian day of the first day in this view.
272      *
273      * @return The julian day of the first day in the view.
274      */
getFirstJulianDaynull275     fun getFirstJulianDay(): Int {
276         return mFirstJulianDay
277     }
278 
279     /**
280      * Calculates the day that the given x position is in, accounting for week
281      * number. Returns a Time referencing that day or null if
282      *
283      * @param x The x position of the touch event
284      * @return A time object for the tapped day or null if the position wasn't
285      * in a day
286      */
getDayFromLocationnull287     open fun getDayFromLocation(x: Float): Time? {
288         val dayStart =
289                 if (mShowWeekNum) (mWidth - mPadding * 2) / mNumCells + mPadding else mPadding
290         if (x < dayStart || x > mWidth - mPadding) {
291             return null
292         }
293         // Selection is (x - start) / (pixels/day) == (x -s) * day / pixels
294         val dayPosition = ((x - dayStart) * mNumDays / (mWidth - dayStart - mPadding)).toInt()
295         var day = mFirstJulianDay + dayPosition
296         val time = Time(mTimeZone)
297         if (mWeek == 0) {
298             // This week is weird...
299             if (day < Time.EPOCH_JULIAN_DAY) {
300                 day++
301             } else if (day == Time.EPOCH_JULIAN_DAY) {
302                 time.set(1, 0, 1970)
303                 time.normalize(true)
304                 return time
305             }
306         }
307         time.setJulianDay(day)
308         return time
309     }
310 
311     @Override
onDrawnull312     protected override fun onDraw(canvas: Canvas) {
313         drawBackground(canvas)
314         drawWeekNums(canvas)
315         drawDaySeparators(canvas)
316     }
317 
318     /**
319      * This draws the selection highlight if a day is selected in this week.
320      * Override this method if you wish to have a different background drawn.
321      *
322      * @param canvas The canvas to draw on
323      */
drawBackgroundnull324     protected open fun drawBackground(canvas: Canvas) {
325         if (mHasSelectedDay) {
326             p.setColor(mSelectedWeekBGColor)
327             p.setStyle(Style.FILL)
328         } else {
329             return
330         }
331         r.top = 1
332         r.bottom = mHeight - 1
333         r.left = mPadding
334         r.right = mSelectedLeft
335         canvas.drawRect(r, p)
336         r.left = mSelectedRight
337         r.right = mWidth - mPadding
338         canvas.drawRect(r, p)
339     }
340 
341     /**
342      * Draws the week and month day numbers for this week. Override this method
343      * if you need different placement.
344      *
345      * @param canvas The canvas to draw on
346      */
drawWeekNumsnull347     protected open fun drawWeekNums(canvas: Canvas) {
348         val y = (mHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2 - DAY_SEPARATOR_WIDTH
349         val nDays = mNumCells
350         var i = 0
351         val divisor = 2 * nDays
352         if (mShowWeekNum) {
353             p.setTextSize(MINI_WK_NUMBER_TEXT_SIZE.toFloat())
354             p.setStyle(Style.FILL)
355             p.setTextAlign(Align.CENTER)
356             p.setAntiAlias(true)
357             p.setColor(mWeekNumColor)
358             val x = (mWidth - mPadding * 2) / divisor + mPadding
359             canvas.drawText(mDayNumbers!![0] as String, x.toFloat(), y.toFloat(), p)
360             i++
361         }
362         var isFocusMonth = mFocusDay[i]
363         mMonthNumPaint.setColor(if (isFocusMonth) mFocusMonthColor else mOtherMonthColor)
364         mMonthNumPaint.setFakeBoldText(false)
365         while (i < nDays) {
366             if (mFocusDay[i] != isFocusMonth) {
367                 isFocusMonth = mFocusDay[i]
368                 mMonthNumPaint.setColor(if (isFocusMonth) mFocusMonthColor else mOtherMonthColor)
369             }
370             if (mHasToday && mToday == i) {
371                 mMonthNumPaint.setTextSize(MINI_TODAY_NUMBER_TEXT_SIZE.toFloat())
372                 mMonthNumPaint.setFakeBoldText(true)
373             }
374             val x = (2 * i + 1) * (mWidth - mPadding * 2) / divisor + mPadding
375             canvas.drawText(mDayNumbers!![i] as String, x.toFloat(), y.toFloat(),
376                     mMonthNumPaint as Paint)
377             if (mHasToday && mToday == i) {
378                 mMonthNumPaint.setTextSize(MINI_DAY_NUMBER_TEXT_SIZE.toFloat())
379                 mMonthNumPaint.setFakeBoldText(false)
380             }
381             i++
382         }
383     }
384 
385     /**
386      * Draws a horizontal line for separating the weeks. Override this method if
387      * you want custom separators.
388      *
389      * @param canvas The canvas to draw on
390      */
drawDaySeparatorsnull391     protected open fun drawDaySeparators(canvas: Canvas) {
392         if (mHasSelectedDay) {
393             r.top = 1
394             r.bottom = mHeight - 1
395             r.left = mSelectedLeft + 1
396             r.right = mSelectedRight - 1
397             p.setStrokeWidth(MINI_TODAY_OUTLINE_WIDTH.toFloat())
398             p.setStyle(Style.STROKE)
399             p.setColor(mTodayOutlineColor)
400             canvas.drawRect(r, p)
401         }
402         if (mShowWeekNum) {
403             p.setColor(mDaySeparatorColor)
404             p.setStrokeWidth(DAY_SEPARATOR_WIDTH.toFloat())
405             val x = (mWidth - mPadding * 2) / mNumCells + mPadding
406             canvas.drawLine(x.toFloat(), 0f, x.toFloat(), mHeight.toFloat(), p)
407         }
408     }
409 
410     @Override
onSizeChangednull411     protected override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
412         mWidth = w
413         updateSelectionPositions()
414     }
415 
416     /**
417      * This calculates the positions for the selected day lines.
418      */
updateSelectionPositionsnull419     protected open fun updateSelectionPositions() {
420         if (mHasSelectedDay) {
421             var selectedPosition = mSelectedDay - mWeekStart
422             if (selectedPosition < 0) {
423                 selectedPosition += 7
424             }
425             if (mShowWeekNum) {
426                 selectedPosition++
427             }
428             mSelectedLeft = (selectedPosition * (mWidth - mPadding * 2) / mNumCells +
429                     mPadding)
430             mSelectedRight = ((selectedPosition + 1) * (mWidth - mPadding * 2) / mNumCells +
431                     mPadding)
432         }
433     }
434 
435     @Override
onMeasurenull436     protected override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
437         setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mHeight)
438     }
439 
440     @Override
onHoverEventnull441     override fun onHoverEvent(event: MotionEvent): Boolean {
442         val context: Context = getContext()
443         // only send accessibility events if accessibility and exploration are
444         // on.
445         val am: AccessibilityManager = context
446                 .getSystemService(Service.ACCESSIBILITY_SERVICE) as AccessibilityManager
447         if (!am.isEnabled() || !am.isTouchExplorationEnabled()) {
448             return super.onHoverEvent(event)
449         }
450         if (event.getAction() !== MotionEvent.ACTION_HOVER_EXIT) {
451             val hover: Time? = getDayFromLocation(event.getX())
452             if (hover != null &&
453                     (mLastHoverTime == null || Time.compare(hover, mLastHoverTime) !== 0)
454             ) {
455                 val millis: Long = hover.toMillis(true)
456                 val date: String? = Utils.formatDateRange(
457                         context, millis, millis,
458                         DateUtils.FORMAT_SHOW_DATE
459                 )
460                 val accessEvent: AccessibilityEvent =
461                         AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED)
462                 accessEvent.getText().add(date)
463                 sendAccessibilityEventUnchecked(accessEvent)
464                 mLastHoverTime = hover
465             }
466         }
467         return true
468     }
469 
470     @JvmField var mLastHoverTime: Time? = null
471 
472     companion object {
473         private const val TAG = "MonthView"
474         /**
475          * These params can be passed into the view to control how it appears.
476          * [.VIEW_PARAMS_WEEK] is the only required field, though the default
477          * values are unlikely to fit most layouts correctly.
478          */
479         /**
480          * This sets the height of this week in pixels
481          */
482         const val VIEW_PARAMS_HEIGHT = "height"
483 
484         /**
485          * This specifies the position (or weeks since the epoch) of this week,
486          * calculated using [Utils.getWeeksSinceEpochFromJulianDay]
487          */
488         const val VIEW_PARAMS_WEEK = "week"
489 
490         /**
491          * This sets one of the days in this view as selected [Time.SUNDAY]
492          * through [Time.SATURDAY].
493          */
494         const val VIEW_PARAMS_SELECTED_DAY = "selected_day"
495 
496         /**
497          * Which day the week should start on. [Time.SUNDAY] through
498          * [Time.SATURDAY].
499          */
500         const val VIEW_PARAMS_WEEK_START = "week_start"
501 
502         /**
503          * How many days to display at a time. Days will be displayed starting with
504          * [.mWeekStart].
505          */
506         const val VIEW_PARAMS_NUM_DAYS = "num_days"
507 
508         /**
509          * Which month is currently in focus, as defined by [Time.month]
510          * [0-11].
511          */
512         const val VIEW_PARAMS_FOCUS_MONTH = "focus_month"
513 
514         /**
515          * If this month should display week numbers. false if 0, true otherwise.
516          */
517         const val VIEW_PARAMS_SHOW_WK_NUM = "show_wk_num"
518         protected var DEFAULT_HEIGHT = 32
519         protected var MIN_HEIGHT = 10
520         protected const val DEFAULT_SELECTED_DAY = -1
521         protected val DEFAULT_WEEK_START: Int = Time.SUNDAY
522         protected const val DEFAULT_NUM_DAYS = 7
523         protected const val DEFAULT_SHOW_WK_NUM = 0
524         protected const val DEFAULT_FOCUS_MONTH = -1
525         protected var DAY_SEPARATOR_WIDTH = 1
526         protected var MINI_DAY_NUMBER_TEXT_SIZE = 14
527         protected var MINI_WK_NUMBER_TEXT_SIZE = 12
528         protected var MINI_TODAY_NUMBER_TEXT_SIZE = 18
529         protected var MINI_TODAY_OUTLINE_WIDTH = 2
530         protected var WEEK_NUM_MARGIN_BOTTOM = 4
531 
532         // used for scaling to the device density
533         @JvmStatic protected var mScale = 0f
534     }
535 
536     init {
537         val res: Resources = context.getResources()
538         mBGColor = res.getColor(R.color.month_bgcolor)
539         mSelectedWeekBGColor = res.getColor(R.color.month_selected_week_bgcolor)
540         mFocusMonthColor = res.getColor(R.color.month_mini_day_number)
541         mOtherMonthColor = res.getColor(R.color.month_other_month_day_number)
542         mDaySeparatorColor = res.getColor(R.color.month_grid_lines)
543         mTodayOutlineColor = res.getColor(R.color.mini_month_today_outline_color)
544         mWeekNumColor = res.getColor(R.color.month_week_num_color)
545         mSelectedDayLine = res.getDrawable(R.drawable.dayline_minical_holo_light)
546         if (mScale == 0f) {
547             mScale = context.getResources().getDisplayMetrics().density
548             if (mScale != 1f) {
549                 DEFAULT_HEIGHT *= mScale.toInt()
550                 MIN_HEIGHT *= mScale.toInt()
551                 MINI_DAY_NUMBER_TEXT_SIZE *= mScale.toInt()
552                 MINI_TODAY_NUMBER_TEXT_SIZE *= mScale.toInt()
553                 MINI_TODAY_OUTLINE_WIDTH *= mScale.toInt()
554                 WEEK_NUM_MARGIN_BOTTOM *= mScale.toInt()
555                 DAY_SEPARATOR_WIDTH *= mScale.toInt()
556                 MINI_WK_NUMBER_TEXT_SIZE *= mScale.toInt()
557             }
558         }
559 
560         // Sets up any standard paints that will be used
561         initView()
562     }
563 }