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