/* * Copyright (C) 2014 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.timer; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.viewpager.widget.ViewPager; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.animation.AccelerateInterpolator; import android.view.animation.DecelerateInterpolator; import android.widget.Button; import android.widget.ImageView; import com.android.deskclock.AnimatorUtils; import com.android.deskclock.DeskClock; import com.android.deskclock.DeskClockFragment; import com.android.deskclock.R; import com.android.deskclock.Utils; import com.android.deskclock.data.DataModel; import com.android.deskclock.data.Timer; import com.android.deskclock.data.TimerListener; import com.android.deskclock.data.TimerStringFormatter; import com.android.deskclock.events.Events; import com.android.deskclock.uidata.UiDataModel; import java.io.Serializable; import java.util.Arrays; import static android.view.View.ALPHA; import static android.view.View.GONE; import static android.view.View.INVISIBLE; import static android.view.View.TRANSLATION_Y; import static android.view.View.VISIBLE; import static com.android.deskclock.uidata.UiDataModel.Tab.TIMERS; /** * Displays a vertical list of timers in all states. */ public final class TimerFragment extends DeskClockFragment { private static final String EXTRA_TIMER_SETUP = "com.android.deskclock.action.TIMER_SETUP"; private static final String KEY_TIMER_SETUP_STATE = "timer_setup_input"; /** Notified when the user swipes vertically to change the visible timer. */ private final TimerPageChangeListener mTimerPageChangeListener = new TimerPageChangeListener(); /** Scheduled to update the timers while at least one is running. */ private final Runnable mTimeUpdateRunnable = new TimeUpdateRunnable(); /** Updates the {@link #mPageIndicators} in response to timers being added or removed. */ private final TimerListener mTimerWatcher = new TimerWatcher(); private TimerSetupView mCreateTimerView; private ViewPager mViewPager; private TimerPagerAdapter mAdapter; private View mTimersView; private View mCurrentView; private ImageView[] mPageIndicators; private Serializable mTimerSetupState; /** {@code true} while this fragment is creating a new timer; {@code false} otherwise. */ private boolean mCreatingTimer; /** * @return an Intent that selects the timers tab with the setup screen for a new timer in place. */ public static Intent createTimerSetupIntent(Context context) { return new Intent(context, DeskClock.class).putExtra(EXTRA_TIMER_SETUP, true); } /** The public no-arg constructor required by all fragments. */ public TimerFragment() { super(TIMERS); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.timer_fragment, container, false); mAdapter = new TimerPagerAdapter(getFragmentManager()); mViewPager = (ViewPager) view.findViewById(R.id.vertical_view_pager); mViewPager.setAdapter(mAdapter); mViewPager.addOnPageChangeListener(mTimerPageChangeListener); mTimersView = view.findViewById(R.id.timer_view); mCreateTimerView = (TimerSetupView) view.findViewById(R.id.timer_setup); mCreateTimerView.setFabContainer(this); mPageIndicators = new ImageView[] { (ImageView) view.findViewById(R.id.page_indicator0), (ImageView) view.findViewById(R.id.page_indicator1), (ImageView) view.findViewById(R.id.page_indicator2), (ImageView) view.findViewById(R.id.page_indicator3) }; DataModel.getDataModel().addTimerListener(mAdapter); DataModel.getDataModel().addTimerListener(mTimerWatcher); // If timer setup state is present, retrieve it to be later honored. if (savedInstanceState != null) { mTimerSetupState = savedInstanceState.getSerializable(KEY_TIMER_SETUP_STATE); } return view; } @Override public void onStart() { super.onStart(); // Initialize the page indicators. updatePageIndicators(); boolean createTimer = false; int showTimerId = -1; // Examine the intent of the parent activity to determine which view to display. final Intent intent = getActivity().getIntent(); if (intent != null) { // These extras are single-use; remove them after honoring them. createTimer = intent.getBooleanExtra(EXTRA_TIMER_SETUP, false); intent.removeExtra(EXTRA_TIMER_SETUP); showTimerId = intent.getIntExtra(TimerService.EXTRA_TIMER_ID, -1); intent.removeExtra(TimerService.EXTRA_TIMER_ID); } // Choose the view to display in this fragment. if (showTimerId != -1) { // A specific timer must be shown; show the list of timers. showTimersView(FAB_AND_BUTTONS_IMMEDIATE); } else if (!hasTimers() || createTimer || mTimerSetupState != null) { // No timers exist, a timer is being created, or the last view was timer setup; // show the timer setup view. showCreateTimerView(FAB_AND_BUTTONS_IMMEDIATE); if (mTimerSetupState != null) { mCreateTimerView.setState(mTimerSetupState); mTimerSetupState = null; } } else { // Otherwise, default to showing the list of timers. showTimersView(FAB_AND_BUTTONS_IMMEDIATE); } // If the intent did not specify a timer to show, show the last timer that expired. if (showTimerId == -1) { final Timer timer = DataModel.getDataModel().getMostRecentExpiredTimer(); showTimerId = timer == null ? -1 : timer.getId(); } // If a specific timer should be displayed, display the corresponding timer tab. if (showTimerId != -1) { final Timer timer = DataModel.getDataModel().getTimer(showTimerId); if (timer != null) { final int index = DataModel.getDataModel().getTimers().indexOf(timer); mViewPager.setCurrentItem(index); } } } @Override public void onResume() { super.onResume(); // We may have received a new intent while paused. final Intent intent = getActivity().getIntent(); if (intent != null && intent.hasExtra(TimerService.EXTRA_TIMER_ID)) { // This extra is single-use; remove after honoring it. final int showTimerId = intent.getIntExtra(TimerService.EXTRA_TIMER_ID, -1); intent.removeExtra(TimerService.EXTRA_TIMER_ID); final Timer timer = DataModel.getDataModel().getTimer(showTimerId); if (timer != null) { // A specific timer must be shown; show the list of timers. final int index = DataModel.getDataModel().getTimers().indexOf(timer); mViewPager.setCurrentItem(index); animateToView(mTimersView, null, false); } } } @Override public void onStop() { super.onStop(); // Stop updating the timers when this fragment is no longer visible. stopUpdatingTime(); } @Override public void onDestroyView() { super.onDestroyView(); DataModel.getDataModel().removeTimerListener(mAdapter); DataModel.getDataModel().removeTimerListener(mTimerWatcher); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); // If the timer creation view is visible, store the input for later restoration. if (mCurrentView == mCreateTimerView) { mTimerSetupState = mCreateTimerView.getState(); outState.putSerializable(KEY_TIMER_SETUP_STATE, mTimerSetupState); } } private void updateFab(@NonNull ImageView fab, boolean animate) { if (mCurrentView == mTimersView) { final Timer timer = getTimer(); if (timer == null) { fab.setVisibility(INVISIBLE); return; } fab.setVisibility(VISIBLE); switch (timer.getState()) { case RUNNING: if (animate) { fab.setImageResource(R.drawable.ic_play_pause_animation); } else { fab.setImageResource(R.drawable.ic_play_pause); } fab.setContentDescription(fab.getResources().getString(R.string.timer_stop)); break; case RESET: if (animate) { fab.setImageResource(R.drawable.ic_stop_play_animation); } else { fab.setImageResource(R.drawable.ic_pause_play); } fab.setContentDescription(fab.getResources().getString(R.string.timer_start)); break; case PAUSED: if (animate) { fab.setImageResource(R.drawable.ic_pause_play_animation); } else { fab.setImageResource(R.drawable.ic_pause_play); } fab.setContentDescription(fab.getResources().getString(R.string.timer_start)); break; case MISSED: case EXPIRED: fab.setImageResource(R.drawable.ic_stop_white_24dp); fab.setContentDescription(fab.getResources().getString(R.string.timer_stop)); break; } } else if (mCurrentView == mCreateTimerView) { if (mCreateTimerView.hasValidInput()) { fab.setImageResource(R.drawable.ic_start_white_24dp); fab.setContentDescription(fab.getResources().getString(R.string.timer_start)); fab.setVisibility(VISIBLE); } else { fab.setContentDescription(null); fab.setVisibility(INVISIBLE); } } } @Override public void onUpdateFab(@NonNull ImageView fab) { updateFab(fab, false); } @Override public void onMorphFab(@NonNull ImageView fab) { // Update the fab's drawable to match the current timer state. updateFab(fab, Utils.isNOrLater()); // Animate the drawable. AnimatorUtils.startDrawableAnimation(fab); } @Override public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) { if (mCurrentView == mTimersView) { left.setClickable(true); left.setText(R.string.timer_delete); left.setContentDescription(left.getResources().getString(R.string.timer_delete)); left.setVisibility(VISIBLE); right.setClickable(true); right.setText(R.string.timer_add_timer); right.setContentDescription(right.getResources().getString(R.string.timer_add_timer)); right.setVisibility(VISIBLE); } else if (mCurrentView == mCreateTimerView) { left.setClickable(true); left.setText(R.string.timer_cancel); left.setContentDescription(left.getResources().getString(R.string.timer_cancel)); // If no timers yet exist, the user is forced to create the first one. left.setVisibility(hasTimers() ? VISIBLE : INVISIBLE); right.setVisibility(INVISIBLE); } } @Override public void onFabClick(@NonNull ImageView fab) { if (mCurrentView == mTimersView) { final Timer timer = getTimer(); // If no timer is currently showing a fab action is meaningless. if (timer == null) { return; } final Context context = fab.getContext(); final long currentTime = timer.getRemainingTime(); switch (timer.getState()) { case RUNNING: DataModel.getDataModel().pauseTimer(timer); Events.sendTimerEvent(R.string.action_stop, R.string.label_deskclock); if (currentTime > 0) { mTimersView.announceForAccessibility(TimerStringFormatter.formatString( context, R.string.timer_accessibility_stopped, currentTime, true)); } break; case PAUSED: case RESET: DataModel.getDataModel().startTimer(timer); Events.sendTimerEvent(R.string.action_start, R.string.label_deskclock); if (currentTime > 0) { mTimersView.announceForAccessibility(TimerStringFormatter.formatString( context, R.string.timer_accessibility_started, currentTime, true)); } break; case MISSED: case EXPIRED: DataModel.getDataModel().resetOrDeleteTimer(timer, R.string.label_deskclock); break; } } else if (mCurrentView == mCreateTimerView) { mCreatingTimer = true; try { // Create the new timer. final long timerLength = mCreateTimerView.getTimeInMillis(); final Timer timer = DataModel.getDataModel().addTimer(timerLength, "", false); Events.sendTimerEvent(R.string.action_create, R.string.label_deskclock); // Start the new timer. DataModel.getDataModel().startTimer(timer); Events.sendTimerEvent(R.string.action_start, R.string.label_deskclock); // Display the freshly created timer view. mViewPager.setCurrentItem(0); } finally { mCreatingTimer = false; } // Return to the list of timers. animateToView(mTimersView, null, true); } } @Override public void onLeftButtonClick(@NonNull Button left) { if (mCurrentView == mTimersView) { // Clicking the "delete" button. final Timer timer = getTimer(); if (timer == null) { return; } if (mAdapter.getCount() > 1) { animateTimerRemove(timer); } else { animateToView(mCreateTimerView, timer, false); } left.announceForAccessibility(getActivity().getString(R.string.timer_deleted)); } else if (mCurrentView == mCreateTimerView) { // Clicking the "cancel" button on the timer creation page returns to the timers list. mCreateTimerView.reset(); animateToView(mTimersView, null, false); left.announceForAccessibility(getActivity().getString(R.string.timer_canceled)); } } @Override public void onRightButtonClick(@NonNull Button right) { if (mCurrentView != mCreateTimerView) { animateToView(mCreateTimerView, null, true); } } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (mCurrentView == mCreateTimerView) { return mCreateTimerView.onKeyDown(keyCode, event); } return super.onKeyDown(keyCode, event); } /** * Updates the state of the page indicators so they reflect the selected page in the context of * all pages. */ private void updatePageIndicators() { final int page = mViewPager.getCurrentItem(); final int pageIndicatorCount = mPageIndicators.length; final int pageCount = mAdapter.getCount(); final int[] states = computePageIndicatorStates(page, pageIndicatorCount, pageCount); for (int i = 0; i < states.length; i++) { final int state = states[i]; final ImageView pageIndicator = mPageIndicators[i]; if (state == 0) { pageIndicator.setVisibility(GONE); } else { pageIndicator.setVisibility(VISIBLE); pageIndicator.setImageResource(state); } } } /** * @param page the selected page; value between 0 and {@code pageCount} * @param pageIndicatorCount the number of indicators displaying the {@code page} location * @param pageCount the number of pages that exist * @return an array of length {@code pageIndicatorCount} specifying which image to display for * each page indicator or 0 if the page indicator should be hidden */ @VisibleForTesting static int[] computePageIndicatorStates(int page, int pageIndicatorCount, int pageCount) { // Compute the number of page indicators that will be visible. final int rangeSize = Math.min(pageIndicatorCount, pageCount); // Compute the inclusive range of pages to indicate centered around the selected page. int rangeStart = page - (rangeSize / 2); int rangeEnd = rangeStart + rangeSize - 1; // Clamp the range of pages if they extend beyond the last page. if (rangeEnd >= pageCount) { rangeEnd = pageCount - 1; rangeStart = rangeEnd - rangeSize + 1; } // Clamp the range of pages if they extend beyond the first page. if (rangeStart < 0) { rangeStart = 0; rangeEnd = rangeSize - 1; } // Build the result with all page indicators initially hidden. final int[] states = new int[pageIndicatorCount]; Arrays.fill(states, 0); // If 0 or 1 total pages exist, all page indicators must remain hidden. if (rangeSize < 2) { return states; } // Initialize the visible page indicators to be dark. Arrays.fill(states, 0, rangeSize, R.drawable.ic_swipe_circle_dark); // If more pages exist before the first page indicator, make it a fade-in gradient. if (rangeStart > 0) { states[0] = R.drawable.ic_swipe_circle_top; } // If more pages exist after the last page indicator, make it a fade-out gradient. if (rangeEnd < pageCount - 1) { states[rangeSize - 1] = R.drawable.ic_swipe_circle_bottom; } // Set the indicator of the selected page to be light. states[page - rangeStart] = R.drawable.ic_swipe_circle_light; return states; } /** * Display the view that creates a new timer. */ private void showCreateTimerView(int updateTypes) { // Stop animating the timers. stopUpdatingTime(); // Show the creation view; hide the timer view. mTimersView.setVisibility(GONE); mCreateTimerView.setVisibility(VISIBLE); // Record the fact that the create view is visible. mCurrentView = mCreateTimerView; // Update the fab and buttons. updateFab(updateTypes); } /** * Display the view that lists all existing timers. */ private void showTimersView(int updateTypes) { // Clear any defunct timer creation state; the next timer creation starts fresh. mTimerSetupState = null; // Show the timer view; hide the creation view. mTimersView.setVisibility(VISIBLE); mCreateTimerView.setVisibility(GONE); // Record the fact that the create view is visible. mCurrentView = mTimersView; // Update the fab and buttons. updateFab(updateTypes); // Start animating the timers. startUpdatingTime(); } /** * @param timerToRemove the timer to be removed during the animation */ private void animateTimerRemove(final Timer timerToRemove) { final long duration = UiDataModel.getUiDataModel().getShortAnimationDuration(); final Animator fadeOut = ObjectAnimator.ofFloat(mViewPager, ALPHA, 1, 0); fadeOut.setDuration(duration); fadeOut.setInterpolator(new DecelerateInterpolator()); fadeOut.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { DataModel.getDataModel().removeTimer(timerToRemove); Events.sendTimerEvent(R.string.action_delete, R.string.label_deskclock); } }); final Animator fadeIn = ObjectAnimator.ofFloat(mViewPager, ALPHA, 0, 1); fadeIn.setDuration(duration); fadeIn.setInterpolator(new AccelerateInterpolator()); final AnimatorSet animatorSet = new AnimatorSet(); animatorSet.play(fadeOut).before(fadeIn); animatorSet.start(); } /** * @param toView one of {@link #mTimersView} or {@link #mCreateTimerView} * @param timerToRemove the timer to be removed during the animation; {@code null} if no timer * should be removed * @param animateDown {@code true} if the views should animate upwards, otherwise downwards */ private void animateToView(final View toView, final Timer timerToRemove, final boolean animateDown) { if (mCurrentView == toView) { return; } final boolean toTimers = toView == mTimersView; if (toTimers) { mTimersView.setVisibility(VISIBLE); } else { mCreateTimerView.setVisibility(VISIBLE); } // Avoid double-taps by enabling/disabling the set of buttons active on the new view. updateFab(BUTTONS_DISABLE); final long animationDuration = UiDataModel.getUiDataModel().getLongAnimationDuration(); final ViewTreeObserver viewTreeObserver = toView.getViewTreeObserver(); viewTreeObserver.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { if (viewTreeObserver.isAlive()) { viewTreeObserver.removeOnPreDrawListener(this); } final View view = mTimersView.findViewById(R.id.timer_time); final float distanceY = view != null ? view.getHeight() + view.getY() : 0; final float translationDistance = animateDown ? distanceY : -distanceY; toView.setTranslationY(-translationDistance); mCurrentView.setTranslationY(0f); toView.setAlpha(0f); mCurrentView.setAlpha(1f); final Animator translateCurrent = ObjectAnimator.ofFloat(mCurrentView, TRANSLATION_Y, translationDistance); final Animator translateNew = ObjectAnimator.ofFloat(toView, TRANSLATION_Y, 0f); final AnimatorSet translationAnimatorSet = new AnimatorSet(); translationAnimatorSet.playTogether(translateCurrent, translateNew); translationAnimatorSet.setDuration(animationDuration); translationAnimatorSet.setInterpolator(AnimatorUtils.INTERPOLATOR_FAST_OUT_SLOW_IN); final Animator fadeOutAnimator = ObjectAnimator.ofFloat(mCurrentView, ALPHA, 0f); fadeOutAnimator.setDuration(animationDuration / 2); fadeOutAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { super.onAnimationStart(animation); // The fade-out animation and fab-shrinking animation should run together. updateFab(FAB_AND_BUTTONS_SHRINK); } @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); if (toTimers) { showTimersView(FAB_AND_BUTTONS_EXPAND); // Reset the state of the create view. mCreateTimerView.reset(); } else { showCreateTimerView(FAB_AND_BUTTONS_EXPAND); } if (timerToRemove != null) { DataModel.getDataModel().removeTimer(timerToRemove); Events.sendTimerEvent(R.string.action_delete, R.string.label_deskclock); } // Update the fab and button states now that the correct view is visible and // before the animation to expand the fab and buttons starts. updateFab(FAB_AND_BUTTONS_IMMEDIATE); } }); final Animator fadeInAnimator = ObjectAnimator.ofFloat(toView, ALPHA, 1f); fadeInAnimator.setDuration(animationDuration / 2); fadeInAnimator.setStartDelay(animationDuration / 2); final AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(fadeOutAnimator, fadeInAnimator, translationAnimatorSet); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); mTimersView.setTranslationY(0f); mCreateTimerView.setTranslationY(0f); mTimersView.setAlpha(1f); mCreateTimerView.setAlpha(1f); } }); animatorSet.start(); return true; } }); } private boolean hasTimers() { return mAdapter.getCount() > 0; } private Timer getTimer() { if (mViewPager == null) { return null; } return mAdapter.getCount() == 0 ? null : mAdapter.getTimer(mViewPager.getCurrentItem()); } private void startUpdatingTime() { // Ensure only one copy of the runnable is ever scheduled by first stopping updates. stopUpdatingTime(); mViewPager.post(mTimeUpdateRunnable); } private void stopUpdatingTime() { mViewPager.removeCallbacks(mTimeUpdateRunnable); } /** * Periodically refreshes the state of each timer. */ private class TimeUpdateRunnable implements Runnable { @Override public void run() { final long startTime = SystemClock.elapsedRealtime(); // If no timers require continuous updates, avoid scheduling the next update. if (!mAdapter.updateTime()) { return; } final long endTime = SystemClock.elapsedRealtime(); // Try to maintain a consistent period of time between redraws. final long delay = Math.max(0, startTime + 20 - endTime); mTimersView.postDelayed(this, delay); } } /** * Update the page indicators and fab in response to a new timer becoming visible. */ private class TimerPageChangeListener extends ViewPager.SimpleOnPageChangeListener { @Override public void onPageSelected(int position) { updatePageIndicators(); updateFab(FAB_AND_BUTTONS_IMMEDIATE); // Showing a new timer page may introduce a timer requiring continuous updates. startUpdatingTime(); } @Override public void onPageScrollStateChanged(int state) { // Teasing a neighboring timer may introduce a timer requiring continuous updates. if (state == ViewPager.SCROLL_STATE_DRAGGING) { startUpdatingTime(); } } } /** * Update the page indicators in response to timers being added or removed. * Update the fab in response to the visible timer changing. */ private class TimerWatcher implements TimerListener { @Override public void timerAdded(Timer timer) { updatePageIndicators(); // If the timer is being created via this fragment avoid adjusting the fab. // Timer setup view is about to be animated away in response to this timer creation. // Changes to the fab immediately preceding that animation are jarring. if (!mCreatingTimer) { updateFab(FAB_AND_BUTTONS_IMMEDIATE); } } @Override public void timerUpdated(Timer before, Timer after) { // If the timer started, animate the timers. if (before.isReset() && !after.isReset()) { startUpdatingTime(); } // Fetch the index of the change. final int index = DataModel.getDataModel().getTimers().indexOf(after); // If the timer just expired but is not displayed, display it now. if (!before.isExpired() && after.isExpired() && index != mViewPager.getCurrentItem()) { mViewPager.setCurrentItem(index, true); } else if (mCurrentView == mTimersView && index == mViewPager.getCurrentItem()) { // Morph the fab from its old state to new state if necessary. if (before.getState() != after.getState() && !(before.isPaused() && after.isReset())) { updateFab(FAB_MORPH); } } } @Override public void timerRemoved(Timer timer) { updatePageIndicators(); updateFab(FAB_AND_BUTTONS_IMMEDIATE); if (mCurrentView == mTimersView && mAdapter.getCount() == 0) { animateToView(mCreateTimerView, null, false); } } } }