blob: 8de47f226e8b9ccb554ea9712097ee8a0fba4116 [file] [log] [blame]
/*
* 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 static android.R.attr.state_activated;
import static android.R.attr.state_pressed;
import static android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM;
import static android.view.View.GONE;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
import static com.android.deskclock.uidata.UiDataModel.Tab.STOPWATCH;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.transition.TransitionManager;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.core.graphics.ColorUtils;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import com.android.deskclock.AnimatorUtils;
import com.android.deskclock.DeskClockFragment;
import com.android.deskclock.LogUtils;
import com.android.deskclock.R;
import com.android.deskclock.StopwatchTextController;
import com.android.deskclock.ThemeUtils;
import com.android.deskclock.Utils;
import com.android.deskclock.data.DataModel;
import com.android.deskclock.data.Lap;
import com.android.deskclock.data.Stopwatch;
import com.android.deskclock.data.StopwatchListener;
import com.android.deskclock.events.Events;
import com.android.deskclock.uidata.TabListener;
import com.android.deskclock.uidata.UiDataModel;
import com.android.deskclock.uidata.UiDataModel.Tab;
/**
* Fragment that shows the stopwatch and recorded laps.
*/
public final class StopwatchFragment extends DeskClockFragment {
/** Milliseconds between redraws while running. */
private static final int REDRAW_PERIOD_RUNNING = 25;
/** Milliseconds between redraws while paused. */
private static final int REDRAW_PERIOD_PAUSED = 500;
/** Keep the screen on when this tab is selected. */
private final TabListener mTabWatcher = new TabWatcher();
/** Scheduled to update the stopwatch time and current lap time while stopwatch is running. */
private final Runnable mTimeUpdateRunnable = new TimeUpdateRunnable();
/** Updates the user interface in response to stopwatch changes. */
private final StopwatchListener mStopwatchWatcher = new StopwatchWatcher();
/** Draws a gradient over the bottom of the {@link #mLapsList} to reduce clash with the fab. */
private GradientItemDecoration mGradientItemDecoration;
/** The data source for {@link #mLapsList}. */
private LapsAdapter mLapsAdapter;
/** The layout manager for the {@link #mLapsAdapter}. */
private LinearLayoutManager mLapsLayoutManager;
/** Draws the reference lap while the stopwatch is running. */
private StopwatchCircleView mTime;
/** The View containing both TextViews of the stopwatch. */
private View mStopwatchWrapper;
/** Displays the recorded lap times. */
private RecyclerView mLapsList;
/** Displays the current stopwatch time (seconds and above only). */
private TextView mMainTimeText;
/** Displays the current stopwatch time (hundredths only). */
private TextView mHundredthsTimeText;
/** Formats and displays the text in the stopwatch. */
private StopwatchTextController mStopwatchTextController;
/** The public no-arg constructor required by all fragments. */
public StopwatchFragment() {
super(STOPWATCH);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) {
mLapsAdapter = new LapsAdapter(getActivity());
mLapsLayoutManager = new LinearLayoutManager(getActivity());
mGradientItemDecoration = new GradientItemDecoration(getActivity());
final View v = inflater.inflate(R.layout.stopwatch_fragment, container, false);
mTime = v.findViewById(R.id.stopwatch_circle);
mLapsList = v.findViewById(R.id.laps_list);
((SimpleItemAnimator) mLapsList.getItemAnimator()).setSupportsChangeAnimations(false);
mLapsList.setLayoutManager(mLapsLayoutManager);
// In landscape layouts, the laps list can reach the top of the screen and thus can cause
// a drop shadow to appear. The same is not true for portrait landscapes.
if (Utils.isLandscape(getActivity())) {
final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher();
mLapsList.addOnLayoutChangeListener(scrollPositionWatcher);
mLapsList.addOnScrollListener(scrollPositionWatcher);
} else {
mLapsList.addItemDecoration(mGradientItemDecoration);
setTabScrolledToTop(true);
}
mLapsList.setAdapter(mLapsAdapter);
// Timer text serves as a virtual start/stop button.
mMainTimeText = v.findViewById(R.id.stopwatch_time_text);
mHundredthsTimeText = v.findViewById(R.id.stopwatch_hundredths_text);
mStopwatchTextController = new StopwatchTextController(mMainTimeText, mHundredthsTimeText);
mStopwatchWrapper = v.findViewById(R.id.stopwatch_time_wrapper);
DataModel.getDataModel().addStopwatchListener(mStopwatchWatcher);
mStopwatchWrapper.setOnClickListener(new TimeClickListener());
if (mTime != null) {
mStopwatchWrapper.setOnTouchListener(new CircleTouchListener());
}
final Context c = mMainTimeText.getContext();
final int colorAccent = ThemeUtils.resolveColor(c, R.attr.colorAccent);
final int textColorPrimary = ThemeUtils.resolveColor(c, android.R.attr.textColorPrimary);
final ColorStateList timeTextColor = new ColorStateList(
new int[][] { { -state_activated, -state_pressed }, {} },
new int[] { textColorPrimary, colorAccent });
mMainTimeText.setTextColor(timeTextColor);
mHundredthsTimeText.setTextColor(timeTextColor);
return v;
}
@Override
public void onStart() {
super.onStart();
final Activity activity = getActivity();
final Intent intent = activity.getIntent();
if (intent != null) {
final String action = intent.getAction();
if (StopwatchService.ACTION_START_STOPWATCH.equals(action)) {
DataModel.getDataModel().startStopwatch();
// Consume the intent
activity.setIntent(null);
} else if (StopwatchService.ACTION_PAUSE_STOPWATCH.equals(action)) {
DataModel.getDataModel().pauseStopwatch();
// Consume the intent
activity.setIntent(null);
}
}
// Conservatively assume the data in the adapter has changed while the fragment was paused.
mLapsAdapter.notifyDataSetChanged();
// Synchronize the user interface with the data model.
updateUI(FAB_AND_BUTTONS_IMMEDIATE);
// Start watching for page changes away from this fragment.
UiDataModel.getUiDataModel().addTabListener(mTabWatcher);
}
@Override
public void onStop() {
super.onStop();
// Stop all updates while the fragment is not visible.
stopUpdatingTime();
// Stop watching for page changes away from this fragment.
UiDataModel.getUiDataModel().removeTabListener(mTabWatcher);
// Release the wake lock if it is currently held.
releaseWakeLock();
}
@Override
public void onDestroyView() {
super.onDestroyView();
DataModel.getDataModel().removeStopwatchListener(mStopwatchWatcher);
}
@Override
public void onFabClick(@NonNull ImageView fab) {
toggleStopwatchState();
}
@Override
public void onLeftButtonClick(@NonNull ImageView left) {
doReset();
}
@Override
public void onRightButtonClick(@NonNull ImageView right) {
switch (getStopwatch().getState()) {
case RUNNING:
doAddLap();
break;
case PAUSED:
doShare();
break;
}
}
private void updateFab(@NonNull ImageView fab) {
if (getStopwatch().isRunning()) {
fab.setImageResource(R.drawable.ic_play_pause);
fab.setContentDescription(fab.getResources().getString(R.string.sw_pause_button));
} else {
fab.setImageResource(R.drawable.ic_pause_play);
fab.setContentDescription(fab.getResources().getString(R.string.sw_start_button));
}
fab.setVisibility(VISIBLE);
}
@Override
public void onUpdateFab(@NonNull ImageView fab) {
updateFab(fab);
}
@Override
public void onMorphFab(@NonNull ImageView fab) {
// Update the fab's drawable to match the current timer state.
updateFab(fab);
// Animate the drawable.
AnimatorUtils.startDrawableAnimation(fab);
}
@Override
public void onUpdateFabButtons(@NonNull ImageView left, @NonNull ImageView right) {
final Resources resources = getResources();
final Context context = left.getContext();
final Drawable icReset = Utils.getVectorDrawable(context, R.drawable.ic_delete);
left.setClickable(true);
left.setImageDrawable(icReset);
left.setContentDescription(resources.getString(R.string.sw_reset_button));
switch (getStopwatch().getState()) {
case RESET:
left.setVisibility(INVISIBLE);
right.setClickable(true);
right.setVisibility(INVISIBLE);
break;
case RUNNING:
left.setVisibility(VISIBLE);
final boolean canRecordLaps = canRecordMoreLaps();
final Drawable icLap = Utils.getVectorDrawable(context,
R.drawable.ic_stopwatch_black);
right.setImageDrawable(icLap);
right.setContentDescription(resources.getString(R.string.sw_lap_button));
right.setClickable(canRecordLaps);
right.setVisibility(canRecordLaps ? VISIBLE : INVISIBLE);
break;
case PAUSED:
left.setVisibility(VISIBLE);
right.setClickable(true);
right.setVisibility(VISIBLE);
final Drawable icShare = Utils.getVectorDrawable(context, R.drawable.ic_share);
right.setImageDrawable(icShare);
right.setContentDescription(resources.getString(R.string.sw_share_button));
break;
}
}
@Override
public int getFabTargetVisibility() {
return View.VISIBLE;
}
/**
* Start the stopwatch.
*/
private void doStart() {
Events.sendStopwatchEvent(R.string.action_start, R.string.label_deskclock);
DataModel.getDataModel().startStopwatch();
}
/**
* Pause the stopwatch.
*/
private void doPause() {
Events.sendStopwatchEvent(R.string.action_pause, R.string.label_deskclock);
DataModel.getDataModel().pauseStopwatch();
}
/**
* Reset the stopwatch.
*/
private void doReset() {
final Stopwatch.State priorState = getStopwatch().getState();
Events.sendStopwatchEvent(R.string.action_reset, R.string.label_deskclock);
DataModel.getDataModel().resetStopwatch();
mMainTimeText.setAlpha(1f);
mHundredthsTimeText.setAlpha(1f);
if (priorState == Stopwatch.State.RUNNING) {
updateFab(FAB_MORPH);
}
}
/**
* Send stopwatch time and lap times to an external sharing application.
*/
private void doShare() {
// Disable the fab buttons to avoid double-taps on the share button.
updateFab(BUTTONS_DISABLE);
final String[] subjects = getResources().getStringArray(R.array.sw_share_strings);
final String subject = subjects[(int) (Math.random() * subjects.length)];
final String text = mLapsAdapter.getShareText();
@SuppressLint("InlinedApi")
final Intent shareIntent = new Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
.putExtra(Intent.EXTRA_SUBJECT, subject)
.putExtra(Intent.EXTRA_TEXT, text)
.setType("text/plain");
final Context context = getActivity();
final String title = context.getString(R.string.sw_share_button);
final Intent shareChooserIntent = Intent.createChooser(shareIntent, title);
try {
context.startActivity(shareChooserIntent);
} catch (ActivityNotFoundException anfe) {
LogUtils.e("Cannot share lap data because no suitable receiving Activity exists");
updateFab(BUTTONS_IMMEDIATE);
}
}
/**
* Record and add a new lap ending now.
*/
private void doAddLap() {
Events.sendStopwatchEvent(R.string.action_lap, R.string.label_deskclock);
// Record a new lap.
final Lap lap = mLapsAdapter.addLap();
if (lap == null) {
return;
}
// Update button states.
updateFab(BUTTONS_IMMEDIATE);
if (lap.getLapNumber() == 1) {
// Child views from prior lap sets hang around and blit to the screen when adding the
// first lap of the subsequent lap set. Remove those superfluous children here manually
// to ensure they aren't seen as the first lap is drawn.
mLapsList.removeAllViewsInLayout();
if (mTime != null) {
// Start animating the reference lap.
mTime.update();
}
// Recording the first lap transitions the UI to display the laps list.
showOrHideLaps(false);
}
// Ensure the newly added lap is visible on screen.
mLapsList.scrollToPosition(0);
}
/**
* Show or hide the list of laps.
*/
private void showOrHideLaps(boolean clearLaps) {
final ViewGroup sceneRoot = (ViewGroup) getView();
if (sceneRoot == null) {
return;
}
TransitionManager.beginDelayedTransition(sceneRoot);
if (clearLaps) {
mLapsAdapter.clearLaps();
}
final boolean lapsVisible = mLapsAdapter.getItemCount() > 0;
mLapsList.setVisibility(lapsVisible ? VISIBLE : GONE);
if (Utils.isPortrait(getActivity())) {
// When the lap list is visible, it includes the bottom padding. When it is absent the
// appropriate bottom padding must be applied to the container.
final Resources res = getResources();
final int bottom = lapsVisible ? 0 : res.getDimensionPixelSize(
R.dimen.fab_container_height);
final int top = sceneRoot.getPaddingTop();
final int left = sceneRoot.getPaddingLeft();
final int right = sceneRoot.getPaddingRight();
sceneRoot.setPadding(left, top, right, bottom);
}
}
private void adjustWakeLock() {
final boolean appInForeground = DataModel.getDataModel().isApplicationInForeground();
if (getStopwatch().isRunning() && isTabSelected() && appInForeground) {
getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
releaseWakeLock();
}
}
private void releaseWakeLock() {
getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
/**
* Either pause or start the stopwatch based on its current state.
*/
private void toggleStopwatchState() {
if (getStopwatch().isRunning()) {
doPause();
} else {
doStart();
}
}
private Stopwatch getStopwatch() {
return DataModel.getDataModel().getStopwatch();
}
private boolean canRecordMoreLaps() {
return DataModel.getDataModel().canAddMoreLaps();
}
/**
* Post the first runnable to update times within the UI. It will reschedule itself as needed.
*/
private void startUpdatingTime() {
// Ensure only one copy of the runnable is ever scheduled by first stopping updates.
stopUpdatingTime();
mMainTimeText.post(mTimeUpdateRunnable);
}
/**
* Remove the runnable that updates times within the UI.
*/
private void stopUpdatingTime() {
mMainTimeText.removeCallbacks(mTimeUpdateRunnable);
}
/**
* Update all time displays based on a single snapshot of the stopwatch progress. This includes
* the stopwatch time drawn in the circle, the current lap time and the total elapsed time in
* the list of laps.
*/
private void updateTime() {
// Compute the total time of the stopwatch.
final Stopwatch stopwatch = getStopwatch();
final long totalTime = stopwatch.getTotalTime();
mStopwatchTextController.setTimeString(totalTime);
// Update the current lap.
final boolean currentLapIsVisible = mLapsLayoutManager.findFirstVisibleItemPosition() == 0;
if (!stopwatch.isReset() && currentLapIsVisible) {
mLapsAdapter.updateCurrentLap(mLapsList, totalTime);
}
}
/**
* Synchronize the UI state with the model data.
*/
private void updateUI(@UpdateFabFlag int updateTypes) {
adjustWakeLock();
// Draw the latest stopwatch and current lap times.
updateTime();
if (mTime != null) {
mTime.update();
}
final Stopwatch stopwatch = getStopwatch();
if (!stopwatch.isReset()) {
startUpdatingTime();
}
// Adjust the visibility of the list of laps.
showOrHideLaps(stopwatch.isReset());
// Update button states.
updateFab(updateTypes);
}
/**
* This runnable periodically updates times throughout the UI. It stops these updates when the
* stopwatch is no longer running.
*/
private final class TimeUpdateRunnable implements Runnable {
@Override
public void run() {
final long startTime = Utils.now();
updateTime();
// Blink text iff the stopwatch is paused and not pressed.
final View touchTarget = mTime != null ? mTime : mStopwatchWrapper;
final Stopwatch stopwatch = getStopwatch();
final boolean blink = stopwatch.isPaused()
&& startTime % 1000 < 500
&& !touchTarget.isPressed();
if (blink) {
mMainTimeText.setAlpha(0f);
mHundredthsTimeText.setAlpha(0f);
} else {
mMainTimeText.setAlpha(1f);
mHundredthsTimeText.setAlpha(1f);
}
if (!stopwatch.isReset()) {
final long period = stopwatch.isPaused()
? REDRAW_PERIOD_PAUSED
: REDRAW_PERIOD_RUNNING;
final long endTime = Utils.now();
final long delay = Math.max(0, startTime + period - endTime);
mMainTimeText.postDelayed(this, delay);
}
}
}
/**
* Acquire or release the wake lock based on the tab state.
*/
private final class TabWatcher implements TabListener {
@Override
public void selectedTabChanged(Tab newSelectedTab) {
adjustWakeLock();
}
}
/**
* Update the user interface in response to a stopwatch change.
*/
private class StopwatchWatcher implements StopwatchListener {
@Override
public void stopwatchUpdated(Stopwatch before, Stopwatch after) {
if (after.isReset()) {
// Ensure the drop shadow is hidden when the stopwatch is reset.
setTabScrolledToTop(true);
if (DataModel.getDataModel().isApplicationInForeground()) {
updateUI(BUTTONS_IMMEDIATE);
}
return;
}
if (DataModel.getDataModel().isApplicationInForeground()) {
updateUI(FAB_MORPH | BUTTONS_IMMEDIATE);
}
}
}
/**
* Toggles stopwatch state when user taps stopwatch.
*/
private final class TimeClickListener implements View.OnClickListener {
@Override
public void onClick(View view) {
if (getStopwatch().isRunning()) {
DataModel.getDataModel().pauseStopwatch();
} else {
DataModel.getDataModel().startStopwatch();
}
}
}
/**
* Checks if the user is pressing inside of the stopwatch circle.
*/
private static final class CircleTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View view, MotionEvent event) {
final int actionMasked = event.getActionMasked();
if (actionMasked != MotionEvent.ACTION_DOWN) {
return false;
}
final float rX = view.getWidth() / 2f;
final float rY = (view.getHeight() - view.getPaddingBottom()) / 2f;
final float r = Math.min(rX, rY);
final float x = event.getX() - rX;
final float y = event.getY() - rY;
final boolean inCircle = Math.pow(x / r, 2.0) + Math.pow(y / r, 2.0) <= 1.0;
// Consume the event if it is outside the circle
return !inCircle;
}
}
/**
* Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls
* the recyclerview or when the size/position of elements within the recyclerview changes.
*/
private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener
implements View.OnLayoutChangeListener {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
setTabScrolledToTop(Utils.isScrolledToTop(mLapsList));
}
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
setTabScrolledToTop(Utils.isScrolledToTop(mLapsList));
}
}
/**
* Draws a tinting gradient over the bottom of the stopwatch laps list. This reduces the
* contrast between floating buttons and the laps list content.
*/
private static final class GradientItemDecoration extends RecyclerView.ItemDecoration {
// 0% - 25% of gradient length -> opacity changes from 0% to 50%
// 25% - 90% of gradient length -> opacity changes from 50% to 100%
// 90% - 100% of gradient length -> opacity remains at 100%
private static final int[] ALPHAS = {
0x00, // 0%
0x1A, // 10%
0x33, // 20%
0x4D, // 30%
0x66, // 40%
0x80, // 50%
0x89, // 53.8%
0x93, // 57.6%
0x9D, // 61.5%
0xA7, // 65.3%
0xB1, // 69.2%
0xBA, // 73.0%
0xC4, // 76.9%
0xCE, // 80.7%
0xD8, // 84.6%
0xE2, // 88.4%
0xEB, // 92.3%
0xF5, // 96.1%
0xFF, // 100%
0xFF, // 100%
0xFF, // 100%
};
/**
* A reusable array of control point colors that define the gradient. It is based on the
* background color of the window and thus recomputed each time that color is changed.
*/
private final int[] mGradientColors = new int[ALPHAS.length];
/** The drawable that produces the tinting gradient effect of this decoration. */
private final GradientDrawable mGradient = new GradientDrawable();
/** The height of the gradient; sized relative to the fab height. */
private final int mGradientHeight;
GradientItemDecoration(Context context) {
mGradient.setOrientation(TOP_BOTTOM);
updateGradientColors(ThemeUtils.resolveColor(context, android.R.attr.windowBackground));
final Resources resources = context.getResources();
mGradientHeight = resources.getDimensionPixelSize(R.dimen.fab_container_height);
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
final int w = parent.getWidth();
final int h = parent.getHeight();
mGradient.setBounds(0, h - mGradientHeight, w, h);
mGradient.draw(c);
}
/**
* Given a {@code baseColor}, compute a gradient of tinted colors that define the fade
* effect to apply to the bottom of the lap list.
*
* @param baseColor a base color to which the gradient tint should be applied
*/
void updateGradientColors(@ColorInt int baseColor) {
// Compute the tinted colors that form the gradient.
for (int i = 0; i < mGradientColors.length; i++) {
mGradientColors[i] = ColorUtils.setAlphaComponent(baseColor, ALPHAS[i]);
}
// Set the gradient colors into the drawable.
mGradient.setColors(mGradientColors);
}
}
}