blob: fcb74854e4ad8d59c990a9e026938ffa6623e62b [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;
import static com.android.deskclock.uidata.UiDataModel.Tab.ALARMS;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.deskclock.alarms.AlarmTimeClickHandler;
import com.android.deskclock.alarms.AlarmUpdateHandler;
import com.android.deskclock.alarms.ScrollHandler;
import com.android.deskclock.alarms.TimePickerDialogFragment;
import com.android.deskclock.alarms.dataadapter.AlarmItemHolder;
import com.android.deskclock.alarms.dataadapter.CollapsedAlarmViewHolder;
import com.android.deskclock.alarms.dataadapter.ExpandedAlarmViewHolder;
import com.android.deskclock.provider.Alarm;
import com.android.deskclock.provider.AlarmInstance;
import com.android.deskclock.uidata.UiDataModel;
import com.android.deskclock.widget.EmptyViewController;
import com.android.deskclock.widget.toast.SnackbarManager;
import com.android.deskclock.widget.toast.ToastManager;
import com.google.android.material.snackbar.Snackbar;
import java.util.ArrayList;
import java.util.List;
/**
* A fragment that displays a list of alarm time and allows interaction with them.
*/
public final class AlarmClockFragment extends DeskClockFragment implements
LoaderManager.LoaderCallbacks<Cursor>,
ScrollHandler,
TimePickerDialogFragment.OnTimeSetListener {
// This extra is used when receiving an intent to create an alarm, but no alarm details
// have been passed in, so the alarm page should start the process of creating a new alarm.
public static final String ALARM_CREATE_NEW_INTENT_EXTRA = "deskclock.create.new";
// This extra is used when receiving an intent to scroll to specific alarm. If alarm
// can not be found, and toast message will pop up that the alarm has be deleted.
public static final String SCROLL_TO_ALARM_INTENT_EXTRA = "deskclock.scroll.to.alarm";
private static final String KEY_EXPANDED_ID = "expandedId";
// Updates "Today/Tomorrow" in the UI when midnight passes.
private final Runnable mMidnightUpdater = new MidnightRunnable();
// Views
private ViewGroup mMainLayout;
private RecyclerView mRecyclerView;
// Data
private CursorLoader mCursorLoader;
private long mScrollToAlarmId = Alarm.INVALID_ID;
private long mExpandedAlarmId = Alarm.INVALID_ID;
private long mCurrentUpdateToken;
// Controllers
private ItemAdapter<AlarmItemHolder> mItemAdapter;
private AlarmUpdateHandler mAlarmUpdateHandler;
private EmptyViewController mEmptyViewController;
private AlarmTimeClickHandler mAlarmTimeClickHandler;
private LinearLayoutManager mLayoutManager;
/**
* The public no-arg constructor required by all fragments.
*/
public AlarmClockFragment() {
super(ALARMS);
}
@Override
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
mCursorLoader = (CursorLoader) LoaderManager.getInstance(this).initLoader(0, null, this);
if (savedState != null) {
mExpandedAlarmId = savedState.getLong(KEY_EXPANDED_ID, Alarm.INVALID_ID);
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
// Inflate the layout for this fragment
final View v = inflater.inflate(R.layout.alarm_clock, container, false);
final Context context = getActivity();
mRecyclerView = v.findViewById(R.id.alarms_recycler_view);
mLayoutManager = new LinearLayoutManager(context) {
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
// We need enough space so after expand/collapse, other items are still
// shown properly. The multiplier was chosen after tests
extraLayoutSpace[0] = 2 * getHeight();
extraLayoutSpace[1] = extraLayoutSpace[0];
}
};
mRecyclerView.setLayoutManager(mLayoutManager);
mMainLayout = v.findViewById(R.id.main);
mAlarmUpdateHandler = new AlarmUpdateHandler(context, this, mMainLayout);
final TextView emptyView = v.findViewById(R.id.alarms_empty_view);
final Drawable noAlarms = Utils.getVectorDrawable(context, R.drawable.ic_noalarms);
emptyView.setCompoundDrawablesWithIntrinsicBounds(null, noAlarms, null, null);
mEmptyViewController = new EmptyViewController(mMainLayout, mRecyclerView, emptyView);
mAlarmTimeClickHandler = new AlarmTimeClickHandler(this, savedState, mAlarmUpdateHandler,
this);
mItemAdapter = new ItemAdapter<>();
mItemAdapter.setHasStableIds();
mItemAdapter.withViewTypes(new CollapsedAlarmViewHolder.Factory(inflater),
null, CollapsedAlarmViewHolder.VIEW_TYPE);
mItemAdapter.withViewTypes(new ExpandedAlarmViewHolder.Factory(context),
null, ExpandedAlarmViewHolder.VIEW_TYPE);
mItemAdapter.setOnItemChangedListener(new ItemAdapter.OnItemChangedListener() {
@Override
public void onItemChanged(ItemAdapter.ItemHolder<?> holder) {
if (((AlarmItemHolder) holder).isExpanded()) {
if (mExpandedAlarmId != holder.itemId) {
// Collapse the prior expanded alarm.
final AlarmItemHolder aih = mItemAdapter.findItemById(mExpandedAlarmId);
if (aih != null) {
aih.collapse();
}
// Record the freshly expanded alarm.
mExpandedAlarmId = holder.itemId;
final RecyclerView.ViewHolder viewHolder =
mRecyclerView.findViewHolderForItemId(mExpandedAlarmId);
if (viewHolder != null) {
smoothScrollTo(viewHolder.getBindingAdapterPosition());
}
}
} else if (mExpandedAlarmId == holder.itemId) {
// The expanded alarm is now collapsed so update the tracking id.
mExpandedAlarmId = Alarm.INVALID_ID;
}
}
@Override
public void onItemChanged(ItemAdapter.ItemHolder<?> holder, Object payload) {
/* No additional work to do */
}
});
final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher();
mRecyclerView.addOnLayoutChangeListener(scrollPositionWatcher);
mRecyclerView.addOnScrollListener(scrollPositionWatcher);
mRecyclerView.setAdapter(mItemAdapter);
final ItemAnimator itemAnimator = new ItemAnimator();
itemAnimator.setChangeDuration(150L);
itemAnimator.setMoveDuration(150L);
mRecyclerView.setItemAnimator(itemAnimator);
return v;
}
@Override
public void onStart() {
super.onStart();
if (!isTabSelected()) {
TimePickerDialogFragment.removeTimeEditDialog(getParentFragmentManager());
}
}
@Override
public void onResume() {
super.onResume();
// Schedule a runnable to update the "Today/Tomorrow" values displayed for non-repeating
// alarms when midnight passes.
UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater);
// Check if another app asked us to create a blank new alarm.
final Activity activity = getActivity();
if (activity == null) {
return;
}
final Intent intent = activity.getIntent();
if (intent == null) {
return;
}
if (intent.hasExtra(ALARM_CREATE_NEW_INTENT_EXTRA)) {
UiDataModel.getUiDataModel().setSelectedTab(ALARMS);
if (intent.getBooleanExtra(ALARM_CREATE_NEW_INTENT_EXTRA, false)) {
// An external app asked us to create a blank alarm.
startCreatingAlarm();
}
// Remove the CREATE_NEW extra now that we've processed it.
intent.removeExtra(ALARM_CREATE_NEW_INTENT_EXTRA);
} else if (intent.hasExtra(SCROLL_TO_ALARM_INTENT_EXTRA)) {
UiDataModel.getUiDataModel().setSelectedTab(ALARMS);
long alarmId = intent.getLongExtra(SCROLL_TO_ALARM_INTENT_EXTRA, Alarm.INVALID_ID);
if (alarmId != Alarm.INVALID_ID) {
setSmoothScrollStableId(alarmId);
if (mCursorLoader != null && mCursorLoader.isStarted()) {
// We need to force a reload here to make sure we have the latest view
// of the data to scroll to.
mCursorLoader.forceLoad();
}
}
// Remove the SCROLL_TO_ALARM extra now that we've processed it.
intent.removeExtra(SCROLL_TO_ALARM_INTENT_EXTRA);
}
}
@Override
public void onPause() {
super.onPause();
UiDataModel.getUiDataModel().removePeriodicCallback(mMidnightUpdater);
// When the user places the app in the background by pressing "home",
// dismiss the toast bar. However, since there is no way to determine if
// home was pressed, just dismiss any existing toast bar when restarting
// the app.
mAlarmUpdateHandler.hideUndoBar();
// Don't show the picker after resuming, so we don't need to remember what time we edited
// last (and the user maybe also doesn't remember then, editing the wrong alarm)
TimePickerDialogFragment.removeTimeEditDialog(getChildFragmentManager());
}
@Override
public void smoothScrollTo(int position) {
mLayoutManager.scrollToPositionWithOffset(position, 0);
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
mAlarmTimeClickHandler.saveInstance(outState);
outState.putLong(KEY_EXPANDED_ID, mExpandedAlarmId);
}
@Override
public void onDestroy() {
super.onDestroy();
ToastManager.cancelToast();
}
public void setLabel(Alarm alarm, String label) {
alarm.label = label;
mAlarmUpdateHandler.asyncUpdateAlarm(alarm, false, true);
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return Alarm.getAlarmsCursorLoader(getActivity());
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> cursorLoader, Cursor data) {
final List<AlarmItemHolder> itemHolders = new ArrayList<>(data.getCount());
for (data.moveToFirst(); !data.isAfterLast(); data.moveToNext()) {
final Alarm alarm = new Alarm(data);
final AlarmInstance alarmInstance = alarm.canPreemptivelyDismiss()
? new AlarmInstance(data, true /* joinedTable */) : null;
final AlarmItemHolder itemHolder =
new AlarmItemHolder(alarm, alarmInstance, mAlarmTimeClickHandler);
itemHolders.add(itemHolder);
}
setAdapterItems(itemHolders, SystemClock.elapsedRealtime());
}
/**
* Updates the adapters items, deferring the update until the current animation is finished or
* if no animation is running then the listener will be automatically be invoked immediately.
*
* @param items the new list of {@link AlarmItemHolder} to use
* @param updateToken a monotonically increasing value used to preserve ordering of deferred
* updates
*/
private void setAdapterItems(final List<AlarmItemHolder> items, final long updateToken) {
if (updateToken < mCurrentUpdateToken) {
LogUtils.v("Ignoring adapter update: %d < %d", updateToken, mCurrentUpdateToken);
return;
}
if (mRecyclerView.getItemAnimator() != null &&
mRecyclerView.getItemAnimator().isRunning()) {
// RecyclerView is currently animating -> defer update.
mRecyclerView.getItemAnimator().isRunning(() -> setAdapterItems(items, updateToken));
} else if (mRecyclerView.isComputingLayout()) {
// RecyclerView is currently computing a layout -> defer update.
mRecyclerView.post(() -> setAdapterItems(items, updateToken));
} else {
mCurrentUpdateToken = updateToken;
mItemAdapter.setItems(items);
// Show or hide the empty view as appropriate.
final boolean noAlarms = items.isEmpty();
mEmptyViewController.setEmpty(noAlarms);
if (noAlarms) {
// Ensure the drop shadow is hidden when no alarms exist.
setTabScrolledToTop(true);
}
// Expand the correct alarm.
if (mExpandedAlarmId != Alarm.INVALID_ID) {
final AlarmItemHolder aih = mItemAdapter.findItemById(mExpandedAlarmId);
if (aih != null) {
mAlarmTimeClickHandler.setSelectedAlarm(aih.item);
aih.expand();
} else {
mAlarmTimeClickHandler.setSelectedAlarm(null);
mExpandedAlarmId = Alarm.INVALID_ID;
}
}
// Scroll to the selected alarm.
if (mScrollToAlarmId != Alarm.INVALID_ID) {
scrollToAlarm(mScrollToAlarmId);
setSmoothScrollStableId(Alarm.INVALID_ID);
}
}
}
/**
* @param alarmId identifies the alarm to be displayed
*/
private void scrollToAlarm(long alarmId) {
final int alarmCount = mItemAdapter.getItemCount();
int alarmPosition = -1;
for (int i = 0; i < alarmCount; i++) {
long id = mItemAdapter.getItemId(i);
if (id == alarmId) {
alarmPosition = i;
break;
}
}
if (alarmPosition >= 0) {
mItemAdapter.findItemById(alarmId).expand();
smoothScrollTo(alarmPosition);
} else {
// Trying to display a deleted alarm should only happen from a missed notification for
// an alarm that has been marked deleted after use.
SnackbarManager.show(Snackbar.make(mMainLayout, R.string
.missed_alarm_has_been_deleted, Snackbar.LENGTH_LONG));
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> cursorLoader) {
}
@Override
public void setSmoothScrollStableId(long stableId) {
mScrollToAlarmId = stableId;
}
@Override
public void onFabClick(@NonNull ImageView fab) {
mAlarmUpdateHandler.hideUndoBar();
startCreatingAlarm();
}
@Override
public void onUpdateFab(@NonNull ImageView fab) {
fab.setVisibility(View.VISIBLE);
fab.setImageResource(R.drawable.ic_add_24dp);
fab.setContentDescription(fab.getResources().getString(R.string.button_alarms));
}
@Override
public void onUpdateFabButtons(@NonNull ImageView left, @NonNull ImageView right) {
left.setVisibility(View.INVISIBLE);
right.setVisibility(View.INVISIBLE);
}
@Override
public int getFabTargetVisibility() {
return View.VISIBLE;
}
private void startCreatingAlarm() {
// Clear the currently selected alarm.
mAlarmTimeClickHandler.setSelectedAlarm(null);
TimePickerDialogFragment.show(this);
}
@Override
public void onTimeSet(int hourOfDay, int minute) {
mAlarmTimeClickHandler.onTimeSet(hourOfDay, minute);
}
public void removeItem(AlarmItemHolder itemHolder) {
mItemAdapter.removeItem(itemHolder);
}
/**
* 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(mRecyclerView));
}
@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(mRecyclerView));
}
}
/**
* This runnable executes at midnight and refreshes the display of all alarms. Collapsed alarms
* that do no repeat will have their "Tomorrow" strings updated to say "Today".
*/
private final class MidnightRunnable implements Runnable {
@Override
public void run() {
mItemAdapter.notifyDataSetChanged();
}
}
}