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.Activity
21 import android.app.ListFragment
22 import android.content.Context
23 import android.content.res.Resources
24 import android.database.DataSetObserver
25 import android.os.Bundle
26 import android.os.Handler
27 import android.text.TextUtils
28 import android.text.format.DateUtils
29 import android.text.format.Time
30 import android.util.Log
31 import android.view.LayoutInflater
32 import android.view.View
33 import android.view.ViewConfiguration
34 import android.view.ViewGroup
35 import android.view.accessibility.AccessibilityEvent
36 import android.widget.AbsListView
37 import android.widget.AbsListView.OnScrollListener
38 import android.widget.ListView
39 import android.widget.TextView
40 import java.util.Calendar
41 import java.util.HashMap
42 import java.util.Locale
43 
44 /**
45  *
46  *
47  * This displays a titled list of weeks with selectable days. It can be
48  * configured to display the week number, start the week on a given day, show a
49  * reduced number of days, or display an arbitrary number of weeks at a time. By
50  * overriding methods and changing variables this fragment can be customized to
51  * easily display a month selection component in a given style.
52  *
53  */
54 open class SimpleDayPickerFragment(initialTime: Long) : ListFragment(), OnScrollListener {
55     protected var WEEK_MIN_VISIBLE_HEIGHT = 12
56     protected var BOTTOM_BUFFER = 20
57     protected var mSaturdayColor = 0
58     protected var mSundayColor = 0
59     protected var mDayNameColor = 0
60 
61     // You can override these numbers to get a different appearance
62     @JvmField protected var mNumWeeks = 6
63     @JvmField protected var mShowWeekNumber = false
64     @JvmField protected var mDaysPerWeek = 7
65 
66     // These affect the scroll speed and feel
67     protected var mFriction = 1.0f
68     @JvmField protected var mContext: Context? = null
69     @JvmField protected var mHandler: Handler = Handler()
70     protected var mMinimumFlingVelocity = 0f
71 
72     // highlighted time
73     @JvmField protected var mSelectedDay: Time = Time()
74     @JvmField protected var mAdapter: SimpleWeeksAdapter? = null
75     @JvmField protected var mListView: ListView? = null
76     @JvmField protected var mDayNamesHeader: ViewGroup? = null
77     @JvmField protected var mDayLabels: Array<String?> = arrayOfNulls(7)
78 
79     // disposable variable used for time calculations
80     @JvmField protected var mTempTime: Time = Time()
81 
82     // When the week starts; numbered like Time.<WEEKDAY> (e.g. SUNDAY=0).
83     @JvmField protected var mFirstDayOfWeek = 0
84 
85     // The first day of the focus month
86     @JvmField protected var mFirstDayOfMonth: Time = Time()
87 
88     // The first day that is visible in the view
89     @JvmField protected var mFirstVisibleDay: Time = Time()
90 
91     // The name of the month to display
92     protected var mMonthName: TextView? = null
93 
94     // The last name announced by accessibility
95     protected var mPrevMonthName: CharSequence? = null
96 
97     // which month should be displayed/highlighted [0-11]
98     protected var mCurrentMonthDisplayed = 0
99 
100     // used for tracking during a scroll
101     protected var mPreviousScrollPosition: Long = 0
102 
103     // used for tracking which direction the view is scrolling
104     protected var mIsScrollingUp = false
105 
106     // used for tracking what state listview is in
107     protected var mPreviousScrollState: Int = OnScrollListener.SCROLL_STATE_IDLE
108 
109     // used for tracking what state listview is in
110     protected var mCurrentScrollState: Int = OnScrollListener.SCROLL_STATE_IDLE
111 
112     // This causes an update of the view at midnight
113     @JvmField protected var mTodayUpdater: Runnable = object : Runnable {
114         @Override
runnull115         override fun run() {
116             val midnight = Time(mFirstVisibleDay.timezone)
117             midnight.setToNow()
118             val currentMillis: Long = midnight.toMillis(true)
119             midnight.hour = 0
120             midnight.minute = 0
121             midnight.second = 0
122             midnight.monthDay++
123             val millisToMidnight: Long = midnight.normalize(true) - currentMillis
124             mHandler.postDelayed(this, millisToMidnight)
125             if (mAdapter != null) {
126                 mAdapter?.notifyDataSetChanged()
127             }
128         }
129     }
130 
131     // This allows us to update our position when a day is tapped
132     @JvmField protected var mObserver: DataSetObserver = object : DataSetObserver() {
133         @Override
onChangednull134         override fun onChanged() {
135             val day: Time? = mAdapter!!.getSelectedDay()
136             if (day!!.year !== mSelectedDay.year || day!!.yearDay !== mSelectedDay.yearDay) {
137                 goTo(day!!.toMillis(true), true, true, false)
138             }
139         }
140     }
141 
142     @Override
onAttachnull143     override fun onAttach(activity: Activity) {
144         super.onAttach(activity)
145         mContext = activity
146         val tz: String = Time.getCurrentTimezone()
147         val viewConfig: ViewConfiguration = ViewConfiguration.get(activity)
148         mMinimumFlingVelocity = (viewConfig.getScaledMinimumFlingVelocity()).toFloat()
149 
150         // Ensure we're in the correct time zone
151         mSelectedDay.switchTimezone(tz)
152         mSelectedDay.normalize(true)
153         mFirstDayOfMonth.timezone = tz
154         mFirstDayOfMonth.normalize(true)
155         mFirstVisibleDay.timezone = tz
156         mFirstVisibleDay.normalize(true)
157         mTempTime.timezone = tz
158         val res: Resources = activity.getResources()
159         mSaturdayColor = res.getColor(R.color.month_saturday)
160         mSundayColor = res.getColor(R.color.month_sunday)
161         mDayNameColor = res.getColor(R.color.month_day_names_color)
162 
163         // Adjust sizes for screen density
164         if (mScale == 0f) {
165             mScale = activity.getResources().getDisplayMetrics().density
166             if (mScale != 1f) {
167                 WEEK_MIN_VISIBLE_HEIGHT *= mScale.toInt()
168                 BOTTOM_BUFFER *= mScale.toInt()
169                 LIST_TOP_OFFSET *= mScale.toInt()
170             }
171         }
172         setUpAdapter()
173         setListAdapter(mAdapter)
174     }
175 
176     /**
177      * Creates a new adapter if necessary and sets up its parameters. Override
178      * this method to provide a custom adapter.
179      */
setUpAdapternull180     protected open fun setUpAdapter() {
181         val weekParams = HashMap<String?, Int?>()
182         weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_NUM_WEEKS, mNumWeeks)
183         weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_SHOW_WEEK, if (mShowWeekNumber) 1 else 0)
184         weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_WEEK_START, mFirstDayOfWeek)
185         weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_JULIAN_DAY,
186                 Time.getJulianDay(mSelectedDay.toMillis(false), mSelectedDay.gmtoff))
187         if (mAdapter == null) {
188             mAdapter = SimpleWeeksAdapter(getActivity(), weekParams)
189             mAdapter?.registerDataSetObserver(mObserver)
190         } else {
191             mAdapter?.updateParams(weekParams)
192         }
193         // refresh the view with the new parameters
194         mAdapter?.notifyDataSetChanged()
195     }
196 
197     @Override
onCreatenull198     override fun onCreate(savedInstanceState: Bundle?) {
199         super.onCreate(savedInstanceState)
200     }
201 
202     @Override
onActivityCreatednull203     override fun onActivityCreated(savedInstanceState: Bundle?) {
204         super.onActivityCreated(savedInstanceState)
205         setUpListView()
206         setUpHeader()
207         mMonthName = getView()?.findViewById(R.id.month_name) as? TextView
208         val child = mListView?.getChildAt(0) as? SimpleWeekView
209         if (child == null) {
210             return
211         }
212         val julianDay: Int = child.getFirstJulianDay()
213         mFirstVisibleDay.setJulianDay(julianDay)
214         // set the title to the month of the second week
215         mTempTime.setJulianDay(julianDay + DAYS_PER_WEEK)
216         setMonthDisplayed(mTempTime, true)
217     }
218 
219     /**
220      * Sets up the strings to be used by the header. Override this method to use
221      * different strings or modify the view params.
222      */
setUpHeadernull223     protected open fun setUpHeader() {
224         mDayLabels = arrayOfNulls(7)
225         for (i in Calendar.SUNDAY..Calendar.SATURDAY) {
226             mDayLabels[i - Calendar.SUNDAY] = DateUtils.getDayOfWeekString(i,
227                     DateUtils.LENGTH_SHORTEST).toUpperCase()
228         }
229     }
230 
231     /**
232      * Sets all the required fields for the list view. Override this method to
233      * set a different list view behavior.
234      */
setUpListViewnull235     protected fun setUpListView() {
236         // Configure the listview
237         mListView = getListView()
238         // Transparent background on scroll
239         mListView?.setCacheColorHint(0)
240         // No dividers
241         mListView?.setDivider(null)
242         // Items are clickable
243         mListView?.setItemsCanFocus(true)
244         // The thumb gets in the way, so disable it
245         mListView?.setFastScrollEnabled(false)
246         mListView?.setVerticalScrollBarEnabled(false)
247         mListView?.setOnScrollListener(this)
248         mListView?.setFadingEdgeLength(0)
249         // Make the scrolling behavior nicer
250         mListView?.setFriction(ViewConfiguration.getScrollFriction() * mFriction)
251     }
252 
253     @Override
onResumenull254     override fun onResume() {
255         super.onResume()
256         setUpAdapter()
257         doResumeUpdates()
258     }
259 
260     @Override
onPausenull261     override fun onPause() {
262         super.onPause()
263         mHandler.removeCallbacks(mTodayUpdater)
264     }
265 
266     @Override
onSaveInstanceStatenull267     override fun onSaveInstanceState(outState: Bundle) {
268         outState.putLong(KEY_CURRENT_TIME, mSelectedDay.toMillis(true))
269     }
270 
271     /**
272      * Updates the user preference fields. Override this to use a different
273      * preference space.
274      */
doResumeUpdatesnull275     protected open fun doResumeUpdates() {
276         // Get default week start based on locale, subtracting one for use with android Time.
277         val cal: Calendar = Calendar.getInstance(Locale.getDefault())
278         mFirstDayOfWeek = cal.getFirstDayOfWeek() - 1
279         mShowWeekNumber = false
280         updateHeader()
281         goTo(mSelectedDay.toMillis(true), false, false, false)
282         mAdapter?.setSelectedDay(mSelectedDay)
283         mTodayUpdater.run()
284     }
285 
286     /**
287      * Fixes the day names header to provide correct spacing and updates the
288      * label text. Override this to set up a custom header.
289      */
updateHeadernull290     protected fun updateHeader() {
291         var label: TextView = mDayNamesHeader!!.findViewById(R.id.wk_label) as TextView
292         if (mShowWeekNumber) {
293             label.setVisibility(View.VISIBLE)
294         } else {
295             label.setVisibility(View.GONE)
296         }
297         val offset = mFirstDayOfWeek - 1
298         for (i in 1..7) {
299             label = mDayNamesHeader!!.getChildAt(i) as TextView
300             if (i < mDaysPerWeek + 1) {
301                 val position = (offset + i) % 7
302                 label.setText(mDayLabels[position])
303                 label.setVisibility(View.VISIBLE)
304                 if (position == Time.SATURDAY) {
305                     label.setTextColor(mSaturdayColor)
306                 } else if (position == Time.SUNDAY) {
307                     label.setTextColor(mSundayColor)
308                 } else {
309                     label.setTextColor(mDayNameColor)
310                 }
311             } else {
312                 label.setVisibility(View.GONE)
313             }
314         }
315         mDayNamesHeader?.invalidate()
316     }
317 
318     @Override
onCreateViewnull319     override fun onCreateView(
320         inflater: LayoutInflater,
321         container: ViewGroup?,
322         savedInstanceState: Bundle?
323     ): View {
324         val v: View = inflater.inflate(R.layout.month_by_week,
325                 container, false)
326         mDayNamesHeader = v.findViewById(R.id.day_names) as ViewGroup
327         return v
328     }
329 
330     /**
331      * Returns the UTC millis since epoch representation of the currently
332      * selected time.
333      *
334      * @return
335      */
336     val selectedTime: Long
337         get() = mSelectedDay.toMillis(true)
338 
339     /**
340      * This moves to the specified time in the view. If the time is not already
341      * in range it will move the list so that the first of the month containing
342      * the time is at the top of the view. If the new time is already in view
343      * the list will not be scrolled unless forceScroll is true. This time may
344      * optionally be highlighted as selected as well.
345      *
346      * @param time The time to move to
347      * @param animate Whether to scroll to the given time or just redraw at the
348      * new location
349      * @param setSelected Whether to set the given time as selected
350      * @param forceScroll Whether to recenter even if the time is already
351      * visible
352      * @return Whether or not the view animated to the new location
353      */
goTonull354     fun goTo(time: Long, animate: Boolean, setSelected: Boolean, forceScroll: Boolean): Boolean {
355         if (time == -1L) {
356             Log.e(TAG, "time is invalid")
357             return false
358         }
359 
360         // Set the selected day
361         if (setSelected) {
362             mSelectedDay.set(time)
363             mSelectedDay.normalize(true)
364         }
365 
366         // If this view isn't returned yet we won't be able to load the lists
367         // current position, so return after setting the selected day.
368         if (!isResumed()) {
369             if (Log.isLoggable(TAG, Log.DEBUG)) {
370                 Log.d(TAG, "We're not visible yet")
371             }
372             return false
373         }
374         mTempTime.set(time)
375         var millis: Long = mTempTime.normalize(true)
376         // Get the week we're going to
377         // TODO push Util function into Calendar public api.
378         var position: Int = Utils.getWeeksSinceEpochFromJulianDay(
379                 Time.getJulianDay(millis, mTempTime.gmtoff), mFirstDayOfWeek)
380         var child: View?
381         var i = 0
382         var top = 0
383         // Find a child that's completely in the view
384         do {
385             child = mListView?.getChildAt(i++)
386             if (child == null) {
387                 break
388             }
389             top = child.getTop()
390             if (Log.isLoggable(TAG, Log.DEBUG)) {
391                 Log.d(TAG, "child at " + (i - 1) + " has top " + top)
392             }
393         } while (top < 0)
394 
395         // Compute the first and last position visible
396         val firstPosition: Int
397         firstPosition = if (child != null) {
398             mListView!!.getPositionForView(child)
399         } else {
400             0
401         }
402         var lastPosition = firstPosition + mNumWeeks - 1
403         if (top > BOTTOM_BUFFER) {
404             lastPosition--
405         }
406         if (setSelected) {
407             mAdapter?.setSelectedDay(mSelectedDay)
408         }
409         if (Log.isLoggable(TAG, Log.DEBUG)) {
410             Log.d(TAG, "GoTo position $position")
411         }
412         // Check if the selected day is now outside of our visible range
413         // and if so scroll to the month that contains it
414         if (position < firstPosition || position > lastPosition || forceScroll) {
415             mFirstDayOfMonth.set(mTempTime)
416             mFirstDayOfMonth.monthDay = 1
417             millis = mFirstDayOfMonth.normalize(true)
418             setMonthDisplayed(mFirstDayOfMonth, true)
419             position = Utils.getWeeksSinceEpochFromJulianDay(
420                     Time.getJulianDay(millis, mFirstDayOfMonth.gmtoff), mFirstDayOfWeek)
421             mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING
422             if (animate) {
423                 mListView?.smoothScrollToPositionFromTop(
424                         position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION)
425                 return true
426             } else {
427                 mListView?.setSelectionFromTop(position, LIST_TOP_OFFSET)
428                 // Perform any after scroll operations that are needed
429                 onScrollStateChanged(mListView, OnScrollListener.SCROLL_STATE_IDLE)
430             }
431         } else if (setSelected) {
432             // Otherwise just set the selection
433             setMonthDisplayed(mSelectedDay, true)
434         }
435         return false
436     }
437 
438     /**
439      * Updates the title and selected month if the view has moved to a new
440      * month.
441      */
442     @Override
onScrollnull443     override fun onScroll(
444         view: AbsListView,
445         firstVisibleItem: Int,
446         visibleItemCount: Int,
447         totalItemCount: Int
448     ) {
449         val child = view.getChildAt(0) as? SimpleWeekView
450         if (child == null) {
451             return
452         }
453 
454         // Figure out where we are
455         val currScroll: Long = (view.getFirstVisiblePosition() * child.getHeight() -
456                                 child.getBottom()).toLong()
457         mFirstVisibleDay.setJulianDay(child.getFirstJulianDay())
458 
459         // If we have moved since our last call update the direction
460         mIsScrollingUp = if (currScroll < mPreviousScrollPosition) {
461             true
462         } else if (currScroll > mPreviousScrollPosition) {
463             false
464         } else {
465             return
466         }
467         mPreviousScrollPosition = currScroll
468         mPreviousScrollState = mCurrentScrollState
469         updateMonthHighlight(mListView as? AbsListView)
470     }
471 
472     /**
473      * Figures out if the month being shown has changed and updates the
474      * highlight if needed
475      *
476      * @param view The ListView containing the weeks
477      */
updateMonthHighlightnull478     private fun updateMonthHighlight(view: AbsListView?) {
479         var child = view?.getChildAt(0) as? SimpleWeekView
480         if (child == null) {
481             return
482         }
483 
484         // Figure out where we are
485         val offset = if (child.getBottom() < WEEK_MIN_VISIBLE_HEIGHT) 1 else 0
486         // Use some hysteresis for checking which month to highlight. This
487         // causes the month to transition when two full weeks of a month are
488         // visible.
489         child = view?.getChildAt(SCROLL_HYST_WEEKS + offset) as? SimpleWeekView
490         if (child == null) {
491             return
492         }
493 
494         // Find out which month we're moving into
495         val month: Int
496         month = if (mIsScrollingUp) {
497             child.getFirstMonth()
498         } else {
499             child.getLastMonth()
500         }
501 
502         // And how it relates to our current highlighted month
503         val monthDiff: Int
504         monthDiff = if (mCurrentMonthDisplayed == 11 && month == 0) {
505             1
506         } else if (mCurrentMonthDisplayed == 0 && month == 11) {
507             -1
508         } else {
509             month - mCurrentMonthDisplayed
510         }
511 
512         // Only switch months if we're scrolling away from the currently
513         // selected month
514         if (monthDiff != 0) {
515             var julianDay: Int = child.getFirstJulianDay()
516             if (mIsScrollingUp) {
517                 // Takes the start of the week
518             } else {
519                 // Takes the start of the following week
520                 julianDay += DAYS_PER_WEEK
521             }
522             mTempTime.setJulianDay(julianDay)
523             setMonthDisplayed(mTempTime, false)
524         }
525     }
526 
527     /**
528      * Sets the month displayed at the top of this view based on time. Override
529      * to add custom events when the title is changed.
530      *
531      * @param time A day in the new focus month.
532      * @param updateHighlight TODO(epastern):
533      */
setMonthDisplayednull534     protected open fun setMonthDisplayed(time: Time, updateHighlight: Boolean) {
535         val oldMonth: CharSequence = mMonthName!!.getText()
536         mMonthName?.setText(Utils.formatMonthYear(mContext, time))
537         mMonthName?.invalidate()
538         if (!TextUtils.equals(oldMonth, mMonthName?.getText())) {
539             mMonthName?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
540         }
541         mCurrentMonthDisplayed = time.month
542         if (updateHighlight) {
543             mAdapter?.updateFocusMonth(mCurrentMonthDisplayed)
544         }
545     }
546 
547     @Override
onScrollStateChangednull548     override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {
549         // use a post to prevent re-entering onScrollStateChanged before it
550         // exits
551         mScrollStateChangedRunnable.doScrollStateChange(view, scrollState)
552     }
553 
554     @JvmField protected var mScrollStateChangedRunnable: ScrollStateRunnable = ScrollStateRunnable()
555 
556     protected inner class ScrollStateRunnable : Runnable {
557         private var mNewState = 0
558 
559         /**
560          * Sets up the runnable with a short delay in case the scroll state
561          * immediately changes again.
562          *
563          * @param view The list view that changed state
564          * @param scrollState The new state it changed to
565          */
doScrollStateChangenull566         fun doScrollStateChange(view: AbsListView?, scrollState: Int) {
567             mHandler.removeCallbacks(this)
568             mNewState = scrollState
569             mHandler.postDelayed(this, SCROLL_CHANGE_DELAY.toLong())
570         }
571 
runnull572         override fun run() {
573             mCurrentScrollState = mNewState
574             if (Log.isLoggable(TAG, Log.DEBUG)) {
575                 Log.d(TAG,
576                         "new scroll state: $mNewState old state: $mPreviousScrollState")
577             }
578             // Fix the position after a scroll or a fling ends
579             if (mNewState == OnScrollListener.SCROLL_STATE_IDLE &&
580                     mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE) {
581                 mPreviousScrollState = mNewState
582                 mAdapter?.updateFocusMonth(mCurrentMonthDisplayed)
583             } else {
584                 mPreviousScrollState = mNewState
585             }
586         }
587     }
588 
589     companion object {
590         private const val TAG = "MonthFragment"
591         private const val KEY_CURRENT_TIME = "current_time"
592 
593         // Affects when the month selection will change while scrolling up
594         protected const val SCROLL_HYST_WEEKS = 2
595 
596         // How long the GoTo fling animation should last
597         @JvmStatic protected val GOTO_SCROLL_DURATION = 500
598 
599         // How long to wait after receiving an onScrollStateChanged notification
600         // before acting on it
601         protected const val SCROLL_CHANGE_DELAY = 40
602 
603         // The number of days to display in each week
604         const val DAYS_PER_WEEK = 7
605 
606         // The size of the month name displayed above the week list
607         protected const val MINI_MONTH_NAME_TEXT_SIZE = 18
608         var LIST_TOP_OFFSET = -1 // so that the top line will be under the separator
609         private var mScale = 0f
610     }
611 
612     init {
613         goTo(initialTime, false, true, true)
614         mHandler = Handler()
615     }
616 }