diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index d20392340..6f632125d 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -25,6 +25,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenStarted import androidx.navigation.fragment.NavHostFragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar @@ -34,8 +35,10 @@ import kotlinx.android.synthetic.main.fragment_browser.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager @@ -53,6 +56,7 @@ import mozilla.components.feature.session.ThumbnailsFeature import mozilla.components.feature.sitepermissions.SitePermissions import mozilla.components.feature.sitepermissions.SitePermissionsFeature import mozilla.components.feature.sitepermissions.SitePermissionsRules +import mozilla.components.lib.state.ext.observe import mozilla.components.support.base.feature.BackHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.view.exitImmersiveModeIfNeeded @@ -63,11 +67,13 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.R import org.mozilla.fenix.ThemeManager +import org.mozilla.fenix.browser.readermode.DefaultReaderModeController import org.mozilla.fenix.collections.CreateCollectionViewModel import org.mozilla.fenix.collections.SaveCollectionStep import org.mozilla.fenix.collections.getStepForCollectionsSize import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FindInPageIntegration +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event.BrowserMenuItemTapped.Item @@ -90,14 +96,13 @@ import org.mozilla.fenix.lib.Do import org.mozilla.fenix.mvi.ActionBusFactory import org.mozilla.fenix.mvi.getAutoDisposeObservable import org.mozilla.fenix.mvi.getManagedEmitter -import org.mozilla.fenix.quickactionsheet.QuickActionAction -import org.mozilla.fenix.quickactionsheet.QuickActionChange -import org.mozilla.fenix.quickactionsheet.QuickActionComponent +import org.mozilla.fenix.quickactionsheet.QuickActionInteractor +import org.mozilla.fenix.quickactionsheet.QuickActionSheetAction import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior -import org.mozilla.fenix.quickactionsheet.QuickActionState -import org.mozilla.fenix.quickactionsheet.QuickActionViewModel +import org.mozilla.fenix.quickactionsheet.QuickActionSheetState +import org.mozilla.fenix.quickactionsheet.QuickActionSheetStore +import org.mozilla.fenix.quickactionsheet.QuickActionView import org.mozilla.fenix.settings.SupportUtils -import org.mozilla.fenix.utils.ItsNotBrokenSnack import org.mozilla.fenix.utils.Settings import java.net.MalformedURLException import java.net.URL @@ -105,6 +110,7 @@ import java.net.URL @SuppressWarnings("TooManyFunctions", "LargeClass") class BrowserFragment : Fragment(), BackHandler { private lateinit var toolbarComponent: ToolbarComponent + private lateinit var quickActionSheetStore: QuickActionSheetStore private var tabCollectionObserver: Observer>? = null private var sessionObserver: Session.Observer? = null @@ -164,26 +170,6 @@ class BrowserFragment : Fragment(), BackHandler { startPostponedEnterTransition() - QuickActionComponent( - view.nestedScrollQuickAction, - ActionBusFactory.get(this), - FenixViewModelProvider.create( - this, - QuickActionViewModel::class.java - ) { - val appLink = requireComponents.useCases.appLinksUseCases.appLinkRedirect - QuickActionViewModel( - QuickActionState( - readable = getSessionById()?.readerable ?: false, - bookmarked = findBookmarkedURL(getSessionById()), - readerActive = getSessionById()?.readerMode ?: false, - bounceNeeded = false, - isAppLink = getSessionById()?.let { appLink.invoke(it.url).hasExternalApp() } ?: false - ) - ) - } - ) - val activity = activity as HomeActivity ThemeManager.applyStatusBarTheme(activity.window, activity.themeManager, activity) @@ -372,18 +358,14 @@ class BrowserFragment : Fragment(), BackHandler { requireComponents.core.engine, requireComponents.core.sessionManager, view.readerViewControlsBar - ) { - getManagedEmitter().apply { - if (it) { - requireComponents.analytics.metrics.track(Event.ReaderModeAvailable) - } + ) { available -> + if (available) { requireComponents.analytics.metrics.track(Event.ReaderModeAvailable) } - onNext(QuickActionChange.ReadableStateChange(it)) - onNext( - QuickActionChange.ReaderActiveStateChange( - sessionManager.selectedSession?.readerMode ?: false - ) - ) + quickActionSheetStore.apply { + dispatch(QuickActionSheetAction.ReadableStateChange(available)) + dispatch(QuickActionSheetAction.ReaderActiveStateChange( + sessionManager.selectedSession?.readerMode ?: false + )) } }, owner = this, @@ -411,6 +393,42 @@ class BrowserFragment : Fragment(), BackHandler { toolbarComponent.getView().setOnSiteSecurityClickedListener { showQuickSettingsDialog() } + + val appLink = requireComponents.useCases.appLinksUseCases.appLinkRedirect + quickActionSheetStore = StoreProvider.get(this) { + QuickActionSheetStore( + QuickActionSheetState( + readable = getSessionById()?.readerable ?: false, + bookmarked = findBookmarkedURL(getSessionById()), + readerActive = getSessionById()?.readerMode ?: false, + bounceNeeded = false, + isAppLink = getSessionById()?.let { appLink.invoke(it.url).hasExternalApp() } ?: false + ) + ) + } + + val quickActionSheetView = QuickActionView( + view.nestedScrollQuickAction, + + QuickActionInteractor( + context!!, + DefaultReaderModeController(readerViewFeature), + quickActionSheetStore, + shareUrl = ::shareUrl, + bookmarkTapped = { + lifecycleScope.launch { bookmarkTapped(it) } + }, + appLinksUseCases = requireComponents.useCases.appLinksUseCases + ) + ) + + quickActionSheetStore.observe(view) { + viewLifecycleOwner.lifecycleScope.launch { + whenStarted { + quickActionSheetView.update(it) + } + } + } } private fun themeReaderViewControlsForPrivateMode(view: View) = with(view) { @@ -495,115 +513,46 @@ class BrowserFragment : Fragment(), BackHandler { } } } - - getAutoDisposeObservable() - .subscribe { - when (it) { - is QuickActionAction.Opened -> { - requireComponents.analytics.metrics.track(Event.QuickActionSheetOpened) - } - is QuickActionAction.Closed -> { - requireComponents.analytics.metrics.track(Event.QuickActionSheetClosed) - } - is QuickActionAction.SharePressed -> { - requireComponents.analytics.metrics.track(Event.QuickActionSheetShareTapped) - getSessionById()?.let { session -> - shareUrl(session.url) - } - } - is QuickActionAction.DownloadsPressed -> { - requireComponents.analytics.metrics.track(Event.QuickActionSheetDownloadTapped) - ItsNotBrokenSnack(context!!).showSnackbar(issueNumber = "348") - } - is QuickActionAction.BookmarkPressed -> { - requireComponents.analytics.metrics.track(Event.QuickActionSheetBookmarkTapped) - bookmarkTapped() - } - is QuickActionAction.ReadPressed -> { - readerViewFeature.withFeature { feature -> - requireComponents.analytics.metrics.track(Event.QuickActionSheetReadTapped) - val actionEmitter = getManagedEmitter() - val enabled = requireComponents.core.sessionManager.selectedSession?.readerMode ?: false - if (enabled) { - feature.hideReaderView() - actionEmitter.onNext(QuickActionChange.ReaderActiveStateChange(false)) - } else { - feature.showReaderView() - actionEmitter.onNext(QuickActionChange.ReaderActiveStateChange(true)) - requireComponents.analytics.metrics.track(Event.ReaderModeOpened) - } - } - } - is QuickActionAction.ReadAppearancePressed -> { - requireComponents.analytics.metrics.track(Event.ReaderModeAppearanceOpened) - readerViewFeature.withFeature { feature -> - feature.showControls() - } - } - is QuickActionAction.OpenAppLinkPressed -> { - appLinksFeature.withFeature { feature -> - val getRedirect = requireComponents.useCases.appLinksUseCases.appLinkRedirect - - val redirect = getSessionById()?.let { session -> - getRedirect.invoke(session.url) - } ?: return@withFeature - - redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK - - val openAppLink = requireComponents.useCases.appLinksUseCases.openAppLink - openAppLink.invoke(redirect) - } - } - } - } - assignSitePermissionsRules() } - private fun bookmarkTapped() { - getSessionById()?.let { session -> - lifecycleScope.launch(IO) { - val bookmarksStorage = requireComponents.core.bookmarksStorage - val existing = bookmarksStorage.getBookmarksWithUrl(session.url) - val found = existing.isNotEmpty() && existing[0].url == session.url - if (found) { - launch(Main) { - nav( - R.id.browserFragment, - BrowserFragmentDirections - .actionBrowserFragmentToBookmarkEditFragment(existing[0].guid) - ) - } - } else { - val guid = bookmarksStorage.addItem( - BookmarkRoot.Mobile.id, - session.url, - session.title, - null - ) - launch(Main) { - getManagedEmitter() - .onNext(QuickActionChange.BookmarkedStateChange(true)) - requireComponents.analytics.metrics.track(Event.AddBookmark) - view?.let { - FenixSnackbar.make( - it.rootView, - Snackbar.LENGTH_LONG + private suspend fun bookmarkTapped(session: Session) = withContext(IO) { + val bookmarksStorage = requireComponents.core.bookmarksStorage + val existing = bookmarksStorage.getBookmarksWithUrl(session.url).firstOrNull { it.url == session.url } + if (existing != null) { + // Bookmark exists, go to edit fragment + withContext(Main) { + nav( + R.id.browserFragment, + BrowserFragmentDirections.actionBrowserFragmentToBookmarkEditFragment(existing.guid) + ) + } + } else { + // Save bookmark, then go to edit fragment + val guid = bookmarksStorage.addItem( + BookmarkRoot.Mobile.id, + url = session.url, + title = session.title, + position = null + ) + + withContext(Main) { + quickActionSheetStore.dispatch( + QuickActionSheetAction.BookmarkedStateChange(bookmarked = true) + ) + requireComponents.analytics.metrics.track(Event.AddBookmark) + + view?.let { + FenixSnackbar.make(it.rootView, Snackbar.LENGTH_LONG) + .setAnchorView(toolbarComponent.uiView.view) + .setAction(getString(R.string.edit_bookmark_snackbar_action)) { + nav( + R.id.browserFragment, + BrowserFragmentDirections.actionBrowserFragmentToBookmarkEditFragment(guid) ) - .setAnchorView(toolbarComponent.uiView.view) - .setAction(getString(R.string.edit_bookmark_snackbar_action)) { - nav( - R.id.browserFragment, - BrowserFragmentDirections - .actionBrowserFragmentToBookmarkEditFragment( - guid - ) - ) - } - .setText(getString(R.string.bookmark_saved_snackbar)) - .show() } - } + .setText(getString(R.string.bookmark_saved_snackbar)) + .show() } } } @@ -881,7 +830,7 @@ class BrowserFragment : Fragment(), BackHandler { override fun onLoadingStateChanged(session: Session, loading: Boolean) { if (!loading) { updateBookmarkState(session) - getManagedEmitter().onNext(QuickActionChange.BounceNeededChange) + quickActionSheetStore.dispatch(QuickActionSheetAction.BounceNeededChange) } super.onLoadingStateChanged(session, loading) @@ -923,12 +872,11 @@ class BrowserFragment : Fragment(), BackHandler { } private fun updateBookmarkState(session: Session) { - if (findBookmarkJob?.isActive == true) findBookmarkJob?.cancel() + findBookmarkJob?.cancel() findBookmarkJob = lifecycleScope.launch(IO) { val found = findBookmarkedURL(session) - launch(Main) { - getManagedEmitter() - .onNext(QuickActionChange.BookmarkedStateChange(found)) + withContext(Main) { + quickActionSheetStore.dispatch(QuickActionSheetAction.BookmarkedStateChange(found)) } } } @@ -936,8 +884,7 @@ class BrowserFragment : Fragment(), BackHandler { private fun updateAppLinksState(session: Session) { val url = session.url val appLinks = requireComponents.useCases.appLinksUseCases.appLinkRedirect - getManagedEmitter() - .onNext(QuickActionChange.AppLinkStateChange(appLinks.invoke(url).hasExternalApp())) + quickActionSheetStore.dispatch(QuickActionSheetAction.AppLinkStateChange(appLinks.invoke(url).hasExternalApp())) } private val collectionStorageObserver = object : TabCollectionStorage.Observer { diff --git a/app/src/main/java/org/mozilla/fenix/browser/readermode/ReaderModeController.kt b/app/src/main/java/org/mozilla/fenix/browser/readermode/ReaderModeController.kt new file mode 100644 index 000000000..4ac3b83c9 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/browser/readermode/ReaderModeController.kt @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.browser.readermode + +import mozilla.components.feature.readerview.ReaderViewFeature +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper + +/** + * An interface that exposes the hide and show reader view functions of a ReaderViewFeature + */ +interface ReaderModeController { + fun hideReaderView() + fun showReaderView() + fun showControls() +} + +class DefaultReaderModeController( + private val readerViewFeature: ViewBoundFeatureWrapper +) : ReaderModeController { + override fun hideReaderView() { + readerViewFeature.withFeature { it.hideReaderView() } + } + + override fun showReaderView() { + readerViewFeature.withFeature { it.showReaderView() } + } + + override fun showControls() { + readerViewFeature.withFeature { it.showControls() } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionComponent.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionComponent.kt deleted file mode 100644 index 4bd363c50..000000000 --- a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionComponent.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.quickactionsheet - -import android.view.ViewGroup -import org.mozilla.fenix.mvi.Action -import org.mozilla.fenix.mvi.ActionBusFactory -import org.mozilla.fenix.mvi.UIComponentViewModelProvider -import org.mozilla.fenix.mvi.ViewState -import org.mozilla.fenix.mvi.Change -import org.mozilla.fenix.mvi.Reducer -import org.mozilla.fenix.mvi.UIComponent -import org.mozilla.fenix.mvi.UIComponentViewModelBase -import org.mozilla.fenix.mvi.UIView - -class QuickActionComponent( - private val container: ViewGroup, - bus: ActionBusFactory, - viewModelProvider: UIComponentViewModelProvider -) : UIComponent( - bus.getManagedEmitter(QuickActionAction::class.java), - bus.getSafeManagedObservable(QuickActionChange::class.java), - viewModelProvider -) { - override fun initView(): UIView = - QuickActionUIView(container, actionEmitter, changesObservable) - - init { - bind() - } -} - -data class QuickActionState( - val readable: Boolean, - val bookmarked: Boolean, - val readerActive: Boolean, - val bounceNeeded: Boolean, - val isAppLink: Boolean -) : ViewState - -sealed class QuickActionAction : Action { - object Opened : QuickActionAction() - object Closed : QuickActionAction() - object SharePressed : QuickActionAction() - object DownloadsPressed : QuickActionAction() - object BookmarkPressed : QuickActionAction() - object ReadPressed : QuickActionAction() - object ReadAppearancePressed : QuickActionAction() - object OpenAppLinkPressed : QuickActionAction() -} - -sealed class QuickActionChange : Change { - data class BookmarkedStateChange(val bookmarked: Boolean) : QuickActionChange() - data class ReadableStateChange(val readable: Boolean) : QuickActionChange() - data class ReaderActiveStateChange(val active: Boolean) : QuickActionChange() - data class AppLinkStateChange(val isAppLink: Boolean) : QuickActionChange() - object BounceNeededChange : QuickActionChange() -} - -class QuickActionViewModel( - initialState: QuickActionState -) : UIComponentViewModelBase(initialState, reducer) { - companion object { - val reducer: Reducer = { state, change -> - when (change) { - is QuickActionChange.BounceNeededChange -> { - state.copy(bounceNeeded = true) - } - is QuickActionChange.BookmarkedStateChange -> { - state.copy(bookmarked = change.bookmarked) - } - is QuickActionChange.ReadableStateChange -> { - state.copy(readable = change.readable) - } - is QuickActionChange.ReaderActiveStateChange -> { - state.copy(readerActive = change.active) - } - is QuickActionChange.AppLinkStateChange -> { - state.copy(isAppLink = change.isAppLink) - } - } - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionInteractor.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionInteractor.kt new file mode 100644 index 000000000..66aa0d0fc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionInteractor.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.quickactionsheet + +import android.content.Context +import android.content.Intent +import androidx.annotation.CallSuper +import mozilla.components.browser.session.Session +import mozilla.components.feature.app.links.AppLinksUseCases +import org.mozilla.fenix.browser.readermode.ReaderModeController +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.utils.ItsNotBrokenSnack + +/** + * Interactor for the QuickActionSheet + */ +class QuickActionInteractor( + private val context: Context, + private val readerModeController: ReaderModeController, + private val quickActionStore: QuickActionSheetStore, + private val shareUrl: (String) -> Unit, + private val bookmarkTapped: (Session) -> Unit, + private val appLinksUseCases: AppLinksUseCases +) : QuickActionSheetInteractor { + + private val selectedSession + inline get() = context.components.core.sessionManager.selectedSession + + @CallSuper + override fun onOpened() { + context.metrics.track(Event.QuickActionSheetOpened) + } + + @CallSuper + override fun onClosed() { + context.metrics.track(Event.QuickActionSheetClosed) + } + + @CallSuper + override fun onSharedPressed() { + context.metrics.track(Event.QuickActionSheetShareTapped) + selectedSession?.url?.let(shareUrl) + } + + @CallSuper + override fun onDownloadsPressed() { + context.metrics.track(Event.QuickActionSheetDownloadTapped) + ItsNotBrokenSnack(context).showSnackbar(issueNumber = "348") + } + + @CallSuper + override fun onBookmarkPressed() { + context.metrics.track(Event.QuickActionSheetBookmarkTapped) + selectedSession?.let(bookmarkTapped) + } + + @CallSuper + override fun onReadPressed() { + context.metrics.track(Event.QuickActionSheetReadTapped) + val enabled = selectedSession?.readerMode ?: false + if (enabled) { + readerModeController.hideReaderView() + } else { + readerModeController.showReaderView() + } + quickActionStore.dispatch(QuickActionSheetAction.ReaderActiveStateChange(!enabled)) + } + + @CallSuper + override fun onOpenAppLinkPressed() { + val getRedirect = appLinksUseCases.appLinkRedirect + val redirect = selectedSession?.let { + getRedirect.invoke(it.url) + } ?: return + + redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK + appLinksUseCases.openAppLink.invoke(redirect) + } + + @CallSuper + override fun onAppearancePressed() { + // TODO telemetry: https://github.com/mozilla-mobile/fenix/issues/2267 + readerModeController.showControls() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt index 58261c1a1..dc87a8ef1 100644 --- a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt @@ -5,26 +5,24 @@ package org.mozilla.fenix.quickactionsheet import android.content.Context +import android.os.Bundle import android.util.AttributeSet import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo import android.widget.LinearLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.widget.NestedScrollView import com.google.android.material.bottomsheet.BottomSheetBehavior import mozilla.components.browser.toolbar.BrowserToolbar import org.mozilla.fenix.R -import android.os.Bundle -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import org.mozilla.fenix.utils.Settings -import kotlin.coroutines.CoroutineContext const val POSITION_SNAP_BUFFER = 1f @@ -33,21 +31,18 @@ class QuickActionSheet @JvmOverloads constructor( attrs: AttributeSet? = null, defStyle: Int = 0, defStyleRes: Int = 0 -) : LinearLayout(context, attrs, defStyle, defStyleRes), CoroutineScope { +) : LinearLayout(context, attrs, defStyle, defStyleRes) { - private lateinit var job: Job - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job + private val scope = MainScope() private lateinit var quickActionSheetBehavior: QuickActionSheetBehavior init { - inflate(getContext(), R.layout.layout_quick_action_sheet, this) + inflate(context, R.layout.layout_quick_action_sheet, this) } override fun onAttachedToWindow() { super.onAttachedToWindow() - job = Job() quickActionSheetBehavior = BottomSheetBehavior.from(quick_action_sheet.parent as View) as QuickActionSheetBehavior quickActionSheetBehavior.isHideable = false @@ -56,7 +51,7 @@ class QuickActionSheet @JvmOverloads constructor( override fun onDetachedFromWindow() { super.onDetachedFromWindow() - job.cancel() + scope.cancel() } private fun setupHandle() { @@ -71,13 +66,13 @@ class QuickActionSheet @JvmOverloads constructor( } fun bounceSheet() { - launch(Main) { + Settings.getInstance(context).incrementAutomaticBounceQuickActionSheetCount() + scope.launch(Dispatchers.Main) { delay(BOUNCE_ANIMATION_DELAY_LENGTH) quickActionSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED delay(BOUNCE_ANIMATION_PAUSE_LENGTH) quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } - Settings.getInstance(context).incrementAutomaticBounceQuickActionSheetCount() } class HandleAccessibilityDelegate( @@ -98,17 +93,16 @@ class QuickActionSheet @JvmOverloads constructor( } override fun performAccessibilityAction(host: View?, action: Int, args: Bundle?): Boolean { - when (action) { - AccessibilityNodeInfo.ACTION_CLICK -> { - finalState = when (quickActionSheetBehavior.state) { + finalState = when (action) { + AccessibilityNodeInfo.ACTION_CLICK -> + when (quickActionSheetBehavior.state) { BottomSheetBehavior.STATE_EXPANDED -> BottomSheetBehavior.STATE_COLLAPSED else -> BottomSheetBehavior.STATE_EXPANDED } - } AccessibilityNodeInfo.ACTION_COLLAPSE -> - finalState = BottomSheetBehavior.STATE_COLLAPSED + BottomSheetBehavior.STATE_COLLAPSED AccessibilityNodeInfo.ACTION_EXPAND -> - finalState = BottomSheetBehavior.STATE_EXPANDED + BottomSheetBehavior.STATE_EXPANDED else -> return super.performAccessibilityAction(host, action, args) } @@ -133,7 +127,6 @@ class QuickActionSheet @JvmOverloads constructor( } } -@Suppress("unused") // Referenced from XML class QuickActionSheetBehavior( context: Context, attrs: AttributeSet @@ -168,4 +161,9 @@ class QuickActionSheetBehavior( } quickActionSheetContainer.translationY = toolbar.translationY + toolbar.height * -1.0f } + + companion object { + fun from(view: NestedScrollView) = + BottomSheetBehavior.from(view) as QuickActionSheetBehavior + } } diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetStore.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetStore.kt new file mode 100644 index 000000000..c662c4f57 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetStore.kt @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.quickactionsheet + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * The [Store] for holding the [QuickActionSheetState] and applying [QuickActionSheetAction]s. + */ +class QuickActionSheetStore(initialState: QuickActionSheetState) : + Store(initialState, ::quickActionSheetStateReducer) + +/** + * The state for the QuickActionSheet found in the Browser Fragment + * @property readable Whether or not the current session can display a reader view + * @property bookmarked Whether or not the current session is already bookmarked + * @property readerActive Whether or not the current session is in reader mode + * @property bounceNeeded Whether or not the quick action sheet should bounce + */ +data class QuickActionSheetState( + val readable: Boolean, + val bookmarked: Boolean, + val readerActive: Boolean, + val bounceNeeded: Boolean, + val isAppLink: Boolean +) : State + +/** + * Actions to dispatch through the [QuickActionSheetStore] to modify [QuickActionSheetState] through the reducer. + */ +sealed class QuickActionSheetAction : Action { + data class BookmarkedStateChange(val bookmarked: Boolean) : QuickActionSheetAction() + data class ReadableStateChange(val readable: Boolean) : QuickActionSheetAction() + data class ReaderActiveStateChange(val active: Boolean) : QuickActionSheetAction() + data class AppLinkStateChange(val isAppLink: Boolean) : QuickActionSheetAction() + object BounceNeededChange : QuickActionSheetAction() +} + +/** + * Reduces [QuickActionSheetAction]s to update [QuickActionSheetState]. + */ +fun quickActionSheetStateReducer( + state: QuickActionSheetState, + action: QuickActionSheetAction +): QuickActionSheetState { + return when (action) { + is QuickActionSheetAction.BookmarkedStateChange -> + state.copy(bookmarked = action.bookmarked) + is QuickActionSheetAction.ReadableStateChange -> + state.copy(readable = action.readable) + is QuickActionSheetAction.ReaderActiveStateChange -> + state.copy(readerActive = action.active) + is QuickActionSheetAction.BounceNeededChange -> + state.copy(bounceNeeded = true) + is QuickActionSheetAction.AppLinkStateChange -> { + state.copy(isAppLink = action.isAppLink) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionUIView.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionUIView.kt deleted file mode 100644 index 6ae82163f..000000000 --- a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionUIView.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.quickactionsheet - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.annotation.DrawableRes -import androidx.core.content.edit -import androidx.core.widget.NestedScrollView -import com.google.android.material.bottomsheet.BottomSheetBehavior -import io.reactivex.Observable -import io.reactivex.Observer -import io.reactivex.functions.Consumer -import kotlinx.android.synthetic.main.fragment_browser.* -import kotlinx.android.synthetic.main.layout_quick_action_sheet.* -import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.* -import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelativeWithIntrinsicBounds -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.mvi.UIView -import org.mozilla.fenix.utils.Settings - -class QuickActionUIView( - container: ViewGroup, - actionEmitter: Observer, - changesObservable: Observable -) : UIView(container, actionEmitter, changesObservable) { - - override val view: NestedScrollView = LayoutInflater.from(container.context) - .inflate(R.layout.component_quick_action_sheet, container, true) - .findViewById(R.id.nestedScrollQuickAction) as NestedScrollView - - val quickActionSheet = view.quick_action_sheet as QuickActionSheet - - init { - val quickActionSheetBehavior = - BottomSheetBehavior.from(nestedScrollQuickAction as View) as QuickActionSheetBehavior - - quickActionSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(v: View, state: Int) { - updateImportantForAccessibility(state) - - if (state == BottomSheetBehavior.STATE_EXPANDED) { - actionEmitter.onNext(QuickActionAction.Opened) - } else if (state == BottomSheetBehavior.STATE_COLLAPSED) { - actionEmitter.onNext(QuickActionAction.Closed) - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - animateOverlay(slideOffset) - } - }) - - updateImportantForAccessibility(quickActionSheetBehavior.state) - - view.quick_action_share.setOnClickListener { - actionEmitter.onNext(QuickActionAction.SharePressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - view.quick_action_downloads.setOnClickListener { - actionEmitter.onNext(QuickActionAction.DownloadsPressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - view.quick_action_bookmark.setOnClickListener { - actionEmitter.onNext(QuickActionAction.BookmarkPressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - view.quick_action_read.setOnClickListener { - actionEmitter.onNext(QuickActionAction.ReadPressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - view.quick_action_read_appearance.setOnClickListener { - actionEmitter.onNext(QuickActionAction.ReadAppearancePressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - view.quick_action_open_app_link.setOnClickListener { - actionEmitter.onNext(QuickActionAction.OpenAppLinkPressed) - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - } - - /** - * Changes alpha of overlay based on new offset of this sheet within [-1,1] range. - */ - private fun animateOverlay(offset: Float) { - overlay.alpha = (1 - offset) - } - - private fun updateImportantForAccessibility(state: Int) { - view.findViewById(R.id.quick_action_buttons_layout).importantForAccessibility = - if (state == BottomSheetBehavior.STATE_COLLAPSED || state == BottomSheetBehavior.STATE_HIDDEN) - View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - else - View.IMPORTANT_FOR_ACCESSIBILITY_AUTO - } - - private fun sendTelemetryEvent(state: Int) { - when (state) { - BottomSheetBehavior.STATE_EXPANDED -> - view.context.components.analytics.metrics.track(Event.QuickActionSheetOpened) - BottomSheetBehavior.STATE_COLLAPSED -> - view.context.components.analytics.metrics.track(Event.QuickActionSheetClosed) - } - } - - @Suppress("ComplexMethod") - override fun updateView() = Consumer { - view.quick_action_read.apply { - visibility = if (it.readable) View.VISIBLE else View.GONE - - val shouldNotify = Settings.getInstance(context).preferences - .getBoolean(context.getString(R.string.pref_key_reader_mode_notification), true) - updateReaderModeButton(it.readable && shouldNotify) - - isSelected = it.readerActive - text = if (it.readerActive) { - context.getString(R.string.quick_action_read_close) - } else { - context.getString(R.string.quick_action_read) - } - } - view.quick_action_read_appearance.visibility = if (it.readerActive) View.VISIBLE else View.GONE - view.quick_action_bookmark.isSelected = it.bookmarked - - view.quick_action_bookmark.text = if (it.bookmarked) { - view.context.getString(R.string.quick_action_bookmark_edit) - } else { - view.context.getString(R.string.quick_action_bookmark) - } - - if (it.bounceNeeded && Settings.getInstance(view.context).shouldAutoBounceQuickActionSheet) { - quickActionSheet.bounceSheet() - } - - view.quick_action_open_app_link.apply { - visibility = if (it.isAppLink) View.VISIBLE else View.GONE - } - } - - private fun updateReaderModeButton(withNotification: Boolean) { - @DrawableRes - val readerTwoStateDrawableId = if (withNotification) { - quickActionSheet.bounceSheet() - Settings.getInstance(view.context).preferences.edit { - putBoolean(view.context.getString(R.string.pref_key_reader_mode_notification), false) - } - R.drawable.reader_two_state_with_notification - } else { - R.drawable.reader_two_state - } - view.quick_action_read.putCompoundDrawablesRelativeWithIntrinsicBounds( - top = view.context.getDrawable(readerTwoStateDrawableId) - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionView.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionView.kt new file mode 100644 index 000000000..504eb43b5 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionView.kt @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.quickactionsheet + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.NestedScrollView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.fragment_browser.* +import kotlinx.android.synthetic.main.layout_quick_action_sheet.* +import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.* +import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelativeWithIntrinsicBounds +import org.mozilla.fenix.R +import org.mozilla.fenix.utils.Settings + +interface QuickActionSheetInteractor { + fun onOpened() + fun onClosed() + fun onSharedPressed() + fun onDownloadsPressed() + fun onBookmarkPressed() + fun onReadPressed() + fun onAppearancePressed() + fun onOpenAppLinkPressed() +} +/** + * View for the quick action sheet that slides out from the toolbar. + */ +class QuickActionView( + override val containerView: ViewGroup, + private val interactor: QuickActionSheetInteractor +) : LayoutContainer, View.OnClickListener { + + val view: NestedScrollView = LayoutInflater.from(containerView.context) + .inflate(R.layout.component_quick_action_sheet, containerView, true) + .findViewById(R.id.nestedScrollQuickAction) + + private val quickActionSheet = view.quick_action_sheet as QuickActionSheet + private val quickActionSheetBehavior = QuickActionSheetBehavior.from(nestedScrollQuickAction) + + init { + quickActionSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(v: View, state: Int) { + updateImportantForAccessibility(state) + + if (state == BottomSheetBehavior.STATE_EXPANDED) { + interactor.onOpened() + } else if (state == BottomSheetBehavior.STATE_COLLAPSED) { + interactor.onClosed() + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + animateOverlay(slideOffset) + } + }) + + updateImportantForAccessibility(quickActionSheetBehavior.state) + + view.quick_action_share.setOnClickListener(this) + view.quick_action_downloads.setOnClickListener(this) + view.quick_action_bookmark.setOnClickListener(this) + view.quick_action_read.setOnClickListener(this) + view.quick_action_appearance.setOnClickListener(this) + view.quick_action_open_app_link.setOnClickListener(this) + } + + /** + * Handles clicks from quick action buttons + */ + override fun onClick(button: View) { + when (button.id) { + R.id.quick_action_share -> interactor.onSharedPressed() + R.id.quick_action_downloads -> interactor.onDownloadsPressed() + R.id.quick_action_bookmark -> interactor.onBookmarkPressed() + R.id.quick_action_read -> interactor.onReadPressed() + R.id.quick_action_appearance -> interactor.onAppearancePressed() + R.id.quick_action_open_app_link -> interactor.onOpenAppLinkPressed() + else -> return + } + quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + /** + * Changes alpha of overlay based on new offset of this sheet within [-1,1] range. + */ + private fun animateOverlay(offset: Float) { + overlay.alpha = (1 - offset) + } + + /** + * Updates the important for accessibility flag on the buttons container, + * depending on if the sheet is opened or closed. + */ + private fun updateImportantForAccessibility(state: Int) { + view.quick_action_buttons_layout.importantForAccessibility = when (state) { + BottomSheetBehavior.STATE_COLLAPSED, BottomSheetBehavior.STATE_HIDDEN -> + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + else -> + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + } + } + + fun update(state: QuickActionSheetState) { + view.quick_action_read.isVisible = state.readable + view.quick_action_read.isSelected = state.readerActive + view.quick_action_read.text = view.context.getString( + if (state.readerActive) R.string.quick_action_read_close else R.string.quick_action_read + ) + notifyReaderModeButton(state.readable) + + view.quick_action_appearance.isVisible = state.readerActive + + view.quick_action_bookmark.isSelected = state.bookmarked + view.quick_action_bookmark.text = view.context.getString( + if (state.bookmarked) R.string.quick_action_bookmark_edit else R.string.quick_action_bookmark + ) + + if (state.bounceNeeded && Settings.getInstance(view.context).shouldAutoBounceQuickActionSheet) { + quickActionSheet.bounceSheet() + } + + view.quick_action_open_app_link.apply { + visibility = if (state.isAppLink) View.VISIBLE else View.GONE + } + } + + private fun notifyReaderModeButton(readable: Boolean) { + val settings = Settings.getInstance(view.context).preferences + val shouldNotifyKey = view.context.getString(R.string.pref_key_reader_mode_notification) + + @DrawableRes + val readerTwoStateDrawableRes = if (readable && settings.getBoolean(shouldNotifyKey, true)) { + quickActionSheet.bounceSheet() + settings.edit { putBoolean(shouldNotifyKey, false) } + R.drawable.reader_two_state_with_notification + } else { + R.drawable.reader_two_state + } + + view.quick_action_read.putCompoundDrawablesRelativeWithIntrinsicBounds( + top = view.context.getDrawable(readerTwoStateDrawableRes) + ) + } +} diff --git a/app/src/main/res/layout/layout_quick_action_sheet.xml b/app/src/main/res/layout/layout_quick_action_sheet.xml index 3f82d6ed7..1bc527719 100644 --- a/app/src/main/res/layout/layout_quick_action_sheet.xml +++ b/app/src/main/res/layout/layout_quick_action_sheet.xml @@ -78,7 +78,7 @@ android:textSize="12sp" />