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