/* * Copyright (C) 2009 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; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.app.Fragment; import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Bundle; import androidx.annotation.StringRes; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayout; import androidx.viewpager.widget.ViewPager; import androidx.viewpager.widget.ViewPager.OnPageChangeListener; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; import com.android.deskclock.actionbarmenu.MenuItemControllerFactory; import com.android.deskclock.actionbarmenu.NightModeMenuItemController; import com.android.deskclock.actionbarmenu.OptionsMenuManager; import com.android.deskclock.actionbarmenu.SettingsMenuItemController; import com.android.deskclock.data.DataModel; import com.android.deskclock.data.DataModel.SilentSetting; import com.android.deskclock.data.OnSilentSettingsListener; import com.android.deskclock.events.Events; import com.android.deskclock.provider.Alarm; import com.android.deskclock.uidata.TabListener; import com.android.deskclock.uidata.UiDataModel; import com.android.deskclock.widget.toast.SnackbarManager; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_DRAGGING; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_SETTLING; import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static com.android.deskclock.AnimatorUtils.getScaleAnimator; /** * The main activity of the application which displays 4 different tabs contains alarms, world * clocks, timers and a stopwatch. */ public class DeskClock extends BaseActivity implements FabContainer, LabelDialogFragment.AlarmLabelDialogHandler { /** Models the interesting state of display the {@link #mFab} button may inhabit. */ private enum FabState { SHOWING, HIDE_ARMED, HIDING } /** Coordinates handling of context menu items. */ private final OptionsMenuManager mOptionsMenuManager = new OptionsMenuManager(); /** Shrinks the {@link #mFab}, {@link #mLeftButton} and {@link #mRightButton} to nothing. */ private final AnimatorSet mHideAnimation = new AnimatorSet(); /** Grows the {@link #mFab}, {@link #mLeftButton} and {@link #mRightButton} to natural sizes. */ private final AnimatorSet mShowAnimation = new AnimatorSet(); /** Hides, updates, and shows only the {@link #mFab}; the buttons are untouched. */ private final AnimatorSet mUpdateFabOnlyAnimation = new AnimatorSet(); /** Hides, updates, and shows only the {@link #mLeftButton} and {@link #mRightButton}. */ private final AnimatorSet mUpdateButtonsOnlyAnimation = new AnimatorSet(); /** Automatically starts the {@link #mShowAnimation} after {@link #mHideAnimation} ends. */ private final AnimatorListenerAdapter mAutoStartShowListener = new AutoStartShowListener(); /** Updates the user interface to reflect the selected tab from the backing model. */ private final TabListener mTabChangeWatcher = new TabChangeWatcher(); /** Shows/hides a snackbar explaining which setting is suppressing alarms from firing. */ private final OnSilentSettingsListener mSilentSettingChangeWatcher = new SilentSettingChangeWatcher(); /** Displays a snackbar explaining why alarms may not fire or may fire silently. */ private Runnable mShowSilentSettingSnackbarRunnable; /** The view to which snackbar items are anchored. */ private View mSnackbarAnchor; /** The current display state of the {@link #mFab}. */ private FabState mFabState = FabState.SHOWING; /** The single floating-action button shared across all tabs in the user interface. */ private ImageView mFab; /** The button left of the {@link #mFab} shared across all tabs in the user interface. */ private Button mLeftButton; /** The button right of the {@link #mFab} shared across all tabs in the user interface. */ private Button mRightButton; /** The controller that shows the drop shadow when content is not scrolled to the top. */ private DropShadowController mDropShadowController; /** The ViewPager that pages through the fragments representing the content of the tabs. */ private ViewPager mFragmentTabPager; /** Generates the fragments that are displayed by the {@link #mFragmentTabPager}. */ private FragmentTabPagerAdapter mFragmentTabPagerAdapter; /** The container that stores the tab headers. */ private TabLayout mTabLayout; /** {@code true} when a settings change necessitates recreating this activity. */ private boolean mRecreateActivity; @Override public void onNewIntent(Intent newIntent) { super.onNewIntent(newIntent); // Fragments may query the latest intent for information, so update the intent. setIntent(newIntent); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.desk_clock); mSnackbarAnchor = findViewById(R.id.content); // Configure the toolbar. final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayShowTitleEnabled(false); } // Configure the menu item controllers add behavior to the toolbar. mOptionsMenuManager.addMenuItemController( new NightModeMenuItemController(this), new SettingsMenuItemController(this)); mOptionsMenuManager.addMenuItemController( MenuItemControllerFactory.getInstance().buildMenuItemControllers(this)); // Inflate the menu during creation to avoid a double layout pass. Otherwise, the menu // inflation occurs *after* the initial draw and a second layout pass adds in the menu. onCreateOptionsMenu(toolbar.getMenu()); // Create the tabs that make up the user interface. mTabLayout = (TabLayout) findViewById(R.id.tabs); final int tabCount = UiDataModel.getUiDataModel().getTabCount(); final boolean showTabLabel = getResources().getBoolean(R.bool.showTabLabel); final boolean showTabHorizontally = getResources().getBoolean(R.bool.showTabHorizontally); for (int i = 0; i < tabCount; i++) { final UiDataModel.Tab tabModel = UiDataModel.getUiDataModel().getTab(i); final @StringRes int labelResId = tabModel.getLabelResId(); final TabLayout.Tab tab = mTabLayout.newTab() .setTag(tabModel) .setIcon(tabModel.getIconResId()) .setContentDescription(labelResId); if (showTabLabel) { tab.setText(labelResId); tab.setCustomView(R.layout.tab_item); @SuppressWarnings("ConstantConditions") final TextView text = (TextView) tab.getCustomView() .findViewById(android.R.id.text1); text.setTextColor(mTabLayout.getTabTextColors()); // Bind the icon to the TextView. final Drawable icon = tab.getIcon(); if (showTabHorizontally) { // Remove the icon so it doesn't affect the minimum TabLayout height. tab.setIcon(null); text.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null); } else { text.setCompoundDrawablesRelativeWithIntrinsicBounds(null, icon, null, null); } } mTabLayout.addTab(tab); } // Configure the buttons shared by the tabs. mFab = (ImageView) findViewById(R.id.fab); mLeftButton = (Button) findViewById(R.id.left_button); mRightButton = (Button) findViewById(R.id.right_button); mFab.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { getSelectedDeskClockFragment().onFabClick(mFab); } }); mLeftButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { getSelectedDeskClockFragment().onLeftButtonClick(mLeftButton); } }); mRightButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { getSelectedDeskClockFragment().onRightButtonClick(mRightButton); } }); final long duration = UiDataModel.getUiDataModel().getShortAnimationDuration(); final ValueAnimator hideFabAnimation = getScaleAnimator(mFab, 1f, 0f); final ValueAnimator showFabAnimation = getScaleAnimator(mFab, 0f, 1f); final ValueAnimator leftHideAnimation = getScaleAnimator(mLeftButton, 1f, 0f); final ValueAnimator rightHideAnimation = getScaleAnimator(mRightButton, 1f, 0f); final ValueAnimator leftShowAnimation = getScaleAnimator(mLeftButton, 0f, 1f); final ValueAnimator rightShowAnimation = getScaleAnimator(mRightButton, 0f, 1f); hideFabAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { getSelectedDeskClockFragment().onUpdateFab(mFab); } }); leftHideAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { getSelectedDeskClockFragment().onUpdateFabButtons(mLeftButton, mRightButton); } }); // Build the reusable animations that hide and show the fab and left/right buttons. // These may be used independently or be chained together. mHideAnimation .setDuration(duration) .play(hideFabAnimation) .with(leftHideAnimation) .with(rightHideAnimation); mShowAnimation .setDuration(duration) .play(showFabAnimation) .with(leftShowAnimation) .with(rightShowAnimation); // Build the reusable animation that hides and shows only the fab. mUpdateFabOnlyAnimation .setDuration(duration) .play(showFabAnimation) .after(hideFabAnimation); // Build the reusable animation that hides and shows only the buttons. mUpdateButtonsOnlyAnimation .setDuration(duration) .play(leftShowAnimation) .with(rightShowAnimation) .after(leftHideAnimation) .after(rightHideAnimation); // Customize the view pager. mFragmentTabPagerAdapter = new FragmentTabPagerAdapter(this); mFragmentTabPager = (ViewPager) findViewById(R.id.desk_clock_pager); // Keep all four tabs to minimize jank. mFragmentTabPager.setOffscreenPageLimit(3); // Set Accessibility Delegate to null so view pager doesn't intercept movements and // prevent the fab from being selected. mFragmentTabPager.setAccessibilityDelegate(null); // Mirror changes made to the selected page of the view pager into UiDataModel. mFragmentTabPager.addOnPageChangeListener(new PageChangeWatcher()); mFragmentTabPager.setAdapter(mFragmentTabPagerAdapter); // Mirror changes made to the selected tab into UiDataModel. mTabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) { UiDataModel.getUiDataModel().setSelectedTab((UiDataModel.Tab) tab.getTag()); } @Override public void onTabUnselected(TabLayout.Tab tab) { } @Override public void onTabReselected(TabLayout.Tab tab) { } }); // Honor changes to the selected tab from outside entities. UiDataModel.getUiDataModel().addTabListener(mTabChangeWatcher); } @Override protected void onStart() { super.onStart(); DataModel.getDataModel().addSilentSettingsListener(mSilentSettingChangeWatcher); DataModel.getDataModel().setApplicationInForeground(true); } @Override protected void onResume() { super.onResume(); final View dropShadow = findViewById(R.id.drop_shadow); mDropShadowController = new DropShadowController(dropShadow, UiDataModel.getUiDataModel(), mSnackbarAnchor.findViewById(R.id.tab_hairline)); // ViewPager does not save state; this honors the selected tab in the user interface. updateCurrentTab(); } @Override protected void onPostResume() { super.onPostResume(); if (mRecreateActivity) { mRecreateActivity = false; // A runnable must be posted here or the new DeskClock activity will be recreated in a // paused state, even though it is the foreground activity. mFragmentTabPager.post(new Runnable() { @Override public void run() { recreate(); } }); } } @Override public void onPause() { if (mDropShadowController != null) { mDropShadowController.stop(); mDropShadowController = null; } super.onPause(); } @Override protected void onStop() { DataModel.getDataModel().removeSilentSettingsListener(mSilentSettingChangeWatcher); if (!isChangingConfigurations()) { DataModel.getDataModel().setApplicationInForeground(false); } super.onStop(); } @Override protected void onDestroy() { UiDataModel.getUiDataModel().removeTabListener(mTabChangeWatcher); super.onDestroy(); } @Override public boolean onCreateOptionsMenu(Menu menu) { mOptionsMenuManager.onCreateOptionsMenu(menu); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); mOptionsMenuManager.onPrepareOptionsMenu(menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { return mOptionsMenuManager.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); } /** * Called by the LabelDialogFormat class after the dialog is finished. */ @Override public void onDialogLabelSet(Alarm alarm, String label, String tag) { final Fragment frag = getFragmentManager().findFragmentByTag(tag); if (frag instanceof AlarmClockFragment) { ((AlarmClockFragment) frag).setLabel(alarm, label); } } /** * Listens for keyboard activity for the tab fragments to handle if necessary. A tab may want to * respond to key presses even if they are not currently focused. */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { return getSelectedDeskClockFragment().onKeyDown(keyCode,event) || super.onKeyDown(keyCode, event); } @Override public void updateFab(@UpdateFabFlag int updateType) { final DeskClockFragment f = getSelectedDeskClockFragment(); switch (updateType & FAB_ANIMATION_MASK) { case FAB_SHRINK_AND_EXPAND: mUpdateFabOnlyAnimation.start(); break; case FAB_IMMEDIATE: f.onUpdateFab(mFab); break; case FAB_MORPH: f.onMorphFab(mFab); break; } switch (updateType & FAB_REQUEST_FOCUS_MASK) { case FAB_REQUEST_FOCUS: mFab.requestFocus(); break; } switch (updateType & BUTTONS_ANIMATION_MASK) { case BUTTONS_IMMEDIATE: f.onUpdateFabButtons(mLeftButton, mRightButton); break; case BUTTONS_SHRINK_AND_EXPAND: mUpdateButtonsOnlyAnimation.start(); break; } switch (updateType & BUTTONS_DISABLE_MASK) { case BUTTONS_DISABLE: mLeftButton.setClickable(false); mRightButton.setClickable(false); break; } switch (updateType & FAB_AND_BUTTONS_SHRINK_EXPAND_MASK) { case FAB_AND_BUTTONS_SHRINK: mHideAnimation.start(); break; case FAB_AND_BUTTONS_EXPAND: mShowAnimation.start(); break; } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { // Recreate the activity if any settings have been changed if (requestCode == SettingsMenuItemController.REQUEST_CHANGE_SETTINGS && resultCode == RESULT_OK) { mRecreateActivity = true; } } /** * Configure the {@link #mFragmentTabPager} and {@link #mTabLayout} to display UiDataModel's * selected tab. */ private void updateCurrentTab() { // Fetch the selected tab from the source of truth: UiDataModel. final UiDataModel.Tab selectedTab = UiDataModel.getUiDataModel().getSelectedTab(); // Update the selected tab in the tablayout if it does not agree with UiDataModel. for (int i = 0; i < mTabLayout.getTabCount(); i++) { final TabLayout.Tab tab = mTabLayout.getTabAt(i); if (tab != null && tab.getTag() == selectedTab && !tab.isSelected()) { tab.select(); break; } } // Update the selected fragment in the viewpager if it does not agree with UiDataModel. for (int i = 0; i < mFragmentTabPagerAdapter.getCount(); i++) { final DeskClockFragment fragment = mFragmentTabPagerAdapter.getDeskClockFragment(i); if (fragment.isTabSelected() && mFragmentTabPager.getCurrentItem() != i) { mFragmentTabPager.setCurrentItem(i); break; } } } /** * @return the DeskClockFragment that is currently selected according to UiDataModel */ private DeskClockFragment getSelectedDeskClockFragment() { for (int i = 0; i < mFragmentTabPagerAdapter.getCount(); i++) { final DeskClockFragment fragment = mFragmentTabPagerAdapter.getDeskClockFragment(i); if (fragment.isTabSelected()) { return fragment; } } final UiDataModel.Tab selectedTab = UiDataModel.getUiDataModel().getSelectedTab(); throw new IllegalStateException("Unable to locate selected fragment (" + selectedTab + ")"); } /** * @return a Snackbar that displays the message with the given id for 5 seconds */ private Snackbar createSnackbar(@StringRes int messageId) { return Snackbar.make(mSnackbarAnchor, messageId, 5000 /* duration */); } /** * As the view pager changes the selected page, update the model to record the new selected tab. */ private final class PageChangeWatcher implements OnPageChangeListener { /** The last reported page scroll state; used to detect exotic state changes. */ private int mPriorState = SCROLL_STATE_IDLE; public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { // Only hide the fab when a non-zero drag distance is detected. This prevents // over-scrolling from needlessly hiding the fab. if (mFabState == FabState.HIDE_ARMED && positionOffsetPixels != 0) { mFabState = FabState.HIDING; mHideAnimation.start(); } } @Override public void onPageScrollStateChanged(int state) { if (mPriorState == SCROLL_STATE_IDLE && state == SCROLL_STATE_SETTLING) { // The user has tapped a tab button; play the hide and show animations linearly. mHideAnimation.addListener(mAutoStartShowListener); mHideAnimation.start(); mFabState = FabState.HIDING; } else if (mPriorState == SCROLL_STATE_SETTLING && state == SCROLL_STATE_DRAGGING) { // The user has interrupted settling on a tab and the fab button must be re-hidden. if (mShowAnimation.isStarted()) { mShowAnimation.cancel(); } if (mHideAnimation.isStarted()) { // Let the hide animation finish naturally; don't auto show when it ends. mHideAnimation.removeListener(mAutoStartShowListener); } else { // Start and immediately end the hide animation to jump to the hidden state. mHideAnimation.start(); mHideAnimation.end(); } mFabState = FabState.HIDING; } else if (state != SCROLL_STATE_DRAGGING && mFabState == FabState.HIDING) { // The user has lifted their finger; show the buttons now or after hide ends. if (mHideAnimation.isStarted()) { // Finish the hide animation and then start the show animation. mHideAnimation.addListener(mAutoStartShowListener); } else { updateFab(FAB_AND_BUTTONS_IMMEDIATE); mShowAnimation.start(); // The animation to show the fab has begun; update the state to showing. mFabState = FabState.SHOWING; } } else if (state == SCROLL_STATE_DRAGGING) { // The user has started a drag so arm the hide animation. mFabState = FabState.HIDE_ARMED; } // Update the last known state. mPriorState = state; } @Override public void onPageSelected(int position) { mFragmentTabPagerAdapter.getDeskClockFragment(position).selectTab(); } } /** * If this listener is attached to {@link #mHideAnimation} when it ends, the corresponding * {@link #mShowAnimation} is automatically started. */ private final class AutoStartShowListener extends AnimatorListenerAdapter { @Override public void onAnimationEnd(Animator animation) { // Prepare the hide animation for its next use; by default do not auto-show after hide. mHideAnimation.removeListener(mAutoStartShowListener); // Update the buttons now that they are no longer visible. updateFab(FAB_AND_BUTTONS_IMMEDIATE); // Automatically start the grow animation now that shrinking is complete. mShowAnimation.start(); // The animation to show the fab has begun; update the state to showing. mFabState = FabState.SHOWING; } } /** * Shows/hides a snackbar as silencing settings are enabled/disabled. */ private final class SilentSettingChangeWatcher implements OnSilentSettingsListener { @Override public void onSilentSettingsChange(SilentSetting before, SilentSetting after) { if (mShowSilentSettingSnackbarRunnable != null) { mSnackbarAnchor.removeCallbacks(mShowSilentSettingSnackbarRunnable); mShowSilentSettingSnackbarRunnable = null; } if (after == null) { SnackbarManager.dismiss(); } else { mShowSilentSettingSnackbarRunnable = new ShowSilentSettingSnackbarRunnable(after); mSnackbarAnchor.postDelayed(mShowSilentSettingSnackbarRunnable, SECOND_IN_MILLIS); } } } /** * Displays a snackbar that indicates a system setting is currently silencing alarms. */ private final class ShowSilentSettingSnackbarRunnable implements Runnable { private final SilentSetting mSilentSetting; private ShowSilentSettingSnackbarRunnable(SilentSetting silentSetting) { mSilentSetting = silentSetting; } public void run() { // Create a snackbar with a message explaining the setting that is silencing alarms. final Snackbar snackbar = createSnackbar(mSilentSetting.getLabelResId()); // Set the associated corrective action if one exists. if (mSilentSetting.isActionEnabled(DeskClock.this)) { final int actionResId = mSilentSetting.getActionResId(); snackbar.setAction(actionResId, mSilentSetting.getActionListener()); } SnackbarManager.show(snackbar); } } /** * As the model reports changes to the selected tab, update the user interface. */ private final class TabChangeWatcher implements TabListener { @Override public void selectedTabChanged(UiDataModel.Tab oldSelectedTab, UiDataModel.Tab newSelectedTab) { // Update the view pager and tab layout to agree with the model. updateCurrentTab(); // Avoid sending events for the initial tab selection on launch and re-selecting a tab // after a configuration change. if (DataModel.getDataModel().isApplicationInForeground()) { switch (newSelectedTab) { case ALARMS: Events.sendAlarmEvent(R.string.action_show, R.string.label_deskclock); break; case CLOCKS: Events.sendClockEvent(R.string.action_show, R.string.label_deskclock); break; case TIMERS: Events.sendTimerEvent(R.string.action_show, R.string.label_deskclock); break; case STOPWATCH: Events.sendStopwatchEvent(R.string.action_show, R.string.label_deskclock); break; } } // If the hide animation has already completed, the buttons must be updated now when the // new tab is known. Otherwise they are updated at the end of the hide animation. if (!mHideAnimation.isStarted()) { updateFab(FAB_AND_BUTTONS_IMMEDIATE); } } } }