From 4b8e9d0c53342c3080293bb84881da976d7cc323 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 27 Jul 2026 08:14:23 +0200 Subject: [PATCH] Put the document actions where the thumb is The toolbar menu is gone. What can be done with the open document is now two buttons over its bottom right corner: search, which is what a reader reaches for most, and one that unfolds the other seven with their names next to them. The top right corner of a modern phone is the one place a thumb holding it cannot reach, and it is where every one of these lived - four of them behind "More options", which says nothing about what is inside. Naming them costs a tap that the overflow charged anyway. DocumentActions is a view rather than a menu, so the actions are set whenever a load finishes instead of whenever something happens to invalidate the menu. That is the reason for the odd rule the old code lived by: prepareMenu ran from onCreateMenu only, and a document that finished loading does not invalidate anything, so which items were visible depended on what else had asked the toolbar to rebuild. There is no menu provider left in either class, and menu_main.xml is deleted. The rows scroll: seven of them do not fit above the buttons on a phone held sideways. They hide while an action mode is up - find, tts and edit each bring their own controls - and while fullscreen has the screen, which back still leaves exactly as before. Back folds them up first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RxF8q9WCwGcu5AHr9yZber --- .../droid/test/MainActivityTests.kt | 39 ++-- .../droid/ui/activity/DocumentFragment.kt | 168 ++++++++++----- .../droid/ui/activity/MainActivity.kt | 95 ++++++--- .../droid/ui/widget/DocumentActions.kt | 196 ++++++++++++++++++ .../res/drawable/bg_document_action_label.xml | 9 + app/src/main/res/drawable/ic_close.xml | 10 + app/src/main/res/drawable/ic_more_vert.xml | 10 + app/src/main/res/drawable/ic_open_in_new.xml | 10 + app/src/main/res/drawable/ic_print.xml | 10 + app/src/main/res/drawable/ic_share.xml | 10 + app/src/main/res/drawable/ic_volume_up.xml | 10 + app/src/main/res/layout/fragment_document.xml | 36 +++- .../main/res/layout/item_document_action.xml | 29 +++ .../main/res/layout/view_document_actions.xml | 64 ++++++ app/src/main/res/menu/menu_main.xml | 59 ------ app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 2 + 17 files changed, 586 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt create mode 100644 app/src/main/res/drawable/bg_document_action_label.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_open_in_new.xml create mode 100644 app/src/main/res/drawable/ic_print.xml create mode 100644 app/src/main/res/drawable/ic_share.xml create mode 100644 app/src/main/res/drawable/ic_volume_up.xml create mode 100644 app/src/main/res/layout/item_document_action.xml create mode 100644 app/src/main/res/layout/view_document_actions.xml delete mode 100644 app/src/main/res/menu/menu_main.xml diff --git a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt index ec18c4b0b3d1..7dc86df7dcf5 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -22,9 +22,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withClassName -import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -121,7 +119,11 @@ class MainActivityTests { // next onView will be blocked until the idling resource is idle, which now covers // the load itself and not just the picker round trip. - clickEditWithOverflowFallback() + waitForDocumentActions() + + unfoldDocumentActions() + + onView(withText(R.string.menu_edit)).check(matches(isDisplayed())) } @Test @@ -132,7 +134,7 @@ class MainActivityTests { // next onView will be blocked until the idling resource is idle, which now covers // the load itself and not just the picker round trip. - clickEditWithOverflowFallback() + waitForDocumentActions() // there used to be a 10s sleep here that asserted nothing, and it is why "testPDF // crashed" was an api 34 bucket of its own: the app is killed whenever play services @@ -185,8 +187,8 @@ class MainActivityTests { onView(withId(android.R.id.button1)).perform(click()) - // Check if edit button becomes available (indicating successful load) - clickEditWithOverflowFallback() + // Check if the document buttons become available (indicating successful load) + waitForDocumentActions() } @Test @@ -269,22 +271,15 @@ class MainActivityTests { .perform(click()) } - private fun clickEditWithOverflowFallback() { - onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled())) - .withFailureHandler { _, _ -> - // fails on small screens, try again with overflow menu - onView(allOf(withContentDescription("More options"), isDisplayed())) - .perform(click()) - - onView( - allOf( - withId(R.id.menu_edit), - withContentDescription("Edit document"), - isDisplayed(), - ) - ) - .perform(click()) - } + // the buttons of the open document are up once it has loaded, so this blocks on the idling + // resource and then says whether anything came of the load. only the button that unfolds the + // rest is checked: what is inside it differs per format, a pdf cannot be edited. + private fun waitForDocumentActions() { + onView(withId(R.id.document_actions_more)).check(matches(isDisplayed())) + } + + private fun unfoldDocumentActions() { + onView(withId(R.id.document_actions_more)).perform(click()) } private fun recreate(activity: MainActivity): MainActivity? { diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index 5fe7aa31c5bc..ed774bbe0b88 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -8,16 +8,13 @@ import android.os.Handler import android.os.Looper import android.text.InputType import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -32,6 +29,7 @@ import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.CrashManager import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper +import app.opendocument.droid.ui.widget.DocumentActions import app.opendocument.droid.ui.widget.PageView import app.opendocument.droid.ui.widget.ProgressDialogFragment import com.google.android.material.tabs.TabLayout @@ -40,7 +38,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException -class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider { +class DocumentFragment : Fragment(), LoaderService.LoaderListener { private lateinit var mainHandler: Handler @@ -56,7 +54,15 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider var pageView: PageView? = null private set - private var menu: Menu? = null + private lateinit var actions: DocumentActions + + /** Folding the actions back up is what back does first, while they are unfolded. */ + private val actionsBackCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + actions.collapse() + } + } private var resultOnStart: FileLoader.Result? = null private var errorOnStart: Throwable? = null @@ -135,11 +141,7 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - requireActivity().addMenuProvider(this, requireActivity()) - - return inflater.inflate(R.layout.fragment_document, container, false) - } + ): View? = inflater.inflate(R.layout.fragment_document, container, false) private fun initializePageView() { pageView?.let { @@ -186,6 +188,16 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider analyticsManager = mainActivity.analyticsManager crashManager = mainActivity.crashManager + actions = view.findViewById(R.id.document_actions) + actions.listener = DocumentActions.Listener { action -> + mainActivity.onDocumentAction(action) + } + actions.expandedListener = { expanded -> actionsBackCallback.isEnabled = expanded } + + // on viewLifecycleOwner, so it stacks above the activity's own callback - the dispatcher + // runs the most recently added enabled callback first + mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, actionsBackCallback) + serviceQueue = mainActivity.loaderServiceQueue serviceQueue.addToQueue { service -> service.setListener(this) } @@ -213,6 +225,7 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider prepareLoad(lastResult.loaderType, false) restoreTabs(lastResult) + prepareActions(lastResult) } pageView?.restoreState(savedInstanceState) @@ -244,24 +257,6 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider tabLayout.addOnTabSelectedListener(tabSelectedListener) } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - this.menu = menu - - menu.findItem(R.id.menu_fullscreen).isVisible = true - menu.findItem(R.id.menu_open_with).isVisible = true - menu.findItem(R.id.menu_share).isVisible = true - menu.findItem(R.id.menu_save).isVisible = true - menu.findItem(R.id.menu_print).isVisible = true - - // the other menu items are dynamically enabled based on the loaded document - state.lastResult?.let { prepareMenu(it.loaderType, it.options.fileType) } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - // TODO: handle menu item clicks here. currently done in Activity for historical reasons - return false - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -360,7 +355,10 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider } private fun unload() { - toggleDocumentMenu(false) + // guarded like resetTabs below: a load can fail before there is a view to put right + if (::actions.isInitialized) { + actions.setActions(null, emptyList()) + } resetTabs() } @@ -375,32 +373,17 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider state.lastSelectedTab = -1 } - private fun toggleDocumentMenu(enabled: Boolean) { - toggleDocumentMenu(enabled, enabled) - } - - private fun toggleDocumentMenu(enabled: Boolean, editEnabled: Boolean) { - val menu = this.menu - if (menu == null) { - val activity = activity - val pageView = this.pageView - if (activity == null || activity.isFinishing || pageView == null) { - return - } - - // menu is not set when loadUri is called via onStart, retry later - pageView.post { toggleDocumentMenu(enabled, editEnabled) } - - return - } - - menu.findItem(R.id.menu_edit).isVisible = editEnabled - - menu.findItem(R.id.menu_search).isVisible = enabled - menu.findItem(R.id.menu_tts).isVisible = enabled - } + /** + * Puts the buttons of the loaded document up, in the order they are worth reaching for. + * + * Called for every result rather than from a menu callback, which is what the toolbar version + * relied on: the menu was only rebuilt when something happened to invalidate it, and a document + * that finished loading is not one of those things. + */ + private fun prepareActions(result: FileLoader.Result) { + val loaderType = result.loaderType + val fileType = result.options.fileType - private fun prepareMenu(loaderType: FileLoader.LoaderType, fileType: String?) { var isEditEnabled = false var isDarkModeSupported = true @@ -423,10 +406,81 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider } } - toggleDocumentMenu(true, isEditEnabled) + val unfolding = ArrayList() + if (isEditEnabled) { + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_edit, + R.drawable.ic_edit, + ) + ) + } + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_TTS, + R.string.menu_tts, + R.drawable.ic_volume_up, + ) + ) + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_SHARE, + R.string.menu_share, + R.drawable.ic_share, + ) + ) + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_PRINT, + R.string.menu_cloud_print, + R.drawable.ic_print, + ) + ) + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_OPEN_WITH, + R.string.menu_open_with, + R.drawable.ic_open_in_new, + ) + ) + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_SAVE, + R.string.action_edit_save, + R.drawable.ic_save, + ) + ) + unfolding.add( + DocumentActions.Action( + DocumentActions.ACTION_FULLSCREEN, + R.string.menu_fullscreen, + R.drawable.ic_fullscreen, + ) + ) + + actions.setActions( + DocumentActions.Action( + DocumentActions.ACTION_SEARCH, + R.string.menu_search, + R.drawable.ic_search, + ), + unfolding, + ) + pageView?.toggleDarkMode(isDarkModeSupported) } + /** Takes the buttons away while the document has the screen to itself. */ + fun setActionsVisible(visible: Boolean) { + if (!::actions.isInitialized) { + return + } + + actions.collapse() + actions.visibility = if (visible) View.VISIBLE else View.GONE + } + private fun requestInAppRating(activity: Activity) { analyticsManager.report("in_app_review_eligible") @@ -494,6 +548,8 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider loadData(result.partUris[0].toString()) } + prepareActions(result) + if ( result.loaderType == FileLoader.LoaderType.RAW || result.loaderType == FileLoader.LoaderType.ONLINE diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 30779b51bdfb..0545b4029675 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -12,15 +12,11 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.view.ActionMode -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -41,10 +37,11 @@ import app.opendocument.droid.ui.FindActionModeCallback import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper import app.opendocument.droid.ui.TtsActionModeCallback +import app.opendocument.droid.ui.widget.DocumentActions import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -class MainActivity : AppCompatActivity(), MenuProvider { +class MainActivity : AppCompatActivity() { private lateinit var handler: Handler @@ -255,8 +252,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { analyticsManager.setCurrentScreen(this, "screen_main") } - - addMenuProvider(this, this) } override fun onStart() { @@ -363,10 +358,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_main, menu) - } - // The play services availability dialog calls startActivityForResult() itself with a // request code we hand it, so its result cannot be routed through an // ActivityResultLauncher. Everything the app launches on its own goes through the @@ -435,11 +426,16 @@ class MainActivity : AppCompatActivity(), MenuProvider { documentFragment.loadUri(uri, isPersistentUri) } - override fun onMenuItemSelected(item: MenuItem): Boolean { + /** + * A button of the open document was tapped. These used to be the toolbar menu, and the ids are + * now [DocumentActions]' own - the handling stays here, where the action modes and the printing + * manager already live. + */ + fun onDocumentAction(action: Int) { val documentFragment = this.documentFragment - when (item.itemId) { - R.id.menu_search -> { + when (action) { + DocumentActions.ACTION_SEARCH -> { val findActionModeCallback = FindActionModeCallback(this) documentFragment?.pageView?.let { findActionModeCallback.setWebView(it) } startSupportActionMode(findActionModeCallback) @@ -448,25 +444,25 @@ class MainActivity : AppCompatActivity(), MenuProvider { analyticsManager.report(AnalyticsConstants.EVENT_SEARCH) } - R.id.menu_open_with -> { + DocumentActions.ACTION_OPEN_WITH -> { documentFragment?.openWith(this) analyticsManager.report("menu_open_with") } - R.id.menu_save -> { + DocumentActions.ACTION_SAVE -> { documentFragment?.prepareSave({ requestSave() }, true) analyticsManager.report("menu_save") } - R.id.menu_share -> { + DocumentActions.ACTION_SHARE -> { documentFragment?.share(this) analyticsManager.report("menu_share") } - R.id.menu_fullscreen -> { + DocumentActions.ACTION_FULLSCREEN -> { if (fullscreen) { analyticsManager.report("menu_fullscreen_leave") @@ -496,9 +492,11 @@ class MainActivity : AppCompatActivity(), MenuProvider { } fullscreen = !fullscreen + + updateDocumentActionsVisible() } - R.id.menu_print -> { + DocumentActions.ACTION_PRINT -> { analyticsManager.report("menu_print") documentFragment?.pageView?.let { pageView -> @@ -508,7 +506,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { } } - R.id.menu_tts -> { + DocumentActions.ACTION_TTS -> { analyticsManager.report("menu_tts") documentFragment?.pageView?.let { pageView -> @@ -519,7 +517,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { } } - R.id.menu_edit -> { + DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") documentFragment?.let { fragment -> @@ -529,11 +527,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { startSupportActionMode(editActionMode) } } - - else -> return super.onOptionsItemSelected(item) } - - return true } private fun offerPurchase() { @@ -555,13 +549,56 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } - override fun onActionModeFinished(mode: ActionMode?) { - super.onActionModeFinished(mode) + /** + * The buttons of the document are only up while nothing else is using the screen: an action + * mode has taken the toolbar over and brought its own controls, and fullscreen is for reading. + * + * Counted rather than a flag, because the two kinds of action mode overlap - selecting text in + * the page starts a framework one on top of the appcompat one that edit mode is. + */ + private var actionModes = 0 + + private fun updateDocumentActionsVisible() { + documentFragment?.setActionsVisible(actionModes == 0 && !fullscreen) + } + + // the appcompat ones, which is what startSupportActionMode() raises: find, tts and edit + override fun onSupportActionModeStarted(mode: androidx.appcompat.view.ActionMode) { + super.onSupportActionModeStarted(mode) + + actionModes++ + + updateDocumentActionsVisible() + } + + override fun onSupportActionModeFinished(mode: androidx.appcompat.view.ActionMode) { + super.onSupportActionModeFinished(mode) + + actionModes-- + + updateDocumentActionsVisible() editActionMode = null ttsActionMode = null } + // and the framework ones, which is what selecting text in the page raises + override fun onActionModeStarted(mode: ActionMode?) { + super.onActionModeStarted(mode) + + actionModes++ + + updateDocumentActionsVisible() + } + + override fun onActionModeFinished(mode: ActionMode?) { + super.onActionModeFinished(mode) + + actionModes-- + + updateDocumentActionsVisible() + } + /** * Whether the ad removal is still worth offering: never in pro, where the purchase is implied, * and not once it has been bought. @@ -598,13 +635,13 @@ class MainActivity : AppCompatActivity(), MenuProvider { fullscreen = false + updateDocumentActionsVisible() + analyticsManager.report("fullscreen_end") } private fun closeDocument() { documentFragment?.let { fragment -> - removeMenuProvider(fragment) - supportFragmentManager.beginTransaction().remove(fragment).commitNow() documentFragment = null diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt new file mode 100644 index 000000000000..7d3572183a31 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt @@ -0,0 +1,196 @@ +package app.opendocument.droid.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import app.opendocument.droid.R +import com.google.android.material.floatingactionbutton.FloatingActionButton + +/** + * What can be done with the open document, as buttons over the bottom right corner of it: one for + * the action worth its own button, and one that unfolds the rest. + * + * This is what the toolbar menu used to be. A document is read with the phone in one hand, and the + * top right corner of a modern screen is the one place a thumb cannot reach - so the actions sit + * where the thumb already is, and the ones that were hidden behind "More options" now say what they + * are. + * + * Material ships no speed dial component (the one it had was never brought over to Material 3), so + * the rows are built here from [R.layout.item_document_action]. + */ +class DocumentActions(context: Context, attributeSet: AttributeSet?) : + FrameLayout(context, attributeSet) { + + /** One button, identified by one of the ACTION_ ids the caller gets back when it is tapped. */ + class Action(val id: Int, @param:StringRes val label: Int, @param:DrawableRes val icon: Int) + + fun interface Listener { + fun onDocumentActionClicked(id: Int) + } + + var listener: Listener? = null + + /** Told whenever the actions unfold or fold back up, so back can close them. */ + var expandedListener: ((Boolean) -> Unit)? = null + + private val scrim: View + private val rowsScroll: ScrollView + private val rows: LinearLayout + private val primaryButton: FloatingActionButton + private val moreButton: FloatingActionButton + + var isExpanded: Boolean = false + private set + + init { + LayoutInflater.from(context).inflate(R.layout.view_document_actions, this, true) + + scrim = findViewById(R.id.document_actions_scrim) + rowsScroll = findViewById(R.id.document_actions_rows_scroll) + rows = findViewById(R.id.document_actions_rows) + primaryButton = findViewById(R.id.document_actions_primary) + moreButton = findViewById(R.id.document_actions_more) + + scrim.setOnClickListener { collapse() } + moreButton.setOnClickListener { if (isExpanded) collapse() else expand() } + + setActions(null, emptyList()) + } + + /** + * What the document can do right now. [primary] gets a button of its own, the rest unfold out + * of the second one, the first of them closest to it - so the order is most wanted first. + * + * Nothing and an empty list take the buttons away entirely, which is what a document that + * failed to load leaves behind. + */ + fun setActions(primary: Action?, unfolding: List) { + collapse() + + if (primary == null) { + primaryButton.visibility = View.GONE + } else { + primaryButton.visibility = View.VISIBLE + primaryButton.setImageResource(primary.icon) + primaryButton.contentDescription = context.getString(primary.label) + primaryButton.setOnClickListener { listener?.onDocumentActionClicked(primary.id) } + } + + rows.removeAllViews() + + // added back to front, because the row added last is the one that ends up at the bottom + // of the column, right above the button they unfold from + for (action in unfolding.asReversed()) { + rows.addView(newRow(action)) + } + + moreButton.visibility = if (unfolding.isEmpty()) View.GONE else View.VISIBLE + } + + private fun newRow(action: Action): View { + val row = LayoutInflater.from(context).inflate(R.layout.item_document_action, rows, false) + + val label: TextView = row.findViewById(R.id.document_action_label) + label.setText(action.label) + + val button: FloatingActionButton = row.findViewById(R.id.document_action_button) + button.setImageResource(action.icon) + button.contentDescription = context.getString(action.label) + + // the button is only the visible half of the row: the label is as much of a target + val click = OnClickListener { + collapse() + + listener?.onDocumentActionClicked(action.id) + } + row.setOnClickListener(click) + button.setOnClickListener(click) + + return row + } + + private fun expand() { + if (isExpanded || rows.childCount == 0) { + return + } + + isExpanded = true + + // a fold that was still fading out would otherwise hide all of this again when it ends + scrim.animate().cancel() + rowsScroll.animate().cancel() + + scrim.alpha = 0f + scrim.visibility = View.VISIBLE + scrim.animate().alpha(1f).setDuration(ANIMATION_MILLIS) + + rowsScroll.alpha = 1f + rowsScroll.visibility = View.VISIBLE + + // the rows hang off the bottom of the scroller, and that is the end the buttons are at + rowsScroll.post { rowsScroll.fullScroll(View.FOCUS_DOWN) } + + val rise = RISE_DP * resources.displayMetrics.density + for (index in 0 until rows.childCount) { + val row = rows.getChildAt(index) + + row.alpha = 0f + row.translationY = rise + row.animate().alpha(1f).translationY(0f).setDuration(ANIMATION_MILLIS) + } + + moreButton.setImageResource(R.drawable.ic_close) + moreButton.contentDescription = context.getString(R.string.document_actions_close) + + expandedListener?.invoke(true) + } + + fun collapse() { + if (!isExpanded) { + // also the state setActions() starts from, so the views have to be put right anyway + scrim.visibility = View.GONE + rowsScroll.visibility = View.GONE + + return + } + + isExpanded = false + + scrim.animate().alpha(0f).setDuration(ANIMATION_MILLIS).withEndAction { + scrim.visibility = View.GONE + } + + rowsScroll.animate().alpha(0f).setDuration(ANIMATION_MILLIS).withEndAction { + rowsScroll.visibility = View.GONE + rowsScroll.alpha = 1f + } + + moreButton.setImageResource(R.drawable.ic_more_vert) + moreButton.contentDescription = context.getString(R.string.document_actions_more) + + expandedListener?.invoke(false) + } + + companion object { + const val ACTION_SEARCH: Int = 1 + const val ACTION_EDIT: Int = 2 + const val ACTION_TTS: Int = 3 + const val ACTION_SHARE: Int = 4 + const val ACTION_PRINT: Int = 5 + const val ACTION_OPEN_WITH: Int = 6 + const val ACTION_SAVE: Int = 7 + const val ACTION_FULLSCREEN: Int = 8 + + private const val ANIMATION_MILLIS = 150L + + /** How far below its place a row starts out, so the column rises as it appears. */ + private const val RISE_DP = 16f + } +} diff --git a/app/src/main/res/drawable/bg_document_action_label.xml b/app/src/main/res/drawable/bg_document_action_label.xml new file mode 100644 index 000000000000..4eaa6a035da3 --- /dev/null +++ b/app/src/main/res/drawable/bg_document_action_label.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000000..7a52b3b744f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000000..2d31aa6ebe86 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 000000000000..1bd20d427424 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_print.xml b/app/src/main/res/drawable/ic_print.xml new file mode 100644 index 000000000000..5175220eb535 --- /dev/null +++ b/app/src/main/res/drawable/ic_print.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000000..b9996940831c --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000000..62f0a8e4cc05 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_document.xml b/app/src/main/res/layout/fragment_document.xml index ff854beddbc6..774974211d26 100644 --- a/app/src/main/res/layout/fragment_document.xml +++ b/app/src/main/res/layout/fragment_document.xml @@ -1,20 +1,34 @@ - - - + android:layout_height="match_parent" + android:orientation="vertical"> - + + + - + diff --git a/app/src/main/res/layout/item_document_action.xml b/app/src/main/res/layout/item_document_action.xml new file mode 100644 index 000000000000..2cdcae9f12a5 --- /dev/null +++ b/app/src/main/res/layout/item_document_action.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_document_actions.xml b/app/src/main/res/layout/view_document_actions.xml new file mode 100644 index 000000000000..0823f936e4f7 --- /dev/null +++ b/app/src/main/res/layout/view_document_actions.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index 5e0e7c730bfe..000000000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 082052a015f3..012ea56e1d19 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -48,4 +48,10 @@ --> #FFFFFF + + #52000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c36f2b8818a1..d7e2b05ec1d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ Up one level Nothing here yet Open a document to get started. It will show up here next time. + More actions + Close actions Reading… Initializing TTS… Ready!