blob: 892794e96fc2603f01c4850c9100c5e9fd3537bc [file] [log] [blame]
/*
* 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<Cursor>, 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<AlarmItemHolder>
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<View>(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<View>(R.id.main) as ViewGroup
mAlarmUpdateHandler = AlarmUpdateHandler(context, this, mMainLayout)
val emptyView = v.findViewById<View>(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<Cursor> {
return Alarm.getAlarmsCursorLoader(requireActivity())
}
override fun onLoadFinished(cursorLoader: Loader<Cursor>, data: Cursor) {
val itemHolders: MutableList<AlarmItemHolder> = 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<AlarmItemHolder>, 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<Cursor>) {
}
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"
}
}