Home | History | Annotate | Download | only in deskclock
      1 /*
      2  * Copyright (C) 2015 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.deskclock;
     18 
     19 import android.app.LoaderManager;
     20 import android.content.Context;
     21 import android.content.Intent;
     22 import android.content.Loader;
     23 import android.database.Cursor;
     24 import android.graphics.drawable.Drawable;
     25 import android.os.Bundle;
     26 import android.os.SystemClock;
     27 import android.support.annotation.NonNull;
     28 import android.support.design.widget.Snackbar;
     29 import android.support.v7.widget.LinearLayoutManager;
     30 import android.support.v7.widget.RecyclerView;
     31 import android.view.LayoutInflater;
     32 import android.view.View;
     33 import android.view.ViewGroup;
     34 import android.widget.Button;
     35 import android.widget.ImageView;
     36 import android.widget.TextView;
     37 
     38 import com.android.deskclock.alarms.AlarmTimeClickHandler;
     39 import com.android.deskclock.alarms.AlarmUpdateHandler;
     40 import com.android.deskclock.alarms.ScrollHandler;
     41 import com.android.deskclock.alarms.TimePickerDialogFragment;
     42 import com.android.deskclock.alarms.dataadapter.AlarmItemHolder;
     43 import com.android.deskclock.alarms.dataadapter.CollapsedAlarmViewHolder;
     44 import com.android.deskclock.alarms.dataadapter.ExpandedAlarmViewHolder;
     45 import com.android.deskclock.provider.Alarm;
     46 import com.android.deskclock.provider.AlarmInstance;
     47 import com.android.deskclock.uidata.UiDataModel;
     48 import com.android.deskclock.widget.EmptyViewController;
     49 import com.android.deskclock.widget.toast.SnackbarManager;
     50 import com.android.deskclock.widget.toast.ToastManager;
     51 
     52 import java.util.ArrayList;
     53 import java.util.List;
     54 
     55 import static com.android.deskclock.uidata.UiDataModel.Tab.ALARMS;
     56 
     57 /**
     58  * A fragment that displays a list of alarm time and allows interaction with them.
     59  */
     60 public final class AlarmClockFragment extends DeskClockFragment implements
     61         LoaderManager.LoaderCallbacks<Cursor>,
     62         ScrollHandler,
     63         TimePickerDialogFragment.OnTimeSetListener {
     64 
     65     // This extra is used when receiving an intent to create an alarm, but no alarm details
     66     // have been passed in, so the alarm page should start the process of creating a new alarm.
     67     public static final String ALARM_CREATE_NEW_INTENT_EXTRA = "deskclock.create.new";
     68 
     69     // This extra is used when receiving an intent to scroll to specific alarm. If alarm
     70     // can not be found, and toast message will pop up that the alarm has be deleted.
     71     public static final String SCROLL_TO_ALARM_INTENT_EXTRA = "deskclock.scroll.to.alarm";
     72 
     73     private static final String KEY_EXPANDED_ID = "expandedId";
     74 
     75     // Updates "Today/Tomorrow" in the UI when midnight passes.
     76     private final Runnable mMidnightUpdater = new MidnightRunnable();
     77 
     78     // Views
     79     private ViewGroup mMainLayout;
     80     private RecyclerView mRecyclerView;
     81 
     82     // Data
     83     private Loader mCursorLoader;
     84     private long mScrollToAlarmId = Alarm.INVALID_ID;
     85     private long mExpandedAlarmId = Alarm.INVALID_ID;
     86     private long mCurrentUpdateToken;
     87 
     88     // Controllers
     89     private ItemAdapter<AlarmItemHolder> mItemAdapter;
     90     private AlarmUpdateHandler mAlarmUpdateHandler;
     91     private EmptyViewController mEmptyViewController;
     92     private AlarmTimeClickHandler mAlarmTimeClickHandler;
     93     private LinearLayoutManager mLayoutManager;
     94 
     95     /**
     96      * The public no-arg constructor required by all fragments.
     97      */
     98     public AlarmClockFragment() {
     99         super(ALARMS);
    100     }
    101 
    102     @Override
    103     public void onCreate(Bundle savedState) {
    104         super.onCreate(savedState);
    105         mCursorLoader = getLoaderManager().initLoader(0, null, this);
    106         if (savedState != null) {
    107             mExpandedAlarmId = savedState.getLong(KEY_EXPANDED_ID, Alarm.INVALID_ID);
    108         }
    109     }
    110 
    111     @Override
    112     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
    113         // Inflate the layout for this fragment
    114         final View v = inflater.inflate(R.layout.alarm_clock, container, false);
    115         final Context context = getActivity();
    116 
    117         mRecyclerView = (RecyclerView) v.findViewById(R.id.alarms_recycler_view);
    118         mLayoutManager = new LinearLayoutManager(context) {
    119             @Override
    120             protected int getExtraLayoutSpace(RecyclerView.State state) {
    121                 final int extraSpace = super.getExtraLayoutSpace(state);
    122                 if (state.willRunPredictiveAnimations()) {
    123                     return Math.max(getHeight(), extraSpace);
    124                 }
    125                 return extraSpace;
    126             }
    127         };
    128         mRecyclerView.setLayoutManager(mLayoutManager);
    129         mMainLayout = (ViewGroup) v.findViewById(R.id.main);
    130         mAlarmUpdateHandler = new AlarmUpdateHandler(context, this, mMainLayout);
    131         final TextView emptyView = (TextView) v.findViewById(R.id.alarms_empty_view);
    132         final Drawable noAlarms = Utils.getVectorDrawable(context, R.drawable.ic_noalarms);
    133         emptyView.setCompoundDrawablesWithIntrinsicBounds(null, noAlarms, null, null);
    134         mEmptyViewController = new EmptyViewController(mMainLayout, mRecyclerView, emptyView);
    135         mAlarmTimeClickHandler = new AlarmTimeClickHandler(this, savedState, mAlarmUpdateHandler,
    136                 this);
    137 
    138         mItemAdapter = new ItemAdapter<>();
    139         mItemAdapter.setHasStableIds();
    140         mItemAdapter.withViewTypes(new CollapsedAlarmViewHolder.Factory(inflater),
    141                 null, CollapsedAlarmViewHolder.VIEW_TYPE);
    142         mItemAdapter.withViewTypes(new ExpandedAlarmViewHolder.Factory(context),
    143                 null, ExpandedAlarmViewHolder.VIEW_TYPE);
    144         mItemAdapter.setOnItemChangedListener(new ItemAdapter.OnItemChangedListener() {
    145             @Override
    146             public void onItemChanged(ItemAdapter.ItemHolder<?> holder) {
    147                 if (((AlarmItemHolder) holder).isExpanded()) {
    148                     if (mExpandedAlarmId != holder.itemId) {
    149                         // Collapse the prior expanded alarm.
    150                         final AlarmItemHolder aih = mItemAdapter.findItemById(mExpandedAlarmId);
    151                         if (aih != null) {
    152                             aih.collapse();
    153                         }
    154                         // Record the freshly expanded alarm.
    155                         mExpandedAlarmId = holder.itemId;
    156                         final RecyclerView.ViewHolder viewHolder =
    157                                 mRecyclerView.findViewHolderForItemId(mExpandedAlarmId);
    158                         if (viewHolder != null) {
    159                             smoothScrollTo(viewHolder.getAdapterPosition());
    160                         }
    161                     }
    162                 } else if (mExpandedAlarmId == holder.itemId) {
    163                     // The expanded alarm is now collapsed so update the tracking id.
    164                     mExpandedAlarmId = Alarm.INVALID_ID;
    165                 }
    166             }
    167 
    168             @Override
    169             public void onItemChanged(ItemAdapter.ItemHolder<?> holder, Object payload) {
    170                 /* No additional work to do */
    171             }
    172         });
    173         final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher();
    174         mRecyclerView.addOnLayoutChangeListener(scrollPositionWatcher);
    175         mRecyclerView.addOnScrollListener(scrollPositionWatcher);
    176         mRecyclerView.setAdapter(mItemAdapter);
    177         final ItemAnimator itemAnimator = new ItemAnimator();
    178         itemAnimator.setChangeDuration(300L);
    179         itemAnimator.setMoveDuration(300L);
    180         mRecyclerView.setItemAnimator(itemAnimator);
    181         return v;
    182     }
    183 
    184     @Override
    185     public void onStart() {
    186         super.onStart();
    187 
    188         if (!isTabSelected()) {
    189             TimePickerDialogFragment.removeTimeEditDialog(getFragmentManager());
    190         }
    191     }
    192 
    193     @Override
    194     public void onResume() {
    195         super.onResume();
    196 
    197         // Schedule a runnable to update the "Today/Tomorrow" values displayed for non-repeating
    198         // alarms when midnight passes.
    199         UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater, 100);
    200 
    201         // Check if another app asked us to create a blank new alarm.
    202         final Intent intent = getActivity().getIntent();
    203         if (intent == null) {
    204             return;
    205         }
    206 
    207         if (intent.hasExtra(ALARM_CREATE_NEW_INTENT_EXTRA)) {
    208             UiDataModel.getUiDataModel().setSelectedTab(ALARMS);
    209             if (intent.getBooleanExtra(ALARM_CREATE_NEW_INTENT_EXTRA, false)) {
    210                 // An external app asked us to create a blank alarm.
    211                 startCreatingAlarm();
    212             }
    213 
    214             // Remove the CREATE_NEW extra now that we've processed it.
    215             intent.removeExtra(ALARM_CREATE_NEW_INTENT_EXTRA);
    216         } else if (intent.hasExtra(SCROLL_TO_ALARM_INTENT_EXTRA)) {
    217             UiDataModel.getUiDataModel().setSelectedTab(ALARMS);
    218 
    219             long alarmId = intent.getLongExtra(SCROLL_TO_ALARM_INTENT_EXTRA, Alarm.INVALID_ID);
    220             if (alarmId != Alarm.INVALID_ID) {
    221                 setSmoothScrollStableId(alarmId);
    222                 if (mCursorLoader != null && mCursorLoader.isStarted()) {
    223                     // We need to force a reload here to make sure we have the latest view
    224                     // of the data to scroll to.
    225                     mCursorLoader.forceLoad();
    226                 }
    227             }
    228 
    229             // Remove the SCROLL_TO_ALARM extra now that we've processed it.
    230             intent.removeExtra(SCROLL_TO_ALARM_INTENT_EXTRA);
    231         }
    232     }
    233 
    234     @Override
    235     public void onPause() {
    236         super.onPause();
    237         UiDataModel.getUiDataModel().removePeriodicCallback(mMidnightUpdater);
    238 
    239         // When the user places the app in the background by pressing "home",
    240         // dismiss the toast bar. However, since there is no way to determine if
    241         // home was pressed, just dismiss any existing toast bar when restarting
    242         // the app.
    243         mAlarmUpdateHandler.hideUndoBar();
    244     }
    245 
    246     @Override
    247     public void smoothScrollTo(int position) {
    248         mLayoutManager.scrollToPositionWithOffset(position, 0);
    249     }
    250 
    251     @Override
    252     public void onSaveInstanceState(Bundle outState) {
    253         super.onSaveInstanceState(outState);
    254         mAlarmTimeClickHandler.saveInstance(outState);
    255         outState.putLong(KEY_EXPANDED_ID, mExpandedAlarmId);
    256     }
    257 
    258     @Override
    259     public void onDestroy() {
    260         super.onDestroy();
    261         ToastManager.cancelToast();
    262     }
    263 
    264     public void setLabel(Alarm alarm, String label) {
    265         alarm.label = label;
    266         mAlarmUpdateHandler.asyncUpdateAlarm(alarm, false, true);
    267     }
    268 
    269     @Override
    270     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
    271         return Alarm.getAlarmsCursorLoader(getActivity());
    272     }
    273 
    274     @Override
    275     public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor data) {
    276         final List<AlarmItemHolder> itemHolders = new ArrayList<>(data.getCount());
    277         for (data.moveToFirst(); !data.isAfterLast(); data.moveToNext()) {
    278             final Alarm alarm = new Alarm(data);
    279             final AlarmInstance alarmInstance = alarm.canPreemptivelyDismiss()
    280                     ? new AlarmInstance(data, true /* joinedTable */) : null;
    281             final AlarmItemHolder itemHolder =
    282                     new AlarmItemHolder(alarm, alarmInstance, mAlarmTimeClickHandler);
    283             itemHolders.add(itemHolder);
    284         }
    285         setAdapterItems(itemHolders, SystemClock.elapsedRealtime());
    286     }
    287 
    288     /**
    289      * Updates the adapters items, deferring the update until the current animation is finished or
    290      * if no animation is running then the listener will be automatically be invoked immediately.
    291      *
    292      * @param items       the new list of {@link AlarmItemHolder} to use
    293      * @param updateToken a monotonically increasing value used to preserve ordering of deferred
    294      *                    updates
    295      */
    296     private void setAdapterItems(final List<AlarmItemHolder> items, final long updateToken) {
    297         if (updateToken < mCurrentUpdateToken) {
    298             LogUtils.v("Ignoring adapter update: %d < %d", updateToken, mCurrentUpdateToken);
    299             return;
    300         }
    301 
    302         if (mRecyclerView.getItemAnimator().isRunning()) {
    303             // RecyclerView is currently animating -> defer update.
    304             mRecyclerView.getItemAnimator().isRunning(
    305                     new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
    306                 @Override
    307                 public void onAnimationsFinished() {
    308                     setAdapterItems(items, updateToken);
    309                 }
    310             });
    311         } else if (mRecyclerView.isComputingLayout()) {
    312             // RecyclerView is currently computing a layout -> defer update.
    313             mRecyclerView.post(new Runnable() {
    314                 @Override
    315                 public void run() {
    316                     setAdapterItems(items, updateToken);
    317                 }
    318             });
    319         } else {
    320             mCurrentUpdateToken = updateToken;
    321             mItemAdapter.setItems(items);
    322 
    323             // Show or hide the empty view as appropriate.
    324             final boolean noAlarms = items.isEmpty();
    325             mEmptyViewController.setEmpty(noAlarms);
    326             if (noAlarms) {
    327                 // Ensure the drop shadow is hidden when no alarms exist.
    328                 setTabScrolledToTop(true);
    329             }
    330 
    331             // Expand the correct alarm.
    332             if (mExpandedAlarmId != Alarm.INVALID_ID) {
    333                 final AlarmItemHolder aih = mItemAdapter.findItemById(mExpandedAlarmId);
    334                 if (aih != null) {
    335                     mAlarmTimeClickHandler.setSelectedAlarm(aih.item);
    336                     aih.expand();
    337                 } else {
    338                     mAlarmTimeClickHandler.setSelectedAlarm(null);
    339                     mExpandedAlarmId = Alarm.INVALID_ID;
    340                 }
    341             }
    342 
    343             // Scroll to the selected alarm.
    344             if (mScrollToAlarmId != Alarm.INVALID_ID) {
    345                 scrollToAlarm(mScrollToAlarmId);
    346                 setSmoothScrollStableId(Alarm.INVALID_ID);
    347             }
    348         }
    349     }
    350 
    351     /**
    352      * @param alarmId identifies the alarm to be displayed
    353      */
    354     private void scrollToAlarm(long alarmId) {
    355         final int alarmCount = mItemAdapter.getItemCount();
    356         int alarmPosition = -1;
    357         for (int i = 0; i < alarmCount; i++) {
    358             long id = mItemAdapter.getItemId(i);
    359             if (id == alarmId) {
    360                 alarmPosition = i;
    361                 break;
    362             }
    363         }
    364 
    365         if (alarmPosition >= 0) {
    366             mItemAdapter.findItemById(alarmId).expand();
    367             smoothScrollTo(alarmPosition);
    368         } else {
    369             // Trying to display a deleted alarm should only happen from a missed notification for
    370             // an alarm that has been marked deleted after use.
    371             SnackbarManager.show(Snackbar.make(mMainLayout, R.string
    372                     .missed_alarm_has_been_deleted, Snackbar.LENGTH_LONG));
    373         }
    374     }
    375 
    376     @Override
    377     public void onLoaderReset(Loader<Cursor> cursorLoader) {
    378     }
    379 
    380     @Override
    381     public void setSmoothScrollStableId(long stableId) {
    382         mScrollToAlarmId = stableId;
    383     }
    384 
    385     @Override
    386     public void onFabClick(@NonNull ImageView fab) {
    387         mAlarmUpdateHandler.hideUndoBar();
    388         startCreatingAlarm();
    389     }
    390 
    391     @Override
    392     public void onUpdateFab(@NonNull ImageView fab) {
    393         fab.setVisibility(View.VISIBLE);
    394         fab.setImageResource(R.drawable.ic_add_white_24dp);
    395         fab.setContentDescription(fab.getResources().getString(R.string.button_alarms));
    396     }
    397 
    398     @Override
    399     public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) {
    400         left.setVisibility(View.INVISIBLE);
    401         right.setVisibility(View.INVISIBLE);
    402     }
    403 
    404     private void startCreatingAlarm() {
    405         // Clear the currently selected alarm.
    406         mAlarmTimeClickHandler.setSelectedAlarm(null);
    407         TimePickerDialogFragment.show(this);
    408     }
    409 
    410     @Override
    411     public void onTimeSet(TimePickerDialogFragment fragment, int hourOfDay, int minute) {
    412         mAlarmTimeClickHandler.onTimeSet(hourOfDay, minute);
    413     }
    414 
    415     public void removeItem(AlarmItemHolder itemHolder) {
    416         mItemAdapter.removeItem(itemHolder);
    417     }
    418 
    419     /**
    420      * Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls
    421      * the recyclerview or when the size/position of elements within the recyclerview changes.
    422      */
    423     private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener
    424             implements View.OnLayoutChangeListener {
    425         @Override
    426         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    427             setTabScrolledToTop(Utils.isScrolledToTop(mRecyclerView));
    428         }
    429 
    430         @Override
    431         public void onLayoutChange(View v, int left, int top, int right, int bottom,
    432                 int oldLeft, int oldTop, int oldRight, int oldBottom) {
    433             setTabScrolledToTop(Utils.isScrolledToTop(mRecyclerView));
    434         }
    435     }
    436 
    437     /**
    438      * This runnable executes at midnight and refreshes the display of all alarms. Collapsed alarms
    439      * that do no repeat will have their "Tomorrow" strings updated to say "Today".
    440      */
    441     private final class MidnightRunnable implements Runnable {
    442         @Override
    443         public void run() {
    444             mItemAdapter.notifyDataSetChanged();
    445         }
    446     }
    447 }
    448