From 3dc0ef75bae574140ceb3ba1e41a5deb10b7c772 Mon Sep 17 00:00:00 2001 From: NIkita Fedrunov Date: Thu, 12 May 2022 16:44:28 +0200 Subject: [PATCH] added coach mark view --- .../vector/app/core/ui/views/CoachMarkView.kt | 187 ++++++++++++++++++ .../features/settings/VectorPreferences.kt | 12 ++ .../leave/SpaceLeaveAdvanceViewAction.kt | 1 + .../leave/SpaceLeaveAdvanceViewState.kt | 3 +- .../leave/SpaceLeaveAdvancedFragment.kt | 8 + .../leave/SpaceLeaveAdvancedViewModel.kt | 12 +- .../src/main/res/drawable/bg_coach_mark.xml | 9 + .../src/main/res/drawable/ic_coach_arrow.xml | 10 + vector/src/main/res/layout/coach_mark.xml | 56 ++++++ .../layout/fragment_space_leave_advanced.xml | 7 + vector/src/main/res/values/strings.xml | 1 + 11 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/core/ui/views/CoachMarkView.kt create mode 100644 vector/src/main/res/drawable/bg_coach_mark.xml create mode 100644 vector/src/main/res/drawable/ic_coach_arrow.xml create mode 100644 vector/src/main/res/layout/coach_mark.xml diff --git a/vector/src/main/java/im/vector/app/core/ui/views/CoachMarkView.kt b/vector/src/main/java/im/vector/app/core/ui/views/CoachMarkView.kt new file mode 100644 index 0000000000..12c7b9a4a0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/CoachMarkView.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.core.ui.views + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.transition.Fade +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import im.vector.app.R + +/*** + * Coach mark widget, which could be shown against any part of layout to provide user with valuable information about it + * It shows a popup dialog which covers entire screen and consume first click on any location on the screen to dismiss itself + * @param context the context + * @param root fragment or activity root view to host popup dialog + */ + +class CoachMarkView(val context: Context, val root: View) { + + @SuppressLint("InflateParams") + private val view: View = LayoutInflater.from(context).inflate(R.layout.coach_mark, null) + private val markView: ViewGroup = view.findViewById(R.id.coach_mark) + + companion object { + /** + * Coach mark pointers should not be positioned on top of rounded corners to prevent gaps between background shape and pointer + * This constant must match `radius` property of coach mark's background shape + * */ + private const val ANCHOR_MIN_HORIZONTAL_MARGIN_DP = 12f + } + + /*** + * Show coach mark for specified anchor view + * + * @param stringId string resource id for text to be shown + * @param anchor view which will be pointed by coach mark + * @param gravity coach mark gravity. [Gravity.BOTTOM] to show mark below anchor, [Gravity.TOP] to show above. + */ + fun show(@StringRes stringId: Int, anchor: View, gravity: Int = Gravity.NO_GRAVITY, onDismiss: (() -> Unit)? = null) { + show(context.resources.getString(stringId), anchor, gravity, onDismiss) + } + + /*** + * Show coach mark for specified anchor view + * + * @param text text to be shown + * @param anchor view which will be pointed by coach mark + * @param gravity coach mark gravity. [Gravity.BOTTOM] to show mark below anchor, [Gravity.TOP] to show above. + */ + fun show(text: CharSequence, anchor: View, gravity: Int = Gravity.NO_GRAVITY, onDismiss: (() -> Unit)? = null) { + view.findViewById(R.id.coach_mark_content).text = text + + val width = context.resources.displayMetrics.widthPixels - markView.paddingLeft - markView.paddingRight + + markView.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(context.resources.displayMetrics.heightPixels, View.MeasureSpec.AT_MOST) + ) + + val anchorDimens = getAnchorDimens(anchor, root) + + setMarkVerticalPosition(context, anchorDimens, view, markView.measuredHeight, gravity) + setPointerHorizontalPosition(anchorDimens, markView) + + createPopupWindow(view, onDismiss).showAtLocation(root, Gravity.NO_GRAVITY, 0, 0) + } + + private fun setMarkVerticalPosition(context: Context, anchorDimens: Dimens, view: View, markHeight: Int, gravity: Int) { + val maxHeight = context.resources.displayMetrics.heightPixels + + val resolvedGravity = resolveGravity( + maxHeight = maxHeight, + anchorDimens = anchorDimens, + markHeight = markHeight, + gravity = gravity + ) + + view.findViewById(R.id.coach_mark_arrow_top).isVisible = resolvedGravity == Gravity.BOTTOM + view.findViewById(R.id.coach_mark_arrow_bottom).isVisible = resolvedGravity == Gravity.TOP + + val markY = when (resolvedGravity) { + Gravity.TOP -> anchorDimens.y - markHeight + Gravity.BOTTOM -> anchorDimens.y + anchorDimens.height + else -> maxHeight / 2 - markHeight / 2 + } + + setMarkY(markY, view) + } + + private fun resolveGravity(maxHeight: Int, anchorDimens: Dimens, markHeight: Int, gravity: Int): Int { + val topSpace = anchorDimens.y + val bottomSpace = maxHeight - (anchorDimens.y + anchorDimens.height) + + val fitAbove = topSpace >= markHeight + val fitBelow = bottomSpace >= markHeight + + if (!fitAbove && !fitBelow) { + return Gravity.CENTER + } + + return when { + gravity == Gravity.TOP && fitAbove -> Gravity.TOP + gravity == Gravity.BOTTOM && fitBelow -> Gravity.BOTTOM + bottomSpace >= topSpace -> Gravity.BOTTOM //choose the bigger side if required gravity isn't provided or can't be applied + else -> Gravity.TOP + } + } + + private fun setMarkY(y: Int, markView: View) { + val mark = markView.findViewById(R.id.coach_mark) + val params = (mark.layoutParams as ViewGroup.MarginLayoutParams) + params.setMargins(0, y, 0, 0) + mark.layoutParams = params + } + + @SuppressLint("ClickableViewAccessibility") + private fun createPopupWindow(containerView: View, onDismiss: (() -> Unit)?): PopupWindow = + PopupWindow(containerView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + enterTransition = Fade(Fade.IN) + exitTransition = Fade(Fade.OUT) + } + + isTouchable = true + setTouchInterceptor { _, _ -> + dismiss() + onDismiss?.invoke() + true + } + } + + private fun setPointerHorizontalPosition(anchorDimens: Dimens, markView: View) { + val topArrow = markView.findViewById(R.id.coach_mark_arrow_top) + val bottomArrow = markView.findViewById(R.id.coach_mark_arrow_bottom) + + val paddingHorizontal = markView.paddingStart + val anchorCenter = anchorDimens.x + anchorDimens.width / 2 + val minHorizontalMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + ANCHOR_MIN_HORIZONTAL_MARGIN_DP, + markView.context.resources.displayMetrics + ).toInt() + + var leftMargin = anchorCenter - paddingHorizontal - topArrow.measuredWidth / 2 + leftMargin = leftMargin.coerceAtLeast(minHorizontalMargin) + + (topArrow.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = leftMargin + (bottomArrow.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = leftMargin + } + + private fun getAnchorDimens(anchor: View, parent: View): Dimens { + val rootViewLoc = IntArray(2) + parent.getLocationOnScreen(rootViewLoc) + + val anchorLoc = IntArray(2) + anchor.getLocationOnScreen(anchorLoc) + anchorLoc[1] -= rootViewLoc[1] + + return Dimens(anchorLoc[0], anchorLoc[1], anchor.measuredWidth, anchor.measuredHeight) + } + + private data class Dimens(val x: Int, val y: Int, val width: Int, val height: Int) +} diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 72f6080417..41639109c6 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -205,6 +205,8 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE" private const val SETTINGS_LABS_ENABLE_LIVE_LOCATION = "SETTINGS_LABS_ENABLE_LIVE_LOCATION" + private const val DID_SHOW_USER_SPACE_LEAVE_COACH_MARK = "DID_SHOW_USER_SPACE_LEAVE_COACH_MARK" + // This key will be used to identify clients with the old thread support enabled io.element.thread const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES" @@ -1102,4 +1104,14 @@ class VectorPreferences @Inject constructor( fun showLiveSenderInfo(): Boolean { return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default)) } + + fun didShowUserSpaceLeaveCoachMark(): Boolean { + return defaultPrefs.getBoolean(DID_SHOW_USER_SPACE_LEAVE_COACH_MARK, false) + } + + fun setDidShowUserSpaceLeaveCoachMark() { + defaultPrefs.edit { + putBoolean(DID_SHOW_USER_SPACE_LEAVE_COACH_MARK, true) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt index a25476bff9..90cd167040 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt @@ -26,4 +26,5 @@ sealed class SpaceLeaveAdvanceViewAction : VectorViewModelAction { object ClearError : SpaceLeaveAdvanceViewAction() object SelectAll : SpaceLeaveAdvanceViewAction() object SelectNone : SpaceLeaveAdvanceViewAction() + object CoachMarkDismissed : SpaceLeaveAdvanceViewAction() } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt index fce5f4efa1..da82001228 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt @@ -30,7 +30,8 @@ data class SpaceLeaveAdvanceViewState( val currentFilter: String = "", val leaveState: Async = Uninitialized, val isFilteringEnabled: Boolean = false, - val isLastAdmin: Boolean = false + val isLastAdmin: Boolean = false, + val showCoachMark: Boolean = false ) : MavericksState { constructor(args: SpaceBottomSheetSettingsArgs) : this( diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt index 308572a30f..0d63a26e6f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt @@ -17,6 +17,7 @@ package im.vector.app.features.spaces.leave import android.os.Bundle +import android.view.Gravity import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -32,6 +33,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.ui.views.CoachMarkView import im.vector.app.core.utils.ToggleableAppBarLayoutBehavior import im.vector.app.databinding.FragmentSpaceLeaveAdvancedBinding import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -113,6 +115,12 @@ class SpaceLeaveAdvancedFragment @Inject constructor( views.appBarLayout.setExpanded(false) } + if (state.showCoachMark) { + CoachMarkView(requireContext(), views.root).show(R.string.space_leave_coach_mark_text, views.coachMarkGuide, Gravity.BOTTOM) { + viewModel.handle(SpaceLeaveAdvanceViewAction.CoachMarkDismissed) + } + } + updateAppBarBehaviorState(state) updateRadioButtonsState(state) diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt index 926739f96c..b62c79329e 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt @@ -29,6 +29,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.settings.VectorPreferences import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -52,7 +53,8 @@ import timber.log.Timber class SpaceLeaveAdvancedViewModel @AssistedInject constructor( @Assisted val initialState: SpaceLeaveAdvanceViewState, private val session: Session, - private val appStateHandler: AppStateHandler + private val appStateHandler: AppStateHandler, + private val vectorPreferences: VectorPreferences ) : VectorViewModel(initialState) { init { @@ -99,7 +101,7 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor( ) setState { - copy(allChildren = Success(children)) + copy(allChildren = Success(children), showCoachMark = !vectorPreferences.didShowUserSpaceLeaveCoachMark()) } } } @@ -113,9 +115,15 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor( is SpaceLeaveAdvanceViewAction.ToggleSelection -> handleSelectionToggle(action) SpaceLeaveAdvanceViewAction.DoLeave -> handleLeave() SpaceLeaveAdvanceViewAction.SelectAll -> handleSelectAll() + SpaceLeaveAdvanceViewAction.CoachMarkDismissed -> handleCoachMarkDismissed() } } + private fun handleCoachMarkDismissed() = withState { + vectorPreferences.setDidShowUserSpaceLeaveCoachMark() + setState { copy(showCoachMark = false) } + } + private fun handleSelectAll() = withState { state -> val filteredRooms = (state.allChildren as? Success)?.invoke()?.filter { it.name.contains(state.currentFilter, true) diff --git a/vector/src/main/res/drawable/bg_coach_mark.xml b/vector/src/main/res/drawable/bg_coach_mark.xml new file mode 100644 index 0000000000..8d4c593733 --- /dev/null +++ b/vector/src/main/res/drawable/bg_coach_mark.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/vector/src/main/res/drawable/ic_coach_arrow.xml b/vector/src/main/res/drawable/ic_coach_arrow.xml new file mode 100644 index 0000000000..0f26f4b4d2 --- /dev/null +++ b/vector/src/main/res/drawable/ic_coach_arrow.xml @@ -0,0 +1,10 @@ + + + + diff --git a/vector/src/main/res/layout/coach_mark.xml b/vector/src/main/res/layout/coach_mark.xml new file mode 100644 index 0000000000..392163e36f --- /dev/null +++ b/vector/src/main/res/layout/coach_mark.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_space_leave_advanced.xml b/vector/src/main/res/layout/fragment_space_leave_advanced.xml index 67d9f044da..d551b00bbb 100644 --- a/vector/src/main/res/layout/fragment_space_leave_advanced.xml +++ b/vector/src/main/res/layout/fragment_space_leave_advanced.xml @@ -136,4 +136,11 @@ android:text="@string/leave_space" /> + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 63d4730dc5..f8cb081a52 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2846,6 +2846,7 @@ You are the only person here. If you leave, no one will be able to join in the future, including you. You won\'t be able to rejoin unless you are re-invited. You\'re the only admin of this space. Leaving it will mean no one has control over it. + Use this form to leave anything at the same time. Leave all rooms and spaces