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
17package com.android.calendar.month;
18
19import com.android.calendar.R;
20import com.android.calendar.Utils;
21
22import android.app.Activity;
23import android.app.ListFragment;
24import android.content.Context;
25import android.content.res.Resources;
26import android.database.DataSetObserver;
27import android.os.Bundle;
28import android.os.Handler;
29import android.text.TextUtils;
30import android.text.format.DateUtils;
31import android.text.format.Time;
32import android.util.Log;
33import android.view.LayoutInflater;
34import android.view.View;
35import android.view.ViewConfiguration;
36import android.view.ViewGroup;
37import android.view.accessibility.AccessibilityEvent;
38import android.widget.AbsListView;
39import android.widget.AbsListView.OnScrollListener;
40import android.widget.ListView;
41import android.widget.TextView;
42
43import java.util.Calendar;
44import java.util.HashMap;
45import 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 */
56public 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
156    public SimpleDayPickerFragment(long initialTime) {
157        goTo(initialTime, false, true, true);
158        mHandler = new Handler();
159    }
160
161    @Override
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     */
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
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
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     */
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     */
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
279    public void onResume() {
280        super.onResume();
281        setUpAdapter();
282        doResumeUpdates();
283    }
284
285    @Override
286    public void onPause() {
287        super.onPause();
288        mHandler.removeCallbacks(mTodayUpdater);
289    }
290
291    @Override
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     */
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     */
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
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     */
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     */
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
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     */
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