blob: b9264d499ce86632cee5cdce5e9e9f14bfd39aad [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.content.Context
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.recyclerview.widget.RecyclerView
import com.android.deskclock.R
import com.android.deskclock.data.DataModel
import com.android.deskclock.data.Lap
import com.android.deskclock.data.Stopwatch
import com.android.deskclock.stopwatch.LapsAdapter.LapItemHolder
import com.android.deskclock.uidata.UiDataModel
import java.text.DecimalFormatSymbols
import kotlin.math.max
/**
* Displays a list of lap times in reverse order. That is, the newest lap is at the top, the oldest
* lap is at the bottom.
*/
internal class LapsAdapter(context: Context) : RecyclerView.Adapter<LapItemHolder?>() {
private val mInflater: LayoutInflater
private val mContext: Context
/** Used to determine when the time format for the lap time column has changed length. */
private var mLastFormattedLapTimeLength = 0
/** Used to determine when the time format for the total time column has changed length. */
private var mLastFormattedAccumulatedTimeLength = 0
init {
mContext = context
mInflater = LayoutInflater.from(context)
setHasStableIds(true)
}
/**
* After recording the first lap, there is always a "current lap" in progress.
*
* @return 0 if no laps are yet recorded; lap count + 1 if any laps exist
*/
override fun getItemCount(): Int {
val lapCount = laps.size
val currentLapCount = if (lapCount == 0) 0 else 1
return currentLapCount + lapCount
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LapItemHolder {
val v: View = mInflater.inflate(R.layout.lap_view, parent, false /* attachToRoot */)
return LapItemHolder(v)
}
override fun onBindViewHolder(viewHolder: LapItemHolder, position: Int) {
val lapTime: Long
val lapNumber: Int
val totalTime: Long
// Lap will be null for the current lap.
val lap = if (position == 0) null else laps[position - 1]
if (lap != null) {
// For a recorded lap, merely extract the values to format.
lapTime = lap.lapTime
lapNumber = lap.lapNumber
totalTime = lap.accumulatedTime
} else {
// For the current lap, compute times relative to the stopwatch.
totalTime = stopwatch.totalTime
lapTime = DataModel.dataModel.getCurrentLapTime(totalTime)
lapNumber = laps.size + 1
}
// Bind data into the child views.
viewHolder.lapTime.setText(formatLapTime(lapTime, true))
viewHolder.accumulatedTime.setText(formatAccumulatedTime(totalTime, true))
viewHolder.lapNumber.setText(formatLapNumber(laps.size + 1, lapNumber))
}
override fun getItemId(position: Int): Long {
val laps = laps
return if (position == 0) {
(laps.size + 1).toLong()
} else {
laps[position - 1].lapNumber.toLong()
}
}
/**
* @param rv the RecyclerView that contains the `childView`
* @param totalTime time accumulated for the current lap and all prior laps
*/
fun updateCurrentLap(rv: RecyclerView, totalTime: Long) {
// If no laps exist there is nothing to do.
if (itemCount == 0) {
return
}
val currentLapView: View? = rv.getChildAt(0)
if (currentLapView != null) {
// Compute the lap time using the total time.
val lapTime = DataModel.dataModel.getCurrentLapTime(totalTime)
val holder = rv.getChildViewHolder(currentLapView) as LapItemHolder
holder.lapTime.setText(formatLapTime(lapTime, false))
holder.accumulatedTime.setText(formatAccumulatedTime(totalTime, false))
}
}
/**
* Record a new lap and update this adapter to include it.
*
* @return a newly cleared lap
*/
fun addLap(): Lap? {
val lap = DataModel.dataModel.addLap()
if (itemCount == 10) {
// 10 total laps indicates all items switch from 1 to 2 digit lap numbers.
notifyDataSetChanged()
} else {
// New current lap now exists.
notifyItemInserted(0)
// Prior current lap must be refreshed once with the true values in place.
notifyItemChanged(1)
}
return lap
}
/**
* Remove all recorded laps and update this adapter.
*/
fun clearLaps() {
// Clear the computed time lengths related to the old recorded laps.
mLastFormattedLapTimeLength = 0
mLastFormattedAccumulatedTimeLength = 0
notifyDataSetChanged()
}
/**
* @return a formatted textual description of lap times and total time
*/
val shareText: String
get() {
val stopwatch = stopwatch
val totalTime = stopwatch.totalTime
val stopwatchTime = formatTime(totalTime, totalTime, ":")
// Choose a size for the builder that is unlikely to be resized.
val builder = StringBuilder(1000)
// Add the total elapsed time of the stopwatch.
builder.append(mContext.getString(R.string.sw_share_main, stopwatchTime))
builder.append("\n")
val laps = laps
if (laps.isNotEmpty()) {
// Add a header for lap times.
builder.append(mContext.getString(R.string.sw_share_laps))
builder.append("\n")
// Loop through the laps in the order they were recorded; reverse of display order.
val separator = DecimalFormatSymbols.getInstance().decimalSeparator.toString() + " "
for (i in laps.indices.reversed()) {
val lap = laps[i]
builder.append(lap.lapNumber)
builder.append(separator)
val lapTime = lap.lapTime
builder.append(formatTime(lapTime, lapTime, " "))
builder.append("\n")
}
// Append the final lap
builder.append(laps.size + 1)
builder.append(separator)
val lapTime = DataModel.dataModel.getCurrentLapTime(totalTime)
builder.append(formatTime(lapTime, lapTime, " "))
builder.append("\n")
}
return builder.toString()
}
/**
* @param lapCount the total number of recorded laps
* @param lapNumber the number of the lap being formatted
* @return e.g. "# 7" if `lapCount` less than 10; "# 07" if `lapCount` is 10 or more
*/
@VisibleForTesting
fun formatLapNumber(lapCount: Int, lapNumber: Int): String {
return if (lapCount < 10) {
mContext.getString(R.string.lap_number_single_digit, lapNumber)
} else {
mContext.getString(R.string.lap_number_double_digit, lapNumber)
}
}
/**
* @param lapTime the lap time to be formatted
* @param isBinding if the lap time is requested so it can be bound avoid notifying of data
* set changes; they are not allowed to occur during bind
* @return a formatted version of the lap time
*/
private fun formatLapTime(lapTime: Long, isBinding: Boolean): String {
// The longest lap dictates the way the given lapTime must be formatted.
val longestLapTime = max(DataModel.dataModel.longestLapTime, lapTime)
val formattedTime = formatTime(longestLapTime, lapTime, LRM_SPACE)
// If the newly formatted lap time has altered the format, refresh all laps.
val newLength = formattedTime.length
if (!isBinding && mLastFormattedLapTimeLength != newLength) {
mLastFormattedLapTimeLength = newLength
notifyDataSetChanged()
}
return formattedTime
}
/**
* @param accumulatedTime the accumulated time to be formatted
* @param isBinding if the lap time is requested so it can be bound avoid notifying of data
* set changes; they are not allowed to occur during bind
* @return a formatted version of the accumulated time
*/
private fun formatAccumulatedTime(accumulatedTime: Long, isBinding: Boolean): String {
val totalTime = stopwatch.totalTime
val longestAccumulatedTime = max(totalTime, accumulatedTime)
val formattedTime = formatTime(longestAccumulatedTime, accumulatedTime, LRM_SPACE)
// If the newly formatted accumulated time has altered the format, refresh all laps.
val newLength = formattedTime.length
if (!isBinding && mLastFormattedAccumulatedTimeLength != newLength) {
mLastFormattedAccumulatedTimeLength = newLength
notifyDataSetChanged()
}
return formattedTime
}
private val stopwatch: Stopwatch
get() = DataModel.dataModel.stopwatch
private val laps: List<Lap>
get() = DataModel.dataModel.laps
/**
* Cache the child views of each lap item view.
*/
internal class LapItemHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val lapNumber: TextView
val lapTime: TextView
val accumulatedTime: TextView
init {
lapTime = itemView.findViewById(R.id.lap_time) as TextView
lapNumber = itemView.findViewById(R.id.lap_number) as TextView
accumulatedTime = itemView.findViewById(R.id.lap_total) as TextView
}
}
companion object {
private val TEN_MINUTES: Long = 10 * DateUtils.MINUTE_IN_MILLIS
private val HOUR: Long = DateUtils.HOUR_IN_MILLIS
private val TEN_HOURS = 10 * HOUR
private val HUNDRED_HOURS = 100 * HOUR
/** A single space preceded by a zero-width LRM; This groups adjacent chars left-to-right. */
private const val LRM_SPACE = "\u200E "
/** Reusable StringBuilder that assembles a formatted time; alleviates memory churn. */
private val sTimeBuilder = StringBuilder(12)
/**
* @param maxTime the maximum amount of time; used to choose a time format
* @param time the time to format guaranteed not to exceed `maxTime`
* @param separator displayed between hours and minutes as well as minutes and seconds
* @return a formatted version of the time
*/
@VisibleForTesting
fun formatTime(maxTime: Long, time: Long, separator: String?): String {
val hours: Int
val minutes: Int
val seconds: Int
val hundredths: Int
if (time <= 0) {
// A negative time should be impossible, but is tolerated to avoid crashing the app.
hundredths = 0
seconds = hundredths
minutes = seconds
hours = minutes
} else {
hours = (time / DateUtils.HOUR_IN_MILLIS).toInt()
var remainder = (time % DateUtils.HOUR_IN_MILLIS).toInt()
minutes = (remainder / DateUtils.MINUTE_IN_MILLIS).toInt()
remainder = (remainder % DateUtils.MINUTE_IN_MILLIS).toInt()
seconds = (remainder / DateUtils.SECOND_IN_MILLIS).toInt()
remainder = (remainder % DateUtils.SECOND_IN_MILLIS).toInt()
hundredths = remainder / 10
}
val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator
sTimeBuilder.setLength(0)
// The display of hours and minutes varies based on maxTime.
when {
maxTime < TEN_MINUTES -> {
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 1))
}
maxTime < HOUR -> {
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2))
}
maxTime < TEN_HOURS -> {
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 1))
sTimeBuilder.append(separator)
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2))
}
maxTime < HUNDRED_HOURS -> {
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 2))
sTimeBuilder.append(separator)
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2))
}
else -> {
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 3))
sTimeBuilder.append(separator)
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2))
}
}
// The display of seconds and hundredths-of-a-second is constant.
sTimeBuilder.append(separator)
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(seconds, 2))
sTimeBuilder.append(decimalSeparator)
sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hundredths, 2))
return sTimeBuilder.toString()
}
}
}