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