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 androidx.annotation.NonNull;
28 import com.google.android.material.snackbar.Snackbar;
29 import androidx.recyclerview.widget.LinearLayoutManager;
30 import androidx.recyclerview.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      */
AlarmClockFragment()98     public AlarmClockFragment() {
99         super(ALARMS);
100     }
101 
102     @Override
onCreate(Bundle savedState)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
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState)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
onStart()185     public void onStart() {
186         super.onStart();
187 
188         if (!isTabSelected()) {
189             TimePickerDialogFragment.removeTimeEditDialog(getFragmentManager());
190         }
191     }
192 
193     @Override
onResume()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
onPause()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
smoothScrollTo(int position)247     public void smoothScrollTo(int position) {
248         mLayoutManager.scrollToPositionWithOffset(position, 0);
249     }
250 
251     @Override
onSaveInstanceState(Bundle outState)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
onDestroy()259     public void onDestroy() {
260         super.onDestroy();
261         ToastManager.cancelToast();
262     }
263 
setLabel(Alarm alarm, String label)264     public void setLabel(Alarm alarm, String label) {
265         alarm.label = label;
266         mAlarmUpdateHandler.asyncUpdateAlarm(alarm, false, true);
267     }
268 
269     @Override
onCreateLoader(int id, Bundle args)270     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
271         return Alarm.getAlarmsCursorLoader(getActivity());
272     }
273 
274     @Override
onLoadFinished(Loader<Cursor> cursorLoader, Cursor data)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      */
setAdapterItems(final List<AlarmItemHolder> items, final long updateToken)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      */
scrollToAlarm(long alarmId)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
onLoaderReset(Loader<Cursor> cursorLoader)377     public void onLoaderReset(Loader<Cursor> cursorLoader) {
378     }
379 
380     @Override
setSmoothScrollStableId(long stableId)381     public void setSmoothScrollStableId(long stableId) {
382         mScrollToAlarmId = stableId;
383     }
384 
385     @Override
onFabClick(@onNull ImageView fab)386     public void onFabClick(@NonNull ImageView fab) {
387         mAlarmUpdateHandler.hideUndoBar();
388         startCreatingAlarm();
389     }
390 
391     @Override
onUpdateFab(@onNull ImageView fab)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
onUpdateFabButtons(@onNull Button left, @NonNull Button right)399     public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) {
400         left.setVisibility(View.INVISIBLE);
401         right.setVisibility(View.INVISIBLE);
402     }
403 
startCreatingAlarm()404     private void startCreatingAlarm() {
405         // Clear the currently selected alarm.
406         mAlarmTimeClickHandler.setSelectedAlarm(null);
407         TimePickerDialogFragment.show(this);
408     }
409 
410     @Override
onTimeSet(TimePickerDialogFragment fragment, int hourOfDay, int minute)411     public void onTimeSet(TimePickerDialogFragment fragment, int hourOfDay, int minute) {
412         mAlarmTimeClickHandler.onTimeSet(hourOfDay, minute);
413     }
414 
removeItem(AlarmItemHolder itemHolder)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
onScrolled(RecyclerView recyclerView, int dx, int dy)426         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
427             setTabScrolledToTop(Utils.isScrolledToTop(mRecyclerView));
428         }
429 
430         @Override
onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)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
run()443         public void run() {
444             mItemAdapter.notifyDataSetChanged();
445         }
446     }
447 }
448