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 android.support.annotation.VisibleForTesting;
21 import android.support.v7.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 }