blob: 3816369725d545952f30764f69af8d722fa596ef [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.stopwatch
import android.R.attr.state_activated
import android.R.attr.state_pressed
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM
import android.os.Bundle
import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import com.android.deskclock.AnimatorUtils
import com.android.deskclock.DeskClockFragment
import com.android.deskclock.FabContainer
import com.android.deskclock.FabContainer.UpdateFabFlag
import com.android.deskclock.data.DataModel
import com.android.deskclock.data.Lap
import com.android.deskclock.data.Stopwatch
import com.android.deskclock.data.StopwatchListener
import com.android.deskclock.events.Events
import com.android.deskclock.LogUtils
import com.android.deskclock.R
import com.android.deskclock.StopwatchTextController
import com.android.deskclock.ThemeUtils
import com.android.deskclock.Utils
import com.android.deskclock.uidata.TabListener
import com.android.deskclock.uidata.UiDataModel
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
/**
* Fragment that shows the stopwatch and recorded laps.
*/
// TODO(colinmarsch) Replace deprecated Fragment related calls
class StopwatchFragment : DeskClockFragment(UiDataModel.Tab.STOPWATCH) {
/** Keep the screen on when this tab is selected. */
private val mTabWatcher: TabListener = TabWatcher()
/** Scheduled to update the stopwatch time and current lap time while stopwatch is running. */
private val mTimeUpdateRunnable: Runnable = TimeUpdateRunnable()
/** Updates the user interface in response to stopwatch changes. */
private val mStopwatchWatcher: StopwatchListener = StopwatchWatcher()
/** Draws a gradient over the bottom of the [.mLapsList] to reduce clash with the fab. */
private var mGradientItemDecoration: GradientItemDecoration? = null
/** The data source for [.mLapsList]. */
private lateinit var mLapsAdapter: LapsAdapter
/** The layout manager for the [.mLapsAdapter]. */
private lateinit var mLapsLayoutManager: LinearLayoutManager
/** Draws the reference lap while the stopwatch is running. */
private var mTime: StopwatchCircleView? = null
/** The View containing both TextViews of the stopwatch. */
private lateinit var mStopwatchWrapper: View
/** Displays the recorded lap times. */
private lateinit var mLapsList: RecyclerView
/** Displays the current stopwatch time (seconds and above only). */
private lateinit var mMainTimeText: TextView
/** Displays the current stopwatch time (hundredths only). */
private lateinit var mHundredthsTimeText: TextView
/** Formats and displays the text in the stopwatch. */
private lateinit var mStopwatchTextController: StopwatchTextController
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View {
mLapsAdapter = LapsAdapter(getActivity())
mLapsLayoutManager = LinearLayoutManager(getActivity())
mGradientItemDecoration = GradientItemDecoration(getActivity())
val v: View = inflater.inflate(R.layout.stopwatch_fragment, container, false)
mTime = v.findViewById(R.id.stopwatch_circle)
mLapsList = v.findViewById(R.id.laps_list) as RecyclerView
(mLapsList.getItemAnimator() as SimpleItemAnimator).setSupportsChangeAnimations(false)
mLapsList.setLayoutManager(mLapsLayoutManager)
mLapsList.addItemDecoration(mGradientItemDecoration!!)
// In landscape layouts, the laps list can reach the top of the screen and thus can cause
// a drop shadow to appear. The same is not true for portrait landscapes.
if (Utils.isLandscape(getActivity())) {
val scrollPositionWatcher = ScrollPositionWatcher()
mLapsList.addOnLayoutChangeListener(scrollPositionWatcher)
mLapsList.addOnScrollListener(scrollPositionWatcher)
} else {
setTabScrolledToTop(true)
}
mLapsList.setAdapter(mLapsAdapter)
// Timer text serves as a virtual start/stop button.
mMainTimeText = v.findViewById(R.id.stopwatch_time_text) as TextView
mHundredthsTimeText = v.findViewById(R.id.stopwatch_hundredths_text) as TextView
mStopwatchTextController = StopwatchTextController(mMainTimeText, mHundredthsTimeText)
mStopwatchWrapper = v.findViewById(R.id.stopwatch_time_wrapper)
DataModel.dataModel.addStopwatchListener(mStopwatchWatcher)
mStopwatchWrapper.setOnClickListener(TimeClickListener())
if (mTime != null) {
mStopwatchWrapper.setOnTouchListener(CircleTouchListener())
}
val c: Context = mMainTimeText.getContext()
val colorAccent = ThemeUtils.resolveColor(c, R.attr.colorAccent)
val textColorPrimary = ThemeUtils.resolveColor(c, android.R.attr.textColorPrimary)
val timeTextColor =
ColorStateList(
arrayOf(intArrayOf(-state_activated, -state_pressed), intArrayOf()),
intArrayOf(textColorPrimary, colorAccent)
)
mMainTimeText.setTextColor(timeTextColor)
mHundredthsTimeText.setTextColor(timeTextColor)
return v
}
override fun onStart() {
super.onStart()
val activity: Activity = getActivity()
val intent: Intent? = activity.getIntent()
if (intent != null) {
val action: String? = intent.getAction()
if (StopwatchService.Companion.ACTION_START_STOPWATCH == action) {
DataModel.dataModel.startStopwatch()
// Consume the intent
activity.setIntent(null)
} else if (StopwatchService.Companion.ACTION_PAUSE_STOPWATCH == action) {
DataModel.dataModel.pauseStopwatch()
// Consume the intent
activity.setIntent(null)
}
}
// Conservatively assume the data in the adapter has changed while the fragment was paused.
mLapsAdapter.notifyDataSetChanged()
// Synchronize the user interface with the data model.
updateUI(FabContainer.FAB_AND_BUTTONS_IMMEDIATE)
// Start watching for page changes away from this fragment.
UiDataModel.uiDataModel.addTabListener(mTabWatcher)
}
override fun onStop() {
super.onStop()
// Stop all updates while the fragment is not visible.
stopUpdatingTime()
// Stop watching for page changes away from this fragment.
UiDataModel.uiDataModel.removeTabListener(mTabWatcher)
// Release the wake lock if it is currently held.
releaseWakeLock()
}
override fun onDestroyView() {
super.onDestroyView()
DataModel.dataModel.removeStopwatchListener(mStopwatchWatcher)
}
override fun onFabClick(fab: ImageView) {
toggleStopwatchState()
}
override fun onLeftButtonClick(left: Button) {
doReset()
}
override fun onRightButtonClick(right: Button) {
when (stopwatch.state) {
Stopwatch.State.RUNNING -> doAddLap()
Stopwatch.State.PAUSED -> doShare()
Stopwatch.State.RESET -> {
}
null -> {
}
}
}
private fun updateFab(fab: ImageView, animate: Boolean) {
if (stopwatch.isRunning) {
if (animate) {
fab.setImageResource(R.drawable.ic_play_pause_animation)
} else {
fab.setImageResource(R.drawable.ic_play_pause)
}
fab.setContentDescription(fab.getResources().getString(R.string.sw_pause_button))
} else {
if (animate) {
fab.setImageResource(R.drawable.ic_pause_play_animation)
} else {
fab.setImageResource(R.drawable.ic_pause_play)
}
fab.setContentDescription(fab.getResources().getString(R.string.sw_start_button))
}
fab.setVisibility(VISIBLE)
}
override fun onUpdateFab(fab: ImageView) {
updateFab(fab, false)
}
override fun onMorphFab(fab: ImageView) {
// Update the fab's drawable to match the current timer state.
updateFab(fab, Utils.isNOrLater())
// Animate the drawable.
AnimatorUtils.startDrawableAnimation(fab)
}
override fun onUpdateFabButtons(left: Button, right: Button) {
val resources: Resources = getResources()
left.setClickable(true)
left.setText(R.string.sw_reset_button)
left.setContentDescription(resources.getString(R.string.sw_reset_button))
when (stopwatch.state) {
Stopwatch.State.RESET -> {
left.setVisibility(INVISIBLE)
right.setClickable(true)
right.setVisibility(INVISIBLE)
}
Stopwatch.State.RUNNING -> {
left.setVisibility(VISIBLE)
val canRecordLaps = canRecordMoreLaps()
right.setText(R.string.sw_lap_button)
right.setContentDescription(resources.getString(R.string.sw_lap_button))
right.setClickable(canRecordLaps)
right.setVisibility(if (canRecordLaps) VISIBLE else INVISIBLE)
}
Stopwatch.State.PAUSED -> {
left.setVisibility(VISIBLE)
right.setClickable(true)
right.setVisibility(VISIBLE)
right.setText(R.string.sw_share_button)
right.setContentDescription(resources.getString(R.string.sw_share_button))
}
null -> {
}
}
}
/**
* @param color the newly installed app window color
*/
override fun onAppColorChanged(@ColorInt color: Int) {
mGradientItemDecoration?.updateGradientColors(color)
mLapsList.invalidateItemDecorations()
}
/**
* Start the stopwatch.
*/
private fun doStart() {
Events.sendStopwatchEvent(R.string.action_start, R.string.label_deskclock)
DataModel.dataModel.startStopwatch()
}
/**
* Pause the stopwatch.
*/
private fun doPause() {
Events.sendStopwatchEvent(R.string.action_pause, R.string.label_deskclock)
DataModel.dataModel.pauseStopwatch()
}
/**
* Reset the stopwatch.
*/
private fun doReset() {
val priorState = stopwatch.state
Events.sendStopwatchEvent(R.string.action_reset, R.string.label_deskclock)
DataModel.dataModel.resetStopwatch()
mMainTimeText.setAlpha(1f)
mHundredthsTimeText.setAlpha(1f)
if (priorState == Stopwatch.State.RUNNING) {
updateFab(FabContainer.FAB_MORPH)
}
}
/**
* Send stopwatch time and lap times to an external sharing application.
*/
private fun doShare() {
// Disable the fab buttons to avoid double-taps on the share button.
updateFab(FabContainer.BUTTONS_DISABLE)
val subjects: Array<String> = getResources().getStringArray(R.array.sw_share_strings)
val subject = subjects[(Math.random() * subjects.size).toInt()]
val text = mLapsAdapter.shareText
@SuppressLint("InlinedApi")
val shareIntent: Intent = Intent(Intent.ACTION_SEND)
.addFlags(if (Utils.isLOrLater()) {
Intent.FLAG_ACTIVITY_NEW_DOCUMENT
} else {
Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET
})
.putExtra(Intent.EXTRA_SUBJECT, subject)
.putExtra(Intent.EXTRA_TEXT, text)
.setType("text/plain")
val context: Context = getActivity()
val title: String = context.getString(R.string.sw_share_button)
val shareChooserIntent: Intent = Intent.createChooser(shareIntent, title)
try {
context.startActivity(shareChooserIntent)
} catch (anfe: ActivityNotFoundException) {
LogUtils.e("Cannot share lap data because no suitable receiving Activity exists")
updateFab(FabContainer.BUTTONS_IMMEDIATE)
}
}
/**
* Record and add a new lap ending now.
*/
private fun doAddLap() {
Events.sendStopwatchEvent(R.string.action_lap, R.string.label_deskclock)
// Record a new lap.
val lap = mLapsAdapter.addLap() ?: return
// Update button states.
updateFab(FabContainer.BUTTONS_IMMEDIATE)
if (lap.lapNumber == 1) {
// Child views from prior lap sets hang around and blit to the screen when adding the
// first lap of the subsequent lap set. Remove those superfluous children here manually
// to ensure they aren't seen as the first lap is drawn.
mLapsList.removeAllViewsInLayout()
if (mTime != null) {
// Start animating the reference lap.
mTime!!.update()
}
// Recording the first lap transitions the UI to display the laps list.
showOrHideLaps(false)
}
// Ensure the newly added lap is visible on screen.
mLapsList.scrollToPosition(0)
}
/**
* Show or hide the list of laps.
*/
private fun showOrHideLaps(clearLaps: Boolean) {
val sceneRoot: ViewGroup = getView() as ViewGroup? ?: return
TransitionManager.beginDelayedTransition(sceneRoot)
if (clearLaps) {
mLapsAdapter.clearLaps()
}
val lapsVisible = mLapsAdapter.getItemCount() > 0
mLapsList.setVisibility(if (lapsVisible) VISIBLE else GONE)
if (Utils.isPortrait(getActivity())) {
// When the lap list is visible, it includes the bottom padding. When it is absent the
// appropriate bottom padding must be applied to the container.
val res: Resources = getResources()
val bottom = if (lapsVisible) 0 else res.getDimensionPixelSize(R.dimen.fab_height)
val top: Int = sceneRoot.getPaddingTop()
val left: Int = sceneRoot.getPaddingLeft()
val right: Int = sceneRoot.getPaddingRight()
sceneRoot.setPadding(left, top, right, bottom)
}
}
private fun adjustWakeLock() {
val appInForeground = DataModel.dataModel.isApplicationInForeground
if (stopwatch.isRunning && isTabSelected && appInForeground) {
getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
releaseWakeLock()
}
}
private fun releaseWakeLock() {
getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
/**
* Either pause or start the stopwatch based on its current state.
*/
private fun toggleStopwatchState() {
if (stopwatch.isRunning) {
doPause()
} else {
doStart()
}
}
private val stopwatch: Stopwatch
get() = DataModel.dataModel.stopwatch
private fun canRecordMoreLaps(): Boolean = DataModel.dataModel.canAddMoreLaps()
/**
* Post the first runnable to update times within the UI. It will reschedule itself as needed.
*/
private fun startUpdatingTime() {
// Ensure only one copy of the runnable is ever scheduled by first stopping updates.
stopUpdatingTime()
mMainTimeText.post(mTimeUpdateRunnable)
}
/**
* Remove the runnable that updates times within the UI.
*/
private fun stopUpdatingTime() {
mMainTimeText.removeCallbacks(mTimeUpdateRunnable)
}
/**
* Update all time displays based on a single snapshot of the stopwatch progress. This includes
* the stopwatch time drawn in the circle, the current lap time and the total elapsed time in
* the list of laps.
*/
private fun updateTime() {
// Compute the total time of the stopwatch.
val stopwatch = stopwatch
val totalTime = stopwatch.totalTime
mStopwatchTextController.setTimeString(totalTime)
// Update the current lap.
val currentLapIsVisible = mLapsLayoutManager.findFirstVisibleItemPosition() == 0
if (!stopwatch.isReset && currentLapIsVisible) {
mLapsAdapter.updateCurrentLap(mLapsList, totalTime)
}
}
/**
* Synchronize the UI state with the model data.
*/
private fun updateUI(@UpdateFabFlag updateTypes: Int) {
adjustWakeLock()
// Draw the latest stopwatch and current lap times.
updateTime()
if (mTime != null) {
mTime!!.update()
}
val stopwatch = stopwatch
if (!stopwatch.isReset) {
startUpdatingTime()
}
// Adjust the visibility of the list of laps.
showOrHideLaps(stopwatch.isReset)
// Update button states.
updateFab(updateTypes)
}
/**
* This runnable periodically updates times throughout the UI. It stops these updates when the
* stopwatch is no longer running.
*/
private inner class TimeUpdateRunnable : Runnable {
override fun run() {
val startTime = Utils.now()
updateTime()
// Blink text iff the stopwatch is paused and not pressed.
val touchTarget: View = if (mTime != null) mTime!! else mStopwatchWrapper
val stopwatch = stopwatch
val blink = (stopwatch.isPaused && startTime % 1000 < 500 && !touchTarget.isPressed())
if (blink) {
mMainTimeText.setAlpha(0f)
mHundredthsTimeText.setAlpha(0f)
} else {
mMainTimeText.setAlpha(1f)
mHundredthsTimeText.setAlpha(1f)
}
if (!stopwatch.isReset) {
val period = (if (stopwatch.isPaused) {
REDRAW_PERIOD_PAUSED
} else {
REDRAW_PERIOD_RUNNING
}).toLong()
val endTime = Utils.now()
val delay: Long = max(0, startTime + period - endTime).toLong()
mMainTimeText.postDelayed(this, delay)
}
}
}
/**
* Acquire or release the wake lock based on the tab state.
*/
private inner class TabWatcher : TabListener {
override fun selectedTabChanged(
oldSelectedTab: UiDataModel.Tab,
newSelectedTab: UiDataModel.Tab
) {
adjustWakeLock()
}
}
/**
* Update the user interface in response to a stopwatch change.
*/
private inner class StopwatchWatcher : StopwatchListener {
override fun stopwatchUpdated(before: Stopwatch, after: Stopwatch) {
if (after.isReset) {
// Ensure the drop shadow is hidden when the stopwatch is reset.
setTabScrolledToTop(true)
if (DataModel.dataModel.isApplicationInForeground) {
updateUI(FabContainer.BUTTONS_IMMEDIATE)
}
return
}
if (DataModel.dataModel.isApplicationInForeground) {
updateUI(FabContainer.FAB_MORPH or FabContainer.BUTTONS_IMMEDIATE)
}
}
override fun lapAdded(lap: Lap) {
}
}
/**
* Toggles stopwatch state when user taps stopwatch.
*/
private inner class TimeClickListener : View.OnClickListener {
override fun onClick(view: View?) {
if (stopwatch.isRunning) {
DataModel.dataModel.pauseStopwatch()
} else {
DataModel.dataModel.startStopwatch()
}
}
}
/**
* Checks if the user is pressing inside of the stopwatch circle.
*/
private inner class CircleTouchListener : View.OnTouchListener {
override fun onTouch(view: View, event: MotionEvent): Boolean {
val actionMasked: Int = event.getActionMasked()
if (actionMasked != MotionEvent.ACTION_DOWN) {
return false
}
val rX: Float = view.getWidth() / 2f
val rY: Float = (view.getHeight() - view.getPaddingBottom()) / 2f
val r = min(rX, rY)
val x: Float = event.getX() - rX
val y: Float = event.getY() - rY
val inCircle = (x / r.toDouble()).pow(2.0) + (y / r.toDouble()).pow(2.0) <= 1.0
// Consume the event if it is outside the circle
return !inCircle
}
}
/**
* 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(), View.OnLayoutChangeListener {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
setTabScrolledToTop(Utils.isScrolledToTop(mLapsList))
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
setTabScrolledToTop(Utils.isScrolledToTop(mLapsList))
}
}
/**
* Draws a tinting gradient over the bottom of the stopwatch laps list. This reduces the
* contrast between floating buttons and the laps list content.
*/
private class GradientItemDecoration internal constructor(context: Context)
: RecyclerView.ItemDecoration() {
/**
* A reusable array of control point colors that define the gradient. It is based on the
* background color of the window and thus recomputed each time that color is changed.
*/
private val mGradientColors = IntArray(ALPHAS.size)
/** The drawable that produces the tinting gradient effect of this decoration. */
private val mGradient: GradientDrawable = GradientDrawable()
/** The height of the gradient; sized relative to the fab height. */
private val mGradientHeight: Int
init {
mGradient.setOrientation(TOP_BOTTOM)
updateGradientColors(ThemeUtils.resolveColor(context, android.R.attr.windowBackground))
val resources: Resources = context.getResources()
val fabHeight: Int = resources.getDimensionPixelSize(R.dimen.fab_height)
mGradientHeight = (fabHeight * 1.2f).roundToInt()
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val w: Int = parent.getWidth()
val h: Int = parent.getHeight()
mGradient.setBounds(0, h - mGradientHeight, w, h)
mGradient.draw(c)
}
/**
* Given a `baseColor`, compute a gradient of tinted colors that define the fade
* effect to apply to the bottom of the lap list.
*
* @param baseColor a base color to which the gradient tint should be applied
*/
fun updateGradientColors(@ColorInt baseColor: Int) {
// Compute the tinted colors that form the gradient.
mGradientColors.indices.forEach { i ->
mGradientColors[i] = ColorUtils.setAlphaComponent(baseColor, ALPHAS[i])
}
// Set the gradient colors into the drawable.
mGradient.setColors(mGradientColors)
}
companion object {
// 0% - 25% of gradient length -> opacity changes from 0% to 50%
// 25% - 90% of gradient length -> opacity changes from 50% to 100%
// 90% - 100% of gradient length -> opacity remains at 100%
private val ALPHAS = intArrayOf(
0x00, // 0%
0x1A, // 10%
0x33, // 20%
0x4D, // 30%
0x66, // 40%
0x80, // 50%
0x89, // 53.8%
0x93, // 57.6%
0x9D, // 61.5%
0xA7, // 65.3%
0xB1, // 69.2%
0xBA, // 73.0%
0xC4, // 76.9%
0xCE, // 80.7%
0xD8, // 84.6%
0xE2, // 88.4%
0xEB, // 92.3%
0xF5, // 96.1%
0xFF, // 100%
0xFF, // 100%
0xFF)
}
}
companion object {
/** Milliseconds between redraws while running. */
private const val REDRAW_PERIOD_RUNNING = 25
/** Milliseconds between redraws while paused. */
private const val REDRAW_PERIOD_PAUSED = 500
}
}