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.stopwatch 18 19 import android.content.Context 20 import android.text.format.DateUtils 21 import android.view.LayoutInflater 22 import android.view.View 23 import android.view.ViewGroup 24 import android.widget.TextView 25 import androidx.annotation.VisibleForTesting 26 import androidx.recyclerview.widget.RecyclerView 27 28 import com.android.deskclock.R 29 import com.android.deskclock.data.DataModel 30 import com.android.deskclock.data.Lap 31 import com.android.deskclock.data.Stopwatch 32 import com.android.deskclock.stopwatch.LapsAdapter.LapItemHolder 33 import com.android.deskclock.uidata.UiDataModel 34 35 import java.text.DecimalFormatSymbols 36 37 import kotlin.math.max 38 39 /** 40 * Displays a list of lap times in reverse order. That is, the newest lap is at the top, the oldest 41 * lap is at the bottom. 42 */ 43 internal class LapsAdapter(context: Context) : RecyclerView.Adapter<LapItemHolder?>() { 44 private val mInflater: LayoutInflater 45 private val mContext: Context 46 47 /** Used to determine when the time format for the lap time column has changed length. */ 48 private var mLastFormattedLapTimeLength = 0 49 50 /** Used to determine when the time format for the total time column has changed length. */ 51 private var mLastFormattedAccumulatedTimeLength = 0 52 53 init { 54 mContext = context 55 mInflater = LayoutInflater.from(context) 56 setHasStableIds(true) 57 } 58 59 /** 60 * After recording the first lap, there is always a "current lap" in progress. 61 * 62 * @return 0 if no laps are yet recorded; lap count + 1 if any laps exist 63 */ getItemCountnull64 override fun getItemCount(): Int { 65 val lapCount = laps.size 66 val currentLapCount = if (lapCount == 0) 0 else 1 67 return currentLapCount + lapCount 68 } 69 onCreateViewHoldernull70 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LapItemHolder { 71 val v: View = mInflater.inflate(R.layout.lap_view, parent, false /* attachToRoot */) 72 return LapItemHolder(v) 73 } 74 onBindViewHoldernull75 override fun onBindViewHolder(viewHolder: LapItemHolder, position: Int) { 76 val lapTime: Long 77 val lapNumber: Int 78 val totalTime: Long 79 80 // Lap will be null for the current lap. 81 val lap = if (position == 0) null else laps[position - 1] 82 if (lap != null) { 83 // For a recorded lap, merely extract the values to format. 84 lapTime = lap.lapTime 85 lapNumber = lap.lapNumber 86 totalTime = lap.accumulatedTime 87 } else { 88 // For the current lap, compute times relative to the stopwatch. 89 totalTime = stopwatch.totalTime 90 lapTime = DataModel.dataModel.getCurrentLapTime(totalTime) 91 lapNumber = laps.size + 1 92 } 93 94 // Bind data into the child views. 95 viewHolder.lapTime.setText(formatLapTime(lapTime, true)) 96 viewHolder.accumulatedTime.setText(formatAccumulatedTime(totalTime, true)) 97 viewHolder.lapNumber.setText(formatLapNumber(laps.size + 1, lapNumber)) 98 } 99 getItemIdnull100 override fun getItemId(position: Int): Long { 101 val laps = laps 102 return if (position == 0) { 103 (laps.size + 1).toLong() 104 } else { 105 laps[position - 1].lapNumber.toLong() 106 } 107 } 108 109 /** 110 * @param rv the RecyclerView that contains the `childView` 111 * @param totalTime time accumulated for the current lap and all prior laps 112 */ updateCurrentLapnull113 fun updateCurrentLap(rv: RecyclerView, totalTime: Long) { 114 // If no laps exist there is nothing to do. 115 if (itemCount == 0) { 116 return 117 } 118 119 val currentLapView: View? = rv.getChildAt(0) 120 if (currentLapView != null) { 121 // Compute the lap time using the total time. 122 val lapTime = DataModel.dataModel.getCurrentLapTime(totalTime) 123 val holder = rv.getChildViewHolder(currentLapView) as LapItemHolder 124 holder.lapTime.setText(formatLapTime(lapTime, false)) 125 holder.accumulatedTime.setText(formatAccumulatedTime(totalTime, false)) 126 } 127 } 128 129 /** 130 * Record a new lap and update this adapter to include it. 131 * 132 * @return a newly cleared lap 133 */ addLapnull134 fun addLap(): Lap? { 135 val lap = DataModel.dataModel.addLap() 136 137 if (itemCount == 10) { 138 // 10 total laps indicates all items switch from 1 to 2 digit lap numbers. 139 notifyDataSetChanged() 140 } else { 141 // New current lap now exists. 142 notifyItemInserted(0) 143 144 // Prior current lap must be refreshed once with the true values in place. 145 notifyItemChanged(1) 146 } 147 148 return lap 149 } 150 151 /** 152 * Remove all recorded laps and update this adapter. 153 */ clearLapsnull154 fun clearLaps() { 155 // Clear the computed time lengths related to the old recorded laps. 156 mLastFormattedLapTimeLength = 0 157 mLastFormattedAccumulatedTimeLength = 0 158 159 notifyDataSetChanged() 160 } 161 162 /** 163 * @return a formatted textual description of lap times and total time 164 */ 165 val shareText: String 166 get() { 167 val stopwatch = stopwatch 168 val totalTime = stopwatch.totalTime 169 val stopwatchTime = formatTime(totalTime, totalTime, ":") 170 171 // Choose a size for the builder that is unlikely to be resized. 172 val builder = StringBuilder(1000) 173 174 // Add the total elapsed time of the stopwatch. 175 builder.append(mContext.getString(R.string.sw_share_main, stopwatchTime)) 176 builder.append("\n") 177 178 val laps = laps 179 if (laps.isNotEmpty()) { 180 // Add a header for lap times. 181 builder.append(mContext.getString(R.string.sw_share_laps)) 182 builder.append("\n") 183 184 // Loop through the laps in the order they were recorded; reverse of display order. 185 val separator = DecimalFormatSymbols.getInstance().decimalSeparator.toString() + " " 186 for (i in laps.indices.reversed()) { 187 val lap = laps[i] 188 builder.append(lap.lapNumber) 189 builder.append(separator) 190 val lapTime = lap.lapTime 191 builder.append(formatTime(lapTime, lapTime, " ")) 192 builder.append("\n") 193 } 194 195 // Append the final lap 196 builder.append(laps.size + 1) 197 builder.append(separator) 198 val lapTime = DataModel.dataModel.getCurrentLapTime(totalTime) 199 builder.append(formatTime(lapTime, lapTime, " ")) 200 builder.append("\n") 201 } 202 return builder.toString() 203 } 204 205 /** 206 * @param lapCount the total number of recorded laps 207 * @param lapNumber the number of the lap being formatted 208 * @return e.g. "# 7" if `lapCount` less than 10; "# 07" if `lapCount` is 10 or more 209 */ 210 @VisibleForTesting formatLapNumbernull211 fun formatLapNumber(lapCount: Int, lapNumber: Int): String { 212 return if (lapCount < 10) { 213 mContext.getString(R.string.lap_number_single_digit, lapNumber) 214 } else { 215 mContext.getString(R.string.lap_number_double_digit, lapNumber) 216 } 217 } 218 219 /** 220 * @param lapTime the lap time to be formatted 221 * @param isBinding if the lap time is requested so it can be bound avoid notifying of data 222 * set changes; they are not allowed to occur during bind 223 * @return a formatted version of the lap time 224 */ formatLapTimenull225 private fun formatLapTime(lapTime: Long, isBinding: Boolean): String { 226 // The longest lap dictates the way the given lapTime must be formatted. 227 val longestLapTime = max(DataModel.dataModel.longestLapTime, lapTime) 228 val formattedTime = formatTime(longestLapTime, lapTime, LRM_SPACE) 229 230 // If the newly formatted lap time has altered the format, refresh all laps. 231 val newLength = formattedTime.length 232 if (!isBinding && mLastFormattedLapTimeLength != newLength) { 233 mLastFormattedLapTimeLength = newLength 234 notifyDataSetChanged() 235 } 236 237 return formattedTime 238 } 239 240 /** 241 * @param accumulatedTime the accumulated time to be formatted 242 * @param isBinding if the lap time is requested so it can be bound avoid notifying of data 243 * set changes; they are not allowed to occur during bind 244 * @return a formatted version of the accumulated time 245 */ formatAccumulatedTimenull246 private fun formatAccumulatedTime(accumulatedTime: Long, isBinding: Boolean): String { 247 val totalTime = stopwatch.totalTime 248 val longestAccumulatedTime = max(totalTime, accumulatedTime) 249 val formattedTime = formatTime(longestAccumulatedTime, accumulatedTime, LRM_SPACE) 250 251 // If the newly formatted accumulated time has altered the format, refresh all laps. 252 val newLength = formattedTime.length 253 if (!isBinding && mLastFormattedAccumulatedTimeLength != newLength) { 254 mLastFormattedAccumulatedTimeLength = newLength 255 notifyDataSetChanged() 256 } 257 258 return formattedTime 259 } 260 261 private val stopwatch: Stopwatch 262 get() = DataModel.dataModel.stopwatch 263 264 private val laps: List<Lap> 265 get() = DataModel.dataModel.laps 266 267 /** 268 * Cache the child views of each lap item view. 269 */ 270 internal class LapItemHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 271 val lapNumber: TextView 272 val lapTime: TextView 273 val accumulatedTime: TextView 274 275 init { 276 lapTime = itemView.findViewById(R.id.lap_time) as TextView 277 lapNumber = itemView.findViewById(R.id.lap_number) as TextView 278 accumulatedTime = itemView.findViewById(R.id.lap_total) as TextView 279 } 280 } 281 282 companion object { 283 private val TEN_MINUTES: Long = 10 * DateUtils.MINUTE_IN_MILLIS 284 private val HOUR: Long = DateUtils.HOUR_IN_MILLIS 285 private val TEN_HOURS = 10 * HOUR 286 private val HUNDRED_HOURS = 100 * HOUR 287 288 /** A single space preceded by a zero-width LRM; This groups adjacent chars left-to-right. */ 289 private const val LRM_SPACE = "\u200E " 290 291 /** Reusable StringBuilder that assembles a formatted time; alleviates memory churn. */ 292 private val sTimeBuilder = StringBuilder(12) 293 294 /** 295 * @param maxTime the maximum amount of time; used to choose a time format 296 * @param time the time to format guaranteed not to exceed `maxTime` 297 * @param separator displayed between hours and minutes as well as minutes and seconds 298 * @return a formatted version of the time 299 */ 300 @VisibleForTesting formatTimenull301 fun formatTime(maxTime: Long, time: Long, separator: String?): String { 302 val hours: Int 303 val minutes: Int 304 val seconds: Int 305 val hundredths: Int 306 if (time <= 0) { 307 // A negative time should be impossible, but is tolerated to avoid crashing the app. 308 hundredths = 0 309 seconds = hundredths 310 minutes = seconds 311 hours = minutes 312 } else { 313 hours = (time / DateUtils.HOUR_IN_MILLIS).toInt() 314 var remainder = (time % DateUtils.HOUR_IN_MILLIS).toInt() 315 minutes = (remainder / DateUtils.MINUTE_IN_MILLIS).toInt() 316 remainder = (remainder % DateUtils.MINUTE_IN_MILLIS).toInt() 317 seconds = (remainder / DateUtils.SECOND_IN_MILLIS).toInt() 318 remainder = (remainder % DateUtils.SECOND_IN_MILLIS).toInt() 319 hundredths = remainder / 10 320 } 321 322 val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator 323 324 sTimeBuilder.setLength(0) 325 326 // The display of hours and minutes varies based on maxTime. 327 when { 328 maxTime < TEN_MINUTES -> { 329 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 1)) 330 } 331 maxTime < HOUR -> { 332 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2)) 333 } 334 maxTime < TEN_HOURS -> { 335 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 1)) 336 sTimeBuilder.append(separator) 337 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2)) 338 } 339 maxTime < HUNDRED_HOURS -> { 340 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 2)) 341 sTimeBuilder.append(separator) 342 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2)) 343 } 344 else -> { 345 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hours, 3)) 346 sTimeBuilder.append(separator) 347 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(minutes, 2)) 348 } 349 } 350 351 // The display of seconds and hundredths-of-a-second is constant. 352 sTimeBuilder.append(separator) 353 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(seconds, 2)) 354 sTimeBuilder.append(decimalSeparator) 355 sTimeBuilder.append(UiDataModel.uiDataModel.getFormattedNumber(hundredths, 2)) 356 357 return sTimeBuilder.toString() 358 } 359 } 360 }