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 }