Add dismissal mechanism for contextual cards.

In the homepage, we should allow cards to be dismissed.

- Implement card flipping upon card long pressing.

Bug: 113783548
Test: robotests, visual
Change-Id: I2ddb498321ba5c5078d6944aa2ef32f1386bdb10
This commit is contained in:
Emily Chuang
2018-11-05 20:18:56 +08:00
parent 192c6a1461
commit 6cf3591645
12 changed files with 196 additions and 37 deletions

View File

@@ -21,6 +21,11 @@
android:layout_height="wrap_content"
style="@style/ContextualCardStyle">
<ViewFlipper
android:id="@+id/viewFlipper"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.slice.widget.SliceView
android:id="@+id/slice_view"
android:layout_width="match_parent"
@@ -30,4 +35,41 @@
android:paddingStart="@dimen/homepage_card_padding_start"
android:paddingEnd="@dimen/homepage_card_padding_end"/>
<!--dismissal view-->
<LinearLayout
android:id="@+id/dismissal_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/homepage_card_padding_start"
android:layout_marginTop="@dimen/homepage_card_padding_start"
android:text="@string/contextual_card_dismiss_confirm_message"
style="@style/TextAppearance.ContextualCardDismissalText"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="bottom|end">
<Button
android:id="@+id/keep"
style="@style/ContextualCardDismissalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/contextual_card_dismiss_keep"/>
<Button
android:id="@+id/remove"
style="@style/ContextualCardDismissalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/contextual_card_dismiss_remove"/>
</LinearLayout>
</LinearLayout>
</ViewFlipper>
</androidx.cardview.widget.CardView>

View File

@@ -10286,4 +10286,10 @@
<!-- Summary for the top level Privacy Settings [CHAR LIMIT=NONE]-->
<string name="privacy_dashboard_summary">Permission, permission usage</string>
<!-- Label for button in contextual card for users to remove the card [CHAR LIMIT=30] -->
<string name="contextual_card_dismiss_remove">Remove</string>
<!-- Label for button in contextual card for users to keep the card [CHAR LIMIT=30] -->
<string name="contextual_card_dismiss_keep">Keep</string>
<!-- String for contextual card dismissal [CHAR LIMIT=NONE] -->
<string name="contextual_card_dismiss_confirm_message">Remove this suggestion?</string>
</resources>

View File

@@ -16,7 +16,8 @@
<resources>
<style name="Theme.ActionBar" parent="@android:style/Widget.DeviceDefault.Light.ActionBar.Solid">
<style name="Theme.ActionBar"
parent="@android:style/Widget.DeviceDefault.Light.ActionBar.Solid">
<item name="android:contentInsetStart">@dimen/actionbar_contentInsetStart</item>
</style>
@@ -78,8 +79,11 @@
<item name="android:scrollbarStyle">outsideOverlay</item>
</style>
<style name="TrimmedHorizontalProgressBar" parent="android:Widget.Material.ProgressBar.Horizontal">
<item name="android:indeterminateDrawable">@drawable/progress_indeterminate_horizontal_material_trimmed</item>
<style name="TrimmedHorizontalProgressBar"
parent="android:Widget.Material.ProgressBar.Horizontal">
<item name="android:indeterminateDrawable">
@drawable/progress_indeterminate_horizontal_material_trimmed
</item>
<item name="android:minHeight">3dip</item>
<item name="android:maxHeight">3dip</item>
</style>
@@ -162,7 +166,8 @@
<item name="android:layout_height">wrap_content</item>
</style>
<style name="ConfirmDeviceCredentialsAnimationStyle" parent="@*android:style/Animation.Material.Activity">
<style name="ConfirmDeviceCredentialsAnimationStyle"
parent="@*android:style/Animation.Material.Activity">
<item name="android:activityOpenEnterAnimation">@anim/confirm_credential_open_enter</item>
<item name="android:activityOpenExitAnimation">@anim/confirm_credential_open_exit</item>
</style>
@@ -171,14 +176,16 @@
<item name="android:background">#ff000000</item>
</style>
<style name="SecurityPreferenceButtonContainer" parent="@android:style/Widget.Material.Light.SegmentedButton">
<style name="SecurityPreferenceButtonContainer"
parent="@android:style/Widget.Material.Light.SegmentedButton">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:weightSum">2</item>
<item name="android:dividerPadding">8dip</item>
</style>
<style name="SecurityPreferenceButton" parent="@android:style/Widget.Material.Light.Button.Borderless">
<style name="SecurityPreferenceButton"
parent="@android:style/Widget.Material.Light.Button.Borderless">
<item name="android:layout_width">0dip</item>
<item name="android:layout_weight">1</item>
<item name="android:layout_height">wrap_content</item>
@@ -192,7 +199,7 @@
<item name="android:layout_marginStart">-16dp</item>
</style>
<style name="SetupWizardButton.Positive" parent="@style/SuwGlifButton.Primary" />
<style name="SetupWizardButton.Positive" parent="@style/SuwGlifButton.Primary"/>
<style name="AccentColorHighlightBorderlessButton">
<item name="android:colorControlHighlight">?android:attr/colorAccent</item>
@@ -252,17 +259,18 @@
<item name="android:imeOptions">flagForceAscii|actionDone</item>
</style>
<style name="TextAppearance.Medium" parent="@android:style/TextAppearance.Material.Medium" />
<style name="TextAppearance.Small" parent="@android:style/TextAppearance.Material.Small" />
<style name="TextAppearance.Medium" parent="@android:style/TextAppearance.Material.Medium"/>
<style name="TextAppearance.Small" parent="@android:style/TextAppearance.Material.Small"/>
<style name="TextAppearance.Switch" parent="@android:style/TextAppearance.Material.Title">
<item name="android:textSize">18sp</item>
</style>
<style name="TextAppearance.CategoryTitle" parent="@android:style/TextAppearance.Material.Body2">
<style name="TextAppearance.CategoryTitle"
parent="@android:style/TextAppearance.Material.Body2">
<item name="android:textColor">?android:attr/textColorSecondary</item>
</style>
<style name="TextAppearance.TileTitle" parent="@android:style/TextAppearance.Material.Subhead" />
<style name="TextAppearance.TileTitle" parent="@android:style/TextAppearance.Material.Subhead"/>
<style name="TextAppearance.SuggestionTitle"
parent="@android:style/TextAppearance.Material.Subhead">
@@ -293,12 +301,14 @@
<item name="android:textStyle">normal</item>
</style>
<style name="TextAppearance.RemoveDialogContent" parent="@android:style/TextAppearance.Material">
<style name="TextAppearance.RemoveDialogContent"
parent="@android:style/TextAppearance.Material">
<item name="android:textSize">16sp</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
<style name="TextAppearance.SearchBar" parent="@android:style/TextAppearance.Material.Widget.Toolbar.Subtitle">
<style name="TextAppearance.SearchBar"
parent="@android:style/TextAppearance.Material.Widget.Toolbar.Subtitle">
<item name="android:textSize">@dimen/search_bar_text_size</item>
</style>
@@ -345,6 +355,12 @@
<item name="android:padding">8dp</item>
</style>
<style name="TextAppearance.ContextualCardDismissalText"
parent="@android:style/TextAppearance.Material.Body1">
<item name="android:fontFamily">@*android:string/config_headlineFontFamilyMedium</item>
<item name="android:textSize">16sp</item>
</style>
<style name="SuggestionCardText">
<item name="android:textAlignment">viewStart</item>
</style>
@@ -391,7 +407,8 @@
<item name="android:lineSpacingMultiplier">1.2</item>
</style>
<style name="RingProgressBarStyle" parent="android:style/Widget.Material.ProgressBar.Horizontal">
<style name="RingProgressBarStyle"
parent="android:style/Widget.Material.ProgressBar.Horizontal">
<item name="android:indeterminate">false</item>
<item name="android:max">10000</item>
<item name="android:mirrorForRtl">false</item>
@@ -470,7 +487,6 @@
<item name="android:textColor">?android:attr/colorAccent</item>
<item name="android:textSize">14sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<style name="ConditionHalfCardBorderlessButton"
@@ -483,4 +499,9 @@
<item name="android:textAlignment">viewEnd</item>
</style>
<style name="ContextualCardDismissalButton"
parent="android:Widget.DeviceDefault.Button.Borderless.Colored">
<item name="android:textAllCaps">false</item>
</style>
</resources>

View File

@@ -28,5 +28,7 @@ public interface ContextualCardController {
void onActionClick(ContextualCard card);
void onDismissed(ContextualCard card);
void setCardUpdateListener(ContextualCardUpdateListener listener);
}

View File

@@ -146,14 +146,14 @@ public class ContextualCardManager implements ContextualCardLoader.CardContentLo
onContextualCardUpdated(cards.stream().collect(groupingBy(ContextualCard::getCardType)));
}
void setListener(ContextualCardUpdateListener listener) {
mListener = listener;
}
public ControllerRendererPool getControllerRendererPool() {
return mControllerRendererPool;
}
void setListener(ContextualCardUpdateListener listener) {
mListener = listener;
}
static class CardContentLoaderCallbacks implements
LoaderManager.LoaderCallbacks<List<ContextualCard>> {

View File

@@ -126,7 +126,8 @@ public class ControllerRendererPool {
if (ConditionContextualCardRenderer.class == clz) {
return new ConditionContextualCardRenderer(context, this /* controllerRendererPool */);
} else if (SliceContextualCardRenderer.class == clz) {
return new SliceContextualCardRenderer(context, lifecycleOwner);
return new SliceContextualCardRenderer(context, lifecycleOwner,
this /* controllerRendererPool */);
} else if (LegacySuggestionContextualCardRenderer.class == clz) {
return new LegacySuggestionContextualCardRenderer(context,
this /* controllerRendererPool */);

View File

@@ -77,6 +77,11 @@ public class ConditionContextualCardController implements ContextualCardControll
@Override
public void onActionClick(ContextualCard contextualCard) {
}
@Override
public void onDismissed(ContextualCard contextualCard) {
final ConditionalContextualCard card = (ConditionalContextualCard) contextualCard;
mConditionManager.onActionClick(card.getConditionId());
}

View File

@@ -112,8 +112,8 @@ public class ConditionContextualCardRenderer implements ContextualCardRenderer {
metricsFeatureProvider.action(
viewContext, MetricsProto.MetricsEvent.ACTION_SETTINGS_CONDITION_BUTTON,
card.getMetricsConstant());
mControllerRendererPool.getController(mContext, card.getCardType()).onActionClick(
card);
mControllerRendererPool.getController(mContext, card.getCardType())
.onDismissed(card);
});
} else {
button.setVisibility(View.GONE);

View File

@@ -86,6 +86,11 @@ public class LegacySuggestionContextualCardController implements ContextualCardC
}
@Override
public void onDismissed(ContextualCard card) {
}
@Override
public void setCardUpdateListener(ContextualCardUpdateListener listener) {
mCardUpdateListener = listener;

View File

@@ -25,6 +25,10 @@ import com.android.settings.homepage.contextualcards.ContextualCardUpdateListene
*/
public class SliceContextualCardController implements ContextualCardController {
private static final String TAG = "SliceCardController";
private ContextualCardUpdateListener mCardUpdateListener;
@Override
public int getCardType() {
return ContextualCard.CardType.SLICE;
@@ -37,11 +41,16 @@ public class SliceContextualCardController implements ContextualCardController {
@Override
public void onActionClick(ContextualCard card) {
//TODO(b/113783548): Implement feedback mechanism
}
@Override
public void onDismissed(ContextualCard card) {
//TODO(b/113783548): Mark this card as dismissed in db and reload loader.
}
@Override
public void setCardUpdateListener(ContextualCardUpdateListener listener) {
mCardUpdateListener = listener;
}
}

View File

@@ -22,6 +22,8 @@ import android.net.Uri;
import android.util.ArrayMap;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ViewFlipper;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
@@ -37,6 +39,7 @@ import androidx.slice.widget.SliceView;
import com.android.settings.R;
import com.android.settings.homepage.contextualcards.ContextualCard;
import com.android.settings.homepage.contextualcards.ContextualCardRenderer;
import com.android.settings.homepage.contextualcards.ControllerRendererPool;
import java.util.Map;
@@ -54,11 +57,14 @@ public class SliceContextualCardRenderer implements ContextualCardRenderer,
private final Context mContext;
private final LifecycleOwner mLifecycleOwner;
private final ControllerRendererPool mControllerRendererPool;
public SliceContextualCardRenderer(Context context, LifecycleOwner lifecycleOwner) {
public SliceContextualCardRenderer(Context context, LifecycleOwner lifecycleOwner,
ControllerRendererPool controllerRendererPool) {
mContext = context;
mLifecycleOwner = lifecycleOwner;
mSliceLiveDataMap = new ArrayMap<>();
mControllerRendererPool = controllerRendererPool;
}
@Override
@@ -104,6 +110,27 @@ public class SliceContextualCardRenderer implements ContextualCardRenderer,
// Set this listener so we can log the interaction users make on the slice
cardHolder.sliceView.setOnSliceActionListener(this);
initDismissalActions(cardHolder, card);
}
private void initDismissalActions(SliceViewHolder cardHolder, ContextualCard card) {
final ViewFlipper viewFlipper = cardHolder.itemView.findViewById(R.id.viewFlipper);
cardHolder.sliceView.setOnLongClickListener(v -> {
viewFlipper.showNext();
return true;
});
final Button btnKeep = cardHolder.itemView.findViewById(R.id.keep);
btnKeep.setOnClickListener(v -> {
viewFlipper.showPrevious();
});
final Button btnRemove = cardHolder.itemView.findViewById(R.id.remove);
btnRemove.setOnClickListener(v -> {
mControllerRendererPool.getController(mContext, card.getCardType()).onDismissed(
card);
});
}
@Override

View File

@@ -24,15 +24,20 @@ import android.content.Context;
import android.net.Uri;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.ViewFlipper;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.slice.Slice;
import androidx.slice.widget.SliceView;
import com.android.settings.R;
import com.android.settings.homepage.contextualcards.ContextualCard;
import com.android.settings.homepage.contextualcards.ContextualCardsFragment;
import com.android.settings.homepage.contextualcards.ControllerRendererPool;
import com.android.settings.testutils.SettingsRobolectricTestRunner;
import org.junit.Before;
@@ -47,6 +52,10 @@ public class SliceContextualCardRendererTest {
@Mock
private LiveData<Slice> mSliceLiveData;
@Mock
private ControllerRendererPool mControllerRendererPool;
@Mock
private SliceContextualCardController mController;
private Context mContext;
private SliceContextualCardRenderer mRenderer;
@@ -57,7 +66,8 @@ public class SliceContextualCardRendererTest {
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
mLifecycleOwner = new ContextualCardsFragment();
mRenderer = new SliceContextualCardRenderer(mContext, mLifecycleOwner);
mRenderer = new SliceContextualCardRenderer(mContext, mLifecycleOwner,
mControllerRendererPool);
}
@Test
@@ -103,7 +113,7 @@ public class SliceContextualCardRendererTest {
}
@Test
public void bindview_sliceLiveDataShouldRemoveObservers() {
public void bindView_sliceLiveDataShouldRemoveObservers() {
final String sliceUri = "content://com.android.settings.slices/action/flashlight";
mRenderer.mSliceLiveDataMap.put(sliceUri, mSliceLiveData);
@@ -112,14 +122,45 @@ public class SliceContextualCardRendererTest {
verify(mSliceLiveData).removeObservers(mLifecycleOwner);
}
@Test
public void longClick_shouldFlipCard() {
final String sliceUri = "content://com.android.settings.slices/action/flashlight";
final RecyclerView.ViewHolder viewHolder = getSliceViewHolder();
final View card = viewHolder.itemView.findViewById(R.id.slice_view);
final ViewFlipper viewFlipper = viewHolder.itemView.findViewById(R.id.viewFlipper);
final View dismissalView = viewHolder.itemView.findViewById(R.id.dismissal_view);
mRenderer.bindView(viewHolder, buildContextualCard(sliceUri));
assertThat(card).isNotNull();
card.performLongClick();
assertThat(viewFlipper.getCurrentView()).isEqualTo(dismissalView);
}
@Test
public void viewClick_keepCard_shouldFlipBackToSlice() {
final String sliceUri = "content://com.android.settings.slices/action/flashlight";
final RecyclerView.ViewHolder viewHolder = getSliceViewHolder();
final View card = viewHolder.itemView.findViewById(R.id.slice_view);
final Button btnKeep = viewHolder.itemView.findViewById(R.id.keep);
final ViewFlipper viewFlipper = viewHolder.itemView.findViewById(R.id.viewFlipper);
mRenderer.bindView(viewHolder, buildContextualCard(sliceUri));
assertThat(card).isNotNull();
card.performLongClick();
assertThat(btnKeep).isNotNull();
btnKeep.performClick();
assertThat(viewFlipper.getCurrentView()).isInstanceOf(SliceView.class);
}
private RecyclerView.ViewHolder getSliceViewHolder() {
final int viewType = mRenderer.getViewType(false /* isHalfWidth */);
final RecyclerView recyclerView = new RecyclerView(mContext);
recyclerView.setLayoutManager(new LinearLayoutManager(mContext));
final View view = LayoutInflater.from(mContext).inflate(viewType, recyclerView, false);
final RecyclerView.ViewHolder viewHolder = mRenderer.createViewHolder(view);
return viewHolder;
return mRenderer.createViewHolder(view);
}
private ContextualCard buildContextualCard(String sliceUri) {