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