| /* |
| * Copyright (C) 2015 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.NonNull; |
| 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.uidata.UiDataModel; |
| |
| import java.text.DecimalFormatSymbols; |
| import java.util.List; |
| |
| /** |
| * 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. |
| */ |
| class LapsAdapter extends RecyclerView.Adapter<LapsAdapter.LapItemHolder> { |
| |
| private static final long TEN_MINUTES = 10 * DateUtils.MINUTE_IN_MILLIS; |
| private static final long HOUR = DateUtils.HOUR_IN_MILLIS; |
| private static final long TEN_HOURS = 10 * HOUR; |
| private static final long HUNDRED_HOURS = 100 * HOUR; |
| |
| /** A single space preceded by a zero-width LRM; This groups adjacent chars left-to-right. */ |
| private static final String LRM_SPACE = "\u200E "; |
| |
| /** Reusable StringBuilder that assembles a formatted time; alleviates memory churn. */ |
| private static final StringBuilder sTimeBuilder = new StringBuilder(12); |
| |
| private final LayoutInflater mInflater; |
| private final Context mContext; |
| |
| /** Used to determine when the time format for the lap time column has changed length. */ |
| private int mLastFormattedLapTimeLength; |
| |
| /** Used to determine when the time format for the total time column has changed length. */ |
| private int mLastFormattedAccumulatedTimeLength; |
| |
| LapsAdapter(Context context) { |
| 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 |
| public int getItemCount() { |
| final int lapCount = getLaps().size(); |
| final int currentLapCount = lapCount == 0 ? 0 : 1; |
| return currentLapCount + lapCount; |
| } |
| |
| @NonNull |
| @Override |
| public LapItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { |
| final View v = mInflater.inflate(R.layout.lap_view, parent, false /* attachToRoot */); |
| return new LapItemHolder(v); |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull LapItemHolder viewHolder, int position) { |
| final long lapTime; |
| final int lapNumber; |
| final long totalTime; |
| |
| // Lap will be null for the current lap. |
| final Lap lap = position == 0 ? null : getLaps().get(position - 1); |
| if (lap != null) { |
| // For a recorded lap, merely extract the values to format. |
| lapTime = lap.getLapTime(); |
| lapNumber = lap.getLapNumber(); |
| totalTime = lap.getAccumulatedTime(); |
| } else { |
| // For the current lap, compute times relative to the stopwatch. |
| totalTime = getStopwatch().getTotalTime(); |
| lapTime = DataModel.getDataModel().getCurrentLapTime(totalTime); |
| lapNumber = getLaps().size() + 1; |
| } |
| |
| // Bind data into the child views. |
| viewHolder.lapTime.setText(formatLapTime(lapTime, true)); |
| viewHolder.accumulatedTime.setText(formatAccumulatedTime(totalTime, true)); |
| viewHolder.lapNumber.setText(formatLapNumber(getLaps().size() + 1, lapNumber)); |
| } |
| |
| @Override |
| public long getItemId(int position) { |
| final List<Lap> laps = getLaps(); |
| if (position == 0) { |
| return laps.size() + 1; |
| } |
| |
| return laps.get(position - 1).getLapNumber(); |
| } |
| |
| /** |
| * @param rv the RecyclerView that contains the {@code childView} |
| * @param totalTime time accumulated for the current lap and all prior laps |
| */ |
| void updateCurrentLap(RecyclerView rv, long totalTime) { |
| // If no laps exist there is nothing to do. |
| if (getItemCount() == 0) { |
| return; |
| } |
| |
| final View currentLapView = rv.getChildAt(0); |
| if (currentLapView != null) { |
| // Compute the lap time using the total time. |
| final long lapTime = DataModel.getDataModel().getCurrentLapTime(totalTime); |
| |
| final LapItemHolder holder = (LapItemHolder) rv.getChildViewHolder(currentLapView); |
| 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 |
| */ |
| Lap addLap() { |
| final Lap lap = DataModel.getDataModel().addLap(); |
| |
| if (getItemCount() == 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. |
| */ |
| void 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 |
| */ |
| String getShareText() { |
| final Stopwatch stopwatch = getStopwatch(); |
| final long totalTime = stopwatch.getTotalTime(); |
| final String stopwatchTime = formatTime(totalTime, totalTime, ":"); |
| |
| // Choose a size for the builder that is unlikely to be resized. |
| final StringBuilder builder = new StringBuilder(1000); |
| |
| // Add the total elapsed time of the stopwatch. |
| builder.append(mContext.getString(R.string.sw_share_main, stopwatchTime)); |
| builder.append("\n"); |
| |
| final List<Lap> laps = getLaps(); |
| if (!laps.isEmpty()) { |
| // 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. |
| final String separator = DecimalFormatSymbols.getInstance().getDecimalSeparator() + " "; |
| for (int i = laps.size() - 1; i >= 0; i--) { |
| final Lap lap = laps.get(i); |
| builder.append(lap.getLapNumber()); |
| builder.append(separator); |
| final long lapTime = lap.getLapTime(); |
| builder.append(formatTime(lapTime, lapTime, " ")); |
| builder.append("\n"); |
| } |
| |
| // Append the final lap |
| builder.append(laps.size() + 1); |
| builder.append(separator); |
| final long lapTime = DataModel.getDataModel().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 {@code lapCount} less than 10; "# 07" if {@code lapCount} is 10 or more |
| */ |
| @VisibleForTesting |
| String formatLapNumber(int lapCount, int lapNumber) { |
| if (lapCount < 10) { |
| return mContext.getString(R.string.lap_number_single_digit, lapNumber); |
| } else { |
| return mContext.getString(R.string.lap_number_double_digit, lapNumber); |
| } |
| } |
| |
| /** |
| * @param maxTime the maximum amount of time; used to choose a time format |
| * @param time the time to format guaranteed not to exceed {@code maxTime} |
| * @param separator displayed between hours and minutes as well as minutes and seconds |
| * @return a formatted version of the time |
| */ |
| @VisibleForTesting |
| static String formatTime(long maxTime, long time, String separator) { |
| final int hours, minutes, seconds, hundredths; |
| if (time <= 0) { |
| // A negative time should be impossible, but is tolerated to avoid crashing the app. |
| hours = minutes = seconds = hundredths = 0; |
| } else { |
| hours = (int) (time / DateUtils.HOUR_IN_MILLIS); |
| int remainder = (int) (time % DateUtils.HOUR_IN_MILLIS); |
| |
| minutes = (int) (remainder / DateUtils.MINUTE_IN_MILLIS); |
| remainder = (int) (remainder % DateUtils.MINUTE_IN_MILLIS); |
| |
| seconds = (int) (remainder / DateUtils.SECOND_IN_MILLIS); |
| remainder = (int) (remainder % DateUtils.SECOND_IN_MILLIS); |
| |
| hundredths = remainder / 10; |
| } |
| |
| final char decimalSeparator = DecimalFormatSymbols.getInstance().getDecimalSeparator(); |
| |
| sTimeBuilder.setLength(0); |
| |
| // The display of hours and minutes varies based on maxTime. |
| if (maxTime < TEN_MINUTES) { |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(minutes, 1)); |
| } else if (maxTime < HOUR) { |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(minutes, 2)); |
| } else if (maxTime < TEN_HOURS) { |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(hours, 1)); |
| sTimeBuilder.append(separator); |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(minutes, 2)); |
| } else if (maxTime < HUNDRED_HOURS) { |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(hours, 2)); |
| sTimeBuilder.append(separator); |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(minutes, 2)); |
| } else { |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(hours, 3)); |
| sTimeBuilder.append(separator); |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(minutes, 2)); |
| } |
| |
| // The display of seconds and hundredths-of-a-second is constant. |
| sTimeBuilder.append(separator); |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(seconds, 2)); |
| sTimeBuilder.append(decimalSeparator); |
| sTimeBuilder.append(UiDataModel.getUiDataModel().getFormattedNumber(hundredths, 2)); |
| |
| return sTimeBuilder.toString(); |
| } |
| |
| /** |
| * @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 String formatLapTime(long lapTime, boolean isBinding) { |
| // The longest lap dictates the way the given lapTime must be formatted. |
| final long longestLapTime = Math.max(DataModel.getDataModel().getLongestLapTime(), lapTime); |
| final String formattedTime = formatTime(longestLapTime, lapTime, LRM_SPACE); |
| |
| // If the newly formatted lap time has altered the format, refresh all laps. |
| final int 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 String formatAccumulatedTime(long accumulatedTime, boolean isBinding) { |
| final long totalTime = getStopwatch().getTotalTime(); |
| final long longestAccumulatedTime = Math.max(totalTime, accumulatedTime); |
| final String formattedTime = formatTime(longestAccumulatedTime, accumulatedTime, LRM_SPACE); |
| |
| // If the newly formatted accumulated time has altered the format, refresh all laps. |
| final int newLength = formattedTime.length(); |
| if (!isBinding && mLastFormattedAccumulatedTimeLength != newLength) { |
| mLastFormattedAccumulatedTimeLength = newLength; |
| notifyDataSetChanged(); |
| } |
| |
| return formattedTime; |
| } |
| |
| private Stopwatch getStopwatch() { |
| return DataModel.getDataModel().getStopwatch(); |
| } |
| |
| private List<Lap> getLaps() { |
| return DataModel.getDataModel().getLaps(); |
| } |
| |
| /** |
| * Cache the child views of each lap item view. |
| */ |
| static final class LapItemHolder extends RecyclerView.ViewHolder { |
| |
| private final TextView lapNumber; |
| private final TextView lapTime; |
| private final TextView accumulatedTime; |
| |
| LapItemHolder(View itemView) { |
| super(itemView); |
| |
| lapTime = itemView.findViewById(R.id.lap_time); |
| lapNumber = itemView.findViewById(R.id.lap_number); |
| accumulatedTime = itemView.findViewById(R.id.lap_total); |
| } |
| } |
| } |