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 }