/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.deskclock import android.content.Context import android.database.Cursor import android.graphics.drawable.Drawable import android.os.Bundle import android.os.SystemClock import android.view.LayoutInflater import android.view.View import android.view.View.OnLayoutChangeListener import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.TextView import androidx.loader.app.LoaderManager.LoaderCallbacks import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.deskclock.ItemAdapter.ItemHolder import com.android.deskclock.ItemAdapter.OnItemChangedListener import com.android.deskclock.alarms.AlarmTimeClickHandler import com.android.deskclock.alarms.AlarmUpdateHandler import com.android.deskclock.alarms.ScrollHandler import com.android.deskclock.alarms.TimePickerDialogFragment import com.android.deskclock.alarms.dataadapter.AlarmItemHolder import com.android.deskclock.alarms.dataadapter.CollapsedAlarmViewHolder import com.android.deskclock.alarms.dataadapter.ExpandedAlarmViewHolder import com.android.deskclock.provider.Alarm import com.android.deskclock.provider.AlarmInstance import com.android.deskclock.uidata.UiDataModel import com.android.deskclock.widget.EmptyViewController import com.android.deskclock.widget.toast.SnackbarManager import com.android.deskclock.widget.toast.ToastManager import com.google.android.material.snackbar.Snackbar import kotlin.math.max /** * A fragment that displays a list of alarm time and allows interaction with them. */ class AlarmClockFragment : DeskClockFragment(UiDataModel.Tab.ALARMS), LoaderCallbacks, ScrollHandler, TimePickerDialogFragment.OnTimeSetListener { // Updates "Today/Tomorrow" in the UI when midnight passes. private val mMidnightUpdater: Runnable = MidnightRunnable() // Views private lateinit var mMainLayout: ViewGroup private lateinit var mRecyclerView: RecyclerView // Data private var mCursorLoader: Loader<*>? = null private var mScrollToAlarmId = Alarm.INVALID_ID private var mExpandedAlarmId = Alarm.INVALID_ID private var mCurrentUpdateToken: Long = 0 // Controllers private lateinit var mItemAdapter: ItemAdapter private lateinit var mAlarmUpdateHandler: AlarmUpdateHandler private lateinit var mEmptyViewController: EmptyViewController private lateinit var mAlarmTimeClickHandler: AlarmTimeClickHandler private lateinit var mLayoutManager: LinearLayoutManager override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) mCursorLoader = loaderManager.initLoader(0, Bundle.EMPTY, this) savedState?.let { mExpandedAlarmId = it.getLong(KEY_EXPANDED_ID, Alarm.INVALID_ID) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle? ): View? { // Inflate the layout for this fragment val v = inflater.inflate(R.layout.alarm_clock, container, false) val context: Context = requireActivity() mRecyclerView = v.findViewById(R.id.alarms_recycler_view) as RecyclerView mLayoutManager = object : LinearLayoutManager(context) { override fun getExtraLayoutSpace(state: RecyclerView.State): Int { val extraSpace: Int = super.getExtraLayoutSpace(state) return if (state.willRunPredictiveAnimations()) { max(getHeight(), extraSpace) } else extraSpace } } mRecyclerView.setLayoutManager(mLayoutManager) mMainLayout = v.findViewById(R.id.main) as ViewGroup mAlarmUpdateHandler = AlarmUpdateHandler(context, this, mMainLayout) val emptyView = v.findViewById(R.id.alarms_empty_view) as TextView val noAlarms: Drawable? = Utils.getVectorDrawable(context, R.drawable.ic_noalarms) emptyView.setCompoundDrawablesWithIntrinsicBounds(null, noAlarms, null, null) mEmptyViewController = EmptyViewController(mMainLayout, mRecyclerView, emptyView) mAlarmTimeClickHandler = AlarmTimeClickHandler(this, savedState, mAlarmUpdateHandler, this) mItemAdapter = ItemAdapter() mItemAdapter.setHasStableIds() mItemAdapter.withViewTypes(CollapsedAlarmViewHolder.Factory(inflater), null, CollapsedAlarmViewHolder.VIEW_TYPE) mItemAdapter.withViewTypes(ExpandedAlarmViewHolder.Factory(context), null, ExpandedAlarmViewHolder.VIEW_TYPE) mItemAdapter.setOnItemChangedListener(object : OnItemChangedListener { override fun onItemChanged(holder: ItemHolder<*>) { if ((holder as AlarmItemHolder).isExpanded) { if (mExpandedAlarmId != holder.itemId) { // Collapse the prior expanded alarm. val aih = mItemAdapter.findItemById(mExpandedAlarmId) aih?.collapse() // Record the freshly expanded alarm. mExpandedAlarmId = holder.itemId val viewHolder: RecyclerView.ViewHolder? = mRecyclerView.findViewHolderForItemId(mExpandedAlarmId) viewHolder?.let { smoothScrollTo(viewHolder.getAdapterPosition()) } } } else if (mExpandedAlarmId == holder.itemId) { // The expanded alarm is now collapsed so update the tracking id. mExpandedAlarmId = Alarm.INVALID_ID } } override fun onItemChanged(holder: ItemHolder<*>, payload: Any) { /* No additional work to do */ } }) val scrollPositionWatcher = ScrollPositionWatcher() mRecyclerView.addOnLayoutChangeListener(scrollPositionWatcher) mRecyclerView.addOnScrollListener(scrollPositionWatcher) mRecyclerView.setAdapter(mItemAdapter) val itemAnimator = ItemAnimator() itemAnimator.setChangeDuration(300L) itemAnimator.setMoveDuration(300L) mRecyclerView.setItemAnimator(itemAnimator) return v } override fun onStart() { super.onStart() if (!isTabSelected) { TimePickerDialogFragment.removeTimeEditDialog(parentFragmentManager) } } override fun onResume() { super.onResume() // Schedule a runnable to update the "Today/Tomorrow" values displayed for non-repeating // alarms when midnight passes. UiDataModel.uiDataModel.addMidnightCallback(mMidnightUpdater) // Check if another app asked us to create a blank new alarm. val intent = requireActivity().intent ?: return if (intent.hasExtra(ALARM_CREATE_NEW_INTENT_EXTRA)) { UiDataModel.uiDataModel.selectedTab = UiDataModel.Tab.ALARMS if (intent.getBooleanExtra(ALARM_CREATE_NEW_INTENT_EXTRA, false)) { // An external app asked us to create a blank alarm. startCreatingAlarm() } // Remove the CREATE_NEW extra now that we've processed it. intent.removeExtra(ALARM_CREATE_NEW_INTENT_EXTRA) } else if (intent.hasExtra(SCROLL_TO_ALARM_INTENT_EXTRA)) { UiDataModel.uiDataModel.selectedTab = UiDataModel.Tab.ALARMS val alarmId = intent.getLongExtra(SCROLL_TO_ALARM_INTENT_EXTRA, Alarm.INVALID_ID) if (alarmId != Alarm.INVALID_ID) { setSmoothScrollStableId(alarmId) if (mCursorLoader != null && mCursorLoader!!.isStarted) { // We need to force a reload here to make sure we have the latest view // of the data to scroll to. mCursorLoader!!.forceLoad() } } // Remove the SCROLL_TO_ALARM extra now that we've processed it. intent.removeExtra(SCROLL_TO_ALARM_INTENT_EXTRA) } } override fun onPause() { super.onPause() UiDataModel.uiDataModel.removePeriodicCallback(mMidnightUpdater) // When the user places the app in the background by pressing "home", // dismiss the toast bar. However, since there is no way to determine if // home was pressed, just dismiss any existing toast bar when restarting // the app. mAlarmUpdateHandler.hideUndoBar() } override fun smoothScrollTo(position: Int) { mLayoutManager.scrollToPositionWithOffset(position, 0) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) mAlarmTimeClickHandler.saveInstance(outState) outState.putLong(KEY_EXPANDED_ID, mExpandedAlarmId) } override fun onDestroy() { super.onDestroy() ToastManager.cancelToast() } fun setLabel(alarm: Alarm, label: String?) { alarm.label = label mAlarmUpdateHandler.asyncUpdateAlarm(alarm, popToast = false, minorUpdate = true) } override fun onCreateLoader(id: Int, args: Bundle?): Loader { return Alarm.getAlarmsCursorLoader(requireActivity()) } override fun onLoadFinished(cursorLoader: Loader, data: Cursor) { val itemHolders: MutableList = ArrayList(data.count) data.moveToFirst() while (!data.isAfterLast) { val alarm = Alarm(data) val alarmInstance = if (alarm.canPreemptivelyDismiss()) { AlarmInstance(data, joinedTable = true) } else { null } val itemHolder = AlarmItemHolder(alarm, alarmInstance, mAlarmTimeClickHandler) itemHolders.add(itemHolder) data.moveToNext() } setAdapterItems(itemHolders, SystemClock.elapsedRealtime()) } /** * Updates the adapters items, deferring the update until the current animation is finished or * if no animation is running then the listener will be automatically be invoked immediately. * * @param items the new list of [AlarmItemHolder] to use * @param updateToken a monotonically increasing value used to preserve ordering of deferred * updates */ private fun setAdapterItems(items: List, updateToken: Long) { if (updateToken < mCurrentUpdateToken) { LogUtils.v("Ignoring adapter update: %d < %d", updateToken, mCurrentUpdateToken) return } if (mRecyclerView.getItemAnimator()!!.isRunning()) { // RecyclerView is currently animating -> defer update. mRecyclerView.getItemAnimator()!!.isRunning( object : RecyclerView.ItemAnimator.ItemAnimatorFinishedListener { override fun onAnimationsFinished() { setAdapterItems(items, updateToken) } }) } else if (mRecyclerView.isComputingLayout()) { // RecyclerView is currently computing a layout -> defer update. mRecyclerView.post(Runnable { setAdapterItems(items, updateToken) }) } else { mCurrentUpdateToken = updateToken mItemAdapter.setItems(items) // Show or hide the empty view as appropriate. val noAlarms = items.isEmpty() mEmptyViewController.setEmpty(noAlarms) if (noAlarms) { // Ensure the drop shadow is hidden when no alarms exist. setTabScrolledToTop(true) } // Expand the correct alarm. if (mExpandedAlarmId != Alarm.INVALID_ID) { val aih = mItemAdapter.findItemById(mExpandedAlarmId) if (aih != null) { mAlarmTimeClickHandler.setSelectedAlarm(aih.item) aih.expand() } else { mAlarmTimeClickHandler.setSelectedAlarm(null) mExpandedAlarmId = Alarm.INVALID_ID } } // Scroll to the selected alarm. if (mScrollToAlarmId != Alarm.INVALID_ID) { scrollToAlarm(mScrollToAlarmId) setSmoothScrollStableId(Alarm.INVALID_ID) } } } /** * @param alarmId identifies the alarm to be displayed */ private fun scrollToAlarm(alarmId: Long) { val alarmCount = mItemAdapter.itemCount var alarmPosition = -1 for (i in 0 until alarmCount) { val id = mItemAdapter.getItemId(i) if (id == alarmId) { alarmPosition = i break } } if (alarmPosition >= 0) { mItemAdapter.findItemById(alarmId)?.expand() smoothScrollTo(alarmPosition) } else { // Trying to display a deleted alarm should only happen from a missed notification for // an alarm that has been marked deleted after use. SnackbarManager.show(Snackbar.make(mMainLayout, R.string.missed_alarm_has_been_deleted, Snackbar.LENGTH_LONG)) } } override fun onLoaderReset(cursorLoader: Loader) { } override fun setSmoothScrollStableId(stableId: Long) { mScrollToAlarmId = stableId } override fun onFabClick(fab: ImageView) { mAlarmUpdateHandler.hideUndoBar() startCreatingAlarm() } override fun onUpdateFab(fab: ImageView) { fab.visibility = View.VISIBLE fab.setImageResource(R.drawable.ic_add_white_24dp) fab.contentDescription = fab.resources.getString(R.string.button_alarms) } override fun onUpdateFabButtons(left: Button, right: Button) { left.visibility = View.INVISIBLE right.visibility = View.INVISIBLE } private fun startCreatingAlarm() { // Clear the currently selected alarm. mAlarmTimeClickHandler.setSelectedAlarm(null) TimePickerDialogFragment.show(this) } override fun onTimeSet(fragment: TimePickerDialogFragment?, hourOfDay: Int, minute: Int) { mAlarmTimeClickHandler.onTimeSet(hourOfDay, minute) } fun removeItem(itemHolder: AlarmItemHolder) { mItemAdapter.removeItem(itemHolder) } /** * Updates the vertical scroll state of this tab in the [UiDataModel] as the user scrolls * the recyclerview or when the size/position of elements within the recyclerview changes. */ private inner class ScrollPositionWatcher : RecyclerView.OnScrollListener(), OnLayoutChangeListener { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { setTabScrolledToTop(Utils.isScrolledToTop(mRecyclerView)) } override fun onLayoutChange( v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { setTabScrolledToTop(Utils.isScrolledToTop(mRecyclerView)) } } /** * This runnable executes at midnight and refreshes the display of all alarms. Collapsed alarms * that do no repeat will have their "Tomorrow" strings updated to say "Today". */ private inner class MidnightRunnable : Runnable { override fun run() { mItemAdapter.notifyDataSetChanged() } } companion object { // This extra is used when receiving an intent to create an alarm, but no alarm details // have been passed in, so the alarm page should start the process of creating a new alarm. const val ALARM_CREATE_NEW_INTENT_EXTRA = "deskclock.create.new" // This extra is used when receiving an intent to scroll to specific alarm. If alarm // can not be found, and toast message will pop up that the alarm has be deleted. const val SCROLL_TO_ALARM_INTENT_EXTRA = "deskclock.scroll.to.alarm" private const val KEY_EXPANDED_ID = "expandedId" } }