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