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