From 5eb607c2f0b6cfe14b9f838cbe7a174cbf46498e Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 16:09:19 -0500 Subject: [PATCH 1/2] feat(editor): add fullscreen mode and remove immersive controller --- .../activities/editor/BaseEditorActivity.kt | 64 ++-- .../activities/editor/FullscreenController.kt | 163 +++++++++ .../editor/LandscapeImmersiveController.kt | 344 ------------------ .../itsaky/androidide/ui/EditorBottomSheet.kt | 19 +- .../utils/WindowInsetsExtensions.kt | 42 +-- .../androidide/viewmodel/EditorViewModel.kt | 34 ++ .../res/drawable/bg_hollow_green_circle.xml | 8 - .../main/res/layout-land/content_editor.xml | 37 +- app/src/main/res/layout/content_editor.xml | 43 +-- .../src/main/res/drawable/ic_fullscreen.xml | 10 + .../main/res/drawable/ic_fullscreen_exit.xml | 10 + resources/src/main/res/values/dimens.xml | 2 +- resources/src/main/res/values/strings.xml | 4 +- 13 files changed, 299 insertions(+), 481 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt delete mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt delete mode 100644 app/src/main/res/drawable/bg_hollow_green_circle.xml create mode 100644 resources/src/main/res/drawable/ic_fullscreen.xml create mode 100644 resources/src/main/res/drawable/ic_fullscreen_exit.xml diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index d480abdec0..c76b5027a0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -177,7 +177,7 @@ abstract class BaseEditorActivity : private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null - private var immersiveController: LandscapeImmersiveController? = null + private var fullscreenController: FullscreenController? = null private val topEdgeThreshold by lazy { SizeUtils.dp2px(TOP_EDGE_SWIPE_THRESHOLD_DP) } var isDestroying = false @@ -455,8 +455,8 @@ abstract class BaseEditorActivity : editorBottomSheet = null gestureDetector = null - immersiveController?.destroy() - immersiveController = null + fullscreenController?.destroy() + fullscreenController = null _binding = null @@ -498,7 +498,6 @@ abstract class BaseEditorActivity : } private fun applyStandardInsets(systemBars: Insets) { - immersiveController?.onSystemBarInsetsChanged(systemBars.top) val root = _binding?.root ?: return val initial = root.getOrStoreInitialPadding() root.updatePadding(bottom = initial.bottom + systemBars.bottom) @@ -627,14 +626,21 @@ abstract class BaseEditorActivity : content.tabs.addOnTabSelectedListener(this) setupStateObservers() + setupFullscreenObserver() setupViews() - immersiveController = LandscapeImmersiveController( + fullscreenController = FullscreenController( contentBinding = content, bottomSheetBehavior = editorBottomSheet!!, + closeDrawerAction = { + binding.editorDrawerLayout.closeDrawer(GravityCompat.START) + }, + onFullscreenToggleRequested = { + editorViewModel.toggleFullscreen() + }, ).also { it.bind() - it.onConfigurationChanged(resources.configuration) + it.render(editorViewModel.isFullscreen, animate = false) } setupContainers() @@ -668,10 +674,10 @@ abstract class BaseEditorActivity : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - immersiveController?.onConfigurationChanged(newConfig) window?.decorView?.let { ViewCompat.requestApplyInsets(it) } reapplySystemBarInsetsFromRoot() _binding?.content?.applyBottomSheetAnchorForOrientation(newConfig.orientation) + fullscreenController?.render(editorViewModel.isFullscreen, animate = false) } private fun reapplySystemBarInsetsFromRoot() { @@ -844,7 +850,6 @@ abstract class BaseEditorActivity : } override fun onPause() { - immersiveController?.onPause() super.onPause() memoryUsageWatcher.listener = null memoryUsageWatcher.stopWatching(false) @@ -1223,6 +1228,16 @@ abstract class BaseEditorActivity : } } + private fun setupFullscreenObserver() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + editorViewModel.uiState.collectLatest { uiState -> + fullscreenController?.render(uiState.isFullscreen, animate = true) + } + } + } + } + private fun setupViews() { setupNoEditorView() setupBottomSheet() @@ -1383,7 +1398,7 @@ abstract class BaseEditorActivity : content.apply { viewContainer.viewTreeObserver.addOnGlobalLayoutListener(observer) - bottomSheet.setOffsetAnchor(editorAppBarLayout) + applyBottomSheetAnchorForOrientation(resources.configuration.orientation) } } @@ -1447,31 +1462,38 @@ abstract class BaseEditorActivity : val isHorizontalSwipe = abs(diffX) > abs(diffY) val hasDownFlingDistance = diffY > flingDistanceThreshold - val hasRightFlingDistance = diffX > flingDistanceThreshold + val hasUpFlingDistance = diffY < -flingDistanceThreshold val hasVerticalVelocity = abs(velocityY) > flingVelocityThreshold val hasHorizontalVelocity = abs(velocityX) > flingVelocityThreshold + val hasRightFlingDistance = diffX > flingDistanceThreshold + + val screenHeight = resources.displayMetrics.heightPixels + val bottomEdgeThreshold = screenHeight - topEdgeThreshold - // Check for a swipe down (to show top bar) - // This is placed before the noFilesOpen check so it works while editing - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val startedNearTopEdge = e1.y < topEdgeThreshold + val startedNearBottomEdge = e1.y > bottomEdgeThreshold - if (isLandscape && startedNearTopEdge && hasDownFlingDistance && hasVerticalVelocity && isVerticalSwipe) { - immersiveController?.showTopBar() - return true + if (isVerticalSwipe && hasVerticalVelocity && startedNearTopEdge && hasDownFlingDistance) { + if (editorViewModel.isFullscreen) { + editorViewModel.exitFullscreen() + return true + } + } + + if (isVerticalSwipe && hasVerticalVelocity && startedNearBottomEdge && hasUpFlingDistance) { + if (editorViewModel.isFullscreen) { + editorViewModel.exitFullscreen() + return true + } } - // Check if no files are open by looking at the displayedChild of the view flipper val noFilesOpen = content.viewContainer.displayedChild == 1 if (!noFilesOpen) { - return false // If files are open, do nothing + return false } - // Check for a right swipe (to open left drawer) - This part is still correct - // Added abs(diffX) > abs(diffY) to prevent diagonal swipes from triggering this if (hasRightFlingDistance && hasHorizontalVelocity && isHorizontalSwipe) { - // Use the correct binding for the drawer layout binding.editorDrawerLayout.openDrawer(GravityCompat.START) return true } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt new file mode 100644 index 0000000000..94e1ba373f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt @@ -0,0 +1,163 @@ +package com.itsaky.androidide.activities.editor + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.ContentEditorBinding +import kotlin.math.abs + +class FullscreenController( + private val contentBinding: ContentEditorBinding, + private val bottomSheetBehavior: BottomSheetBehavior, + private val closeDrawerAction: () -> Unit, + private val onFullscreenToggleRequested: () -> Unit, +) { + private val topBar = contentBinding.editorAppBarLayout + private val appBarContent = contentBinding.editorAppbarContent + private val editorContainer = contentBinding.editorContainer + private val fullscreenToggle = contentBinding.btnFullscreenToggle + + private var isBound = false + private var isTransitioning = false + private var currentFullscreen = false + private var defaultSkipCollapsed = false + + private val transitionDurationMs = 350L + + private val defaultEditorBottomMargin by lazy { + (editorContainer.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + } + + private val offsetListener = AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + val totalScrollRange = appBarLayout.totalScrollRange + if (totalScrollRange > 0) { + val collapseFraction = abs(verticalOffset).toFloat() / totalScrollRange.toFloat() + appBarContent.alpha = 1f - collapseFraction + } + } + + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheetView: View, newState: Int) { + handleBottomSheetStateChange(newState) + } + + override fun onSlide(bottomSheetView: View, slideOffset: Float) = Unit + } + + fun bind() { + if (isBound) return + isBound = true + + defaultSkipCollapsed = bottomSheetBehavior.skipCollapsed + bottomSheetBehavior.skipCollapsed = false + setupScrollFlags() + topBar.addOnOffsetChangedListener(offsetListener) + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) + fullscreenToggle.setOnClickListener { onFullscreenToggleRequested() } + } + + fun destroy() { + if (!isBound) return + isBound = false + + fullscreenToggle.setOnClickListener(null) + topBar.removeOnOffsetChangedListener(offsetListener) + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) + bottomSheetBehavior.skipCollapsed = defaultSkipCollapsed + fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + } + + fun render(isFullscreen: Boolean, animate: Boolean) { + val stateChanged = currentFullscreen != isFullscreen + val shouldAnimate = animate && stateChanged + currentFullscreen = isFullscreen + isTransitioning = shouldAnimate + + if (isFullscreen) { + applyFullscreen(shouldAnimate) + } else { + applyNonFullscreen(shouldAnimate) + } + + syncToggleUi(isFullscreen) + + if (shouldAnimate) { + fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + fullscreenToggle.postDelayed(clearTransitioningRunnable, transitionDurationMs) + } else { + isTransitioning = false + } + } + + private fun setupScrollFlags() { + appBarContent.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or + AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS or + AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP + } + } + + private fun handleBottomSheetStateChange(newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED && !currentFullscreen) { + bottomSheetBehavior.isHideable = false + } + + if (newState == BottomSheetBehavior.STATE_EXPANDED || + newState == BottomSheetBehavior.STATE_HALF_EXPANDED + ) { + if (currentFullscreen && !isTransitioning) { + onFullscreenToggleRequested() + } + } + } + + private fun applyFullscreen(animate: Boolean) { + closeDrawerAction() + + topBar.setExpanded(false, animate) + appBarContent.alpha = 0f + + bottomSheetBehavior.isHideable = true + if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + editorContainer.updateLayoutParams { + bottomMargin = 0 + } + } + + private fun applyNonFullscreen(animate: Boolean) { + topBar.setExpanded(true, animate) + appBarContent.alpha = 1f + + if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } else { + bottomSheetBehavior.isHideable = false + } + + editorContainer.updateLayoutParams { + bottomMargin = defaultEditorBottomMargin + } + } + + private fun syncToggleUi(isFullscreen: Boolean) { + if (isFullscreen) { + fullscreenToggle.setImageResource(R.drawable.ic_fullscreen_exit) + fullscreenToggle.contentDescription = + contentBinding.root.context.getString(R.string.desc_exit_fullscreen) + } else { + fullscreenToggle.setImageResource(R.drawable.ic_fullscreen) + fullscreenToggle.contentDescription = + contentBinding.root.context.getString(R.string.desc_enter_fullscreen) + } + } + + private val clearTransitioningRunnable = Runnable { + isTransitioning = false + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt deleted file mode 100644 index 1f5ad0d776..0000000000 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.itsaky.androidide.activities.editor - -import android.annotation.SuppressLint -import android.content.res.Configuration -import android.view.View -import android.view.View.OnLayoutChangeListener -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.core.view.updateLayoutParams -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.appbar.AppBarLayout -import com.itsaky.androidide.databinding.ContentEditorBinding -import kotlin.math.roundToInt - -/** - * Controls immersive behavior for the editor in landscape mode. - * - * Top bar: - * - expands/collapses through AppBarLayout behavior - * - stays visible until the user manually hides it - * - * Bottom bar: - * - remains backed by BottomSheetBehavior - * - can be visually hidden by translating the collapsed peek area - */ -class LandscapeImmersiveController( - contentBinding: ContentEditorBinding, - private val bottomSheetBehavior: BottomSheetBehavior, -) { - private val topBar = contentBinding.editorAppBarLayout - private val appBarContent = contentBinding.editorAppbarContent - private val viewContainer = contentBinding.viewContainer - private val editorContainer = contentBinding.editorContainer - private val bottomSheet = contentBinding.bottomSheet - private val topToggle = contentBinding.btnToggleTopBar - private val bottomToggle = contentBinding.btnToggleBottomBar - - private var isBound = false - - private var isLandscape = false - private var isTopBarRequestedVisible = true - private var isBottomBarRequestedVisible = true - private var isBottomBarShown = true - private var isPendingBottomBarHideAfterCollapse = false - - private var statusBarTopInset = 0 - private var currentAppBarOffset = 0 - private var lastKnownScrollRange = 0 - private val defaultEditorBottomMargin = - (editorContainer.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin - - private val topBarOffsetListener = - AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> - currentAppBarOffset = verticalOffset - lastKnownScrollRange = appBarLayout.totalScrollRange - updateEditorTopInset() - } - - private val appBarLayoutChangeListener = - OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - if (!isLandscape) return@OnLayoutChangeListener - - val newScrollRange = topBar.totalScrollRange - val scrollRangeChanged = newScrollRange != lastKnownScrollRange - - lastKnownScrollRange = newScrollRange - - val isVisuallyOutOfSync = !isTopBarRequestedVisible && currentAppBarOffset > -newScrollRange - - if (scrollRangeChanged || isVisuallyOutOfSync) { - topBar.post(::enforceCollapsedStateIfNeeded) - } - } - - private fun enforceCollapsedStateIfNeeded() { - if (isTopBarRequestedVisible) return - - collapseTopBarWithoutAnimation() - updateEditorTopInset() - } - - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheetView: View, newState: Int) { - when (newState) { - BottomSheetBehavior.STATE_EXPANDED, - BottomSheetBehavior.STATE_HALF_EXPANDED -> onBottomBarExpandedOrHalfExpanded() - - BottomSheetBehavior.STATE_COLLAPSED -> onBottomBarCollapsed() - - BottomSheetBehavior.STATE_DRAGGING, - BottomSheetBehavior.STATE_SETTLING -> Unit - - BottomSheetBehavior.STATE_HIDDEN -> { - isBottomBarShown = false - } - } - } - - override fun onSlide(bottomSheetView: View, slideOffset: Float) = Unit - } - - init { - setupClickListeners() - } - - @SuppressLint("ClickableViewAccessibility") - fun bind() { - if (isBound) return - isBound = true - - topBar.addOnOffsetChangedListener(topBarOffsetListener) - appBarContent.addOnLayoutChangeListener(appBarLayoutChangeListener) - bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) - } - - fun onPause() { - cancelBottomSheetAnimation() - setBottomSheetTranslation( - if (!isBottomBarShown && bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { - bottomSheetBehavior.peekHeight.toFloat() - } else { - 0f - }, - ) - } - - @SuppressLint("ClickableViewAccessibility") - fun destroy() { - onPause() - - if (!isBound) return - isBound = false - - topToggle.setOnClickListener(null) - bottomToggle.setOnClickListener(null) - - topBar.removeOnOffsetChangedListener(topBarOffsetListener) - appBarContent.removeOnLayoutChangeListener(appBarLayoutChangeListener) - bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) - } - - fun onConfigurationChanged(newConfig: Configuration) { - isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE - - appBarContent.updateLayoutParams { - scrollFlags = if (isLandscape) { - AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or - AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS or - AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP - } else { - 0 - } - } - - if (isLandscape) { - enableImmersiveMode() - } else { - disableImmersiveMode() - } - - updateEditorBottomInset() - } - - fun onSystemBarInsetsChanged(topInset: Int) { - statusBarTopInset = topInset - updateEditorTopInset() - } - - private fun setupClickListeners() { - topToggle.setOnClickListener { - if (!isLandscape) return@setOnClickListener - if (isTopBarRequestedVisible) hideTopBar() else showTopBar() - } - - bottomToggle.setOnClickListener { - if (!isLandscape) return@setOnClickListener - if (isBottomBarShown) hideBottomBar() else showBottomBar(expandHalfWay = true) - } - } - - private fun onBottomBarExpandedOrHalfExpanded() { - cancelBottomSheetAnimation() - setBottomSheetTranslation(0f) - isBottomBarShown = true - isBottomBarRequestedVisible = true - isPendingBottomBarHideAfterCollapse = false - updateEditorBottomInset() - } - - private fun onBottomBarCollapsed() { - if (isPendingBottomBarHideAfterCollapse && !isBottomBarRequestedVisible) { - isPendingBottomBarHideAfterCollapse = false - applyHiddenBottomBarTranslation(animate = true) - return - } - - cancelBottomSheetAnimation() - setBottomSheetTranslation(0f) - isBottomBarShown = true - updateEditorBottomInset() - } - - private fun enableImmersiveMode() { - setTogglesVisible(true) - - topBar.post { hideTopBar(animate = false) } - bottomSheet.post { hideBottomBar(animate = false) } - } - - private fun disableImmersiveMode() { - setTogglesVisible(false) - - isTopBarRequestedVisible = true - topBar.setExpanded(true, false) - - isBottomBarRequestedVisible = true - isBottomBarShown = true - isPendingBottomBarHideAfterCollapse = false - - cancelBottomSheetAnimation() - setBottomSheetTranslation(0f) - - updateEditorBottomInset() - } - - private fun setTogglesVisible(visible: Boolean) { - topToggle.isVisible = visible - bottomToggle.isVisible = visible - } - - fun showTopBar(animate: Boolean = true) { - isTopBarRequestedVisible = true - topBar.setExpanded(true, animate) - } - - private fun hideTopBar(animate: Boolean = true) { - isTopBarRequestedVisible = false - topBar.setExpanded(false, animate) - } - - private fun collapseTopBarWithoutAnimation() { - topBar.setExpanded(false, false) - currentAppBarOffset = -topBar.totalScrollRange - } - - private fun showBottomBar(animate: Boolean = true, expandHalfWay: Boolean = false) { - isBottomBarRequestedVisible = true - isBottomBarShown = true - isPendingBottomBarHideAfterCollapse = false - - updateEditorBottomInset() - - if (expandHalfWay) { - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED - } else { ensureBottomSheetCollapsed() } - - animateBottomSheetTranslation(to = 0f, animate = animate) - } - - private fun hideBottomBar(animate: Boolean = true) { - isBottomBarRequestedVisible = false - isBottomBarShown = false - updateEditorBottomInset() - - if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { - applyHiddenBottomBarTranslation(animate) - return - } - - isPendingBottomBarHideAfterCollapse = true - ensureBottomSheetCollapsed() - } - - private fun ensureBottomSheetCollapsed() { - if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { - bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - } - - private fun applyHiddenBottomBarTranslation(animate: Boolean) { - animateBottomSheetTranslation( - to = bottomSheetBehavior.peekHeight.toFloat(), - animate = animate, - ) - } - - private fun animateBottomSheetTranslation(to: Float, animate: Boolean) { - cancelBottomSheetAnimation() - if (animate) { - bottomSheet.animate() - .translationY(to) - .setDuration(BOTTOM_BAR_ANIMATION_DURATION_MS) - .start() - } else { - setBottomSheetTranslation(to) - } - } - - private fun cancelBottomSheetAnimation() { - bottomSheet.animate().cancel() - } - - private fun setBottomSheetTranslation(value: Float) { - bottomSheet.translationY = value - } - - private fun updateEditorBottomInset() { - val bottomMargin = if (isLandscape) { - if (isBottomBarShown) bottomSheetBehavior.peekHeight else 0 - } else { - defaultEditorBottomMargin - } - - editorContainer.updateLayoutParams { - if (bottomMargin != this.bottomMargin) { - this.bottomMargin = bottomMargin - } - } - } - - /** - * Applies the editor top inset progressively as the app bar collapses, - * avoiding a jump at the end of the animation. - */ - private fun updateEditorTopInset() { - val topPadding = when { - !isLandscape || lastKnownScrollRange <= 0 -> 0 - else -> { - val collapseFraction = - (-currentAppBarOffset.toFloat() / lastKnownScrollRange.toFloat()) - .coerceIn(0f, 1f) - (statusBarTopInset * collapseFraction).roundToInt() - } - } - - viewContainer.updatePadding(top = topPadding) - } - - private companion object { - const val BOTTOM_BAR_ANIMATION_DURATION_MS = 200L - } -} diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index b40edf3958..29c97c0cc8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -125,7 +125,6 @@ constructor( companion object { private val log = LoggerFactory.getLogger(EditorBottomSheet::class.java) - private const val COLLAPSE_HEADER_AT_OFFSET = 0.5f const val CHILD_HEADER = 0 const val CHILD_SYMBOL_INPUT = 1 @@ -380,19 +379,13 @@ constructor( } fun onSlide(sheetOffset: Float) { - val heightScale = - if (sheetOffset >= COLLAPSE_HEADER_AT_OFFSET) { - ((COLLAPSE_HEADER_AT_OFFSET - sheetOffset) + COLLAPSE_HEADER_AT_OFFSET) * 2f - } else { - 1f - } + val safeOffset = sheetOffset.coerceIn(0f, 1f) - val paddingScale = - if (!isImeVisible && sheetOffset <= COLLAPSE_HEADER_AT_OFFSET) { - ((1f - sheetOffset) * 2f) - 1f - } else { - 0f - } + val heightScale = 1f - safeOffset + + val paddingScale = if (!isImeVisible) { + 1f - safeOffset + } else 0f val padding = insetBottom * paddingScale binding.headerContainer.apply { diff --git a/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt index ab619a66d7..c6ef657a4c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt @@ -36,15 +36,10 @@ fun View.getOrStoreInitialPadding(): InitialPadding { fun View.applyResponsiveAppBarInsets(appbarContent: View) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - - if (isLandscape) { - view.updatePadding(top = 0) - appbarContent.updatePadding(top = insets.top) - } else { - view.updatePadding(top = insets.top) - appbarContent.updatePadding(top = 0) - } + + view.updatePadding(top = insets.top) + appbarContent.updatePadding(top = 0) + windowInsets } @@ -103,12 +98,7 @@ fun ContentEditorBinding.applyImmersiveModeInsets(systemBars: Insets) { val isRtl = root.layoutDirection == View.LAYOUT_DIRECTION_RTL val endInset = if (isRtl) systemBars.left else systemBars.right - btnToggleTopBar.updateLayoutParams { - topMargin = baseMargin + systemBars.top - marginEnd = baseMargin + endInset - } - - btnToggleBottomBar.updateLayoutParams { + btnFullscreenToggle.updateLayoutParams { bottomMargin = baseMargin + systemBars.bottom marginEnd = baseMargin + endInset } @@ -117,26 +107,8 @@ fun ContentEditorBinding.applyImmersiveModeInsets(systemBars: Insets) { } /** - * Recomputes bottom sheet offsets based on the current app bar height. - */ -fun ContentEditorBinding.refreshBottomSheetAnchor() { - bottomSheet.setOffsetAnchor(editorAppBarLayout) -} - -/** - * Allows the bottom sheet to expand fully (no app bar anchor). - */ -fun ContentEditorBinding.resetBottomSheetAnchor() { - bottomSheet.resetOffsetAnchor() -} - -/** - * Applies the correct bottom sheet anchor based on orientation. + * Allows the bottom sheet to expand fully while relying on top insets to stay clear of system bars. */ fun ContentEditorBinding.applyBottomSheetAnchorForOrientation(orientation: Int) { - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - refreshBottomSheetAnchor() - } else { - resetBottomSheetAnchor() - } + bottomSheet.resetOffsetAnchor() } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index d603772560..73e699a77a 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -45,6 +46,10 @@ import java.util.Collections @Suppress("PropertyName") class EditorViewModel : ViewModel() { + data class UiState( + val isFullscreen: Boolean = false, + ) + internal val _isBuildInProgress = MutableLiveData(false) internal val _isInitializing = MutableLiveData(false) internal val _statusText = MutableLiveData>("" to CENTER) @@ -54,13 +59,17 @@ class EditorViewModel : ViewModel() { internal val _filesModified = MutableLiveData(false) internal val _filesSaving = MutableLiveData(false) + internal val _isFullscreen = MutableLiveData(false) private val _openedFiles = MutableLiveData() private val _isBoundToBuildService = MutableLiveData(false) private val _files = MutableLiveData>(ArrayList()) + private val _uiState = MutableStateFlow(UiState()) private val _searchResults = MutableStateFlow>>(emptyMap()) val searchResults: StateFlow>> = _searchResults.asStateFlow() + val uiState: StateFlow = _uiState.asStateFlow() + fun onSearchResultsReady(results: Map>) { _searchResults.value = results } @@ -137,6 +146,31 @@ class EditorViewModel : ViewModel() { _isSyncNeeded.value = value } + var isFullscreen: Boolean + get() = uiState.value.isFullscreen + set(value) { + updateFullscreen(value) + } + + fun updateFullscreen(value: Boolean) { + _uiState.update { current -> + if (current.isFullscreen == value) { + current + } else { + current.copy(isFullscreen = value) + } + } + _isFullscreen.value = value + } + + fun toggleFullscreen() { + updateFullscreen(!uiState.value.isFullscreen) + } + + fun exitFullscreen() { + updateFullscreen(false) + } + internal var files: MutableList get() = this._files.value ?: Collections.emptyList() set(value) { diff --git a/app/src/main/res/drawable/bg_hollow_green_circle.xml b/app/src/main/res/drawable/bg_hollow_green_circle.xml deleted file mode 100644 index 4afdaace9e..0000000000 --- a/app/src/main/res/drawable/bg_hollow_green_circle.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout-land/content_editor.xml b/app/src/main/res/layout-land/content_editor.xml index 10257cecff..eae0f55f5b 100644 --- a/app/src/main/res/layout-land/content_editor.xml +++ b/app/src/main/res/layout-land/content_editor.xml @@ -112,7 +112,7 @@ android:id="@+id/editor_container" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginBottom="@dimen/editor_sheet_peek_height" /> + android:layout_marginBottom="@dimen/editor_sheet_collapsed_height" /> - - + android:contentDescription="@string/desc_enter_fullscreen" /> diff --git a/app/src/main/res/layout/content_editor.xml b/app/src/main/res/layout/content_editor.xml index 4bf1f01468..e384046db1 100644 --- a/app/src/main/res/layout/content_editor.xml +++ b/app/src/main/res/layout/content_editor.xml @@ -109,7 +109,7 @@ android:id="@+id/editor_container" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginBottom="@dimen/editor_sheet_peek_height" /> + android:layout_marginBottom="@dimen/editor_sheet_collapsed_height" /> - - + android:id="@+id/btn_fullscreen_toggle" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_gravity="bottom|end" + android:layout_marginBottom="16dp" + android:layout_marginEnd="16dp" + android:padding="12dp" + android:scaleType="fitCenter" + android:src="@drawable/ic_fullscreen" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/desc_enter_fullscreen" /> diff --git a/resources/src/main/res/drawable/ic_fullscreen.xml b/resources/src/main/res/drawable/ic_fullscreen.xml new file mode 100644 index 0000000000..d1258a411c --- /dev/null +++ b/resources/src/main/res/drawable/ic_fullscreen.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_fullscreen_exit.xml b/resources/src/main/res/drawable/ic_fullscreen_exit.xml new file mode 100644 index 0000000000..53fcf5243d --- /dev/null +++ b/resources/src/main/res/drawable/ic_fullscreen_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/values/dimens.xml b/resources/src/main/res/values/dimens.xml index cd9dd6e9db..98584cd383 100755 --- a/resources/src/main/res/values/dimens.xml +++ b/resources/src/main/res/values/dimens.xml @@ -1,7 +1,7 @@ 40dp - 56dp + 100dp 4dip 8dip 16dip diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ec118b2699..4dfaa94852 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -859,8 +859,8 @@ Breakpoint Project name Project Options - Toggle top bar - Toggle bottom bar + Enter full screen + Exit full screen Plugin Manager From 23ff5b6f29a7d1b298c510ebdbaa05179367ee98 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 2 Apr 2026 17:23:29 -0500 Subject: [PATCH 2/2] refactor(editor): clean up fullscreen state handling --- .../activities/editor/BaseEditorActivity.kt | 47 ++++-- ...reenController.kt => FullscreenManager.kt} | 155 ++++++++++++++---- 2 files changed, 154 insertions(+), 48 deletions(-) rename app/src/main/java/com/itsaky/androidide/activities/editor/{FullscreenController.kt => FullscreenManager.kt} (54%) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index c76b5027a0..8c36fc09f0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -177,7 +177,7 @@ abstract class BaseEditorActivity : private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null - private var fullscreenController: FullscreenController? = null + private var fullscreenManager: FullscreenManager? = null private val topEdgeThreshold by lazy { SizeUtils.dp2px(TOP_EDGE_SWIPE_THRESHOLD_DP) } var isDestroying = false @@ -455,8 +455,8 @@ abstract class BaseEditorActivity : editorBottomSheet = null gestureDetector = null - fullscreenController?.destroy() - fullscreenController = null + fullscreenManager?.destroy() + fullscreenManager = null _binding = null @@ -629,7 +629,7 @@ abstract class BaseEditorActivity : setupFullscreenObserver() setupViews() - fullscreenController = FullscreenController( + fullscreenManager = FullscreenManager( contentBinding = content, bottomSheetBehavior = editorBottomSheet!!, closeDrawerAction = { @@ -677,13 +677,14 @@ abstract class BaseEditorActivity : window?.decorView?.let { ViewCompat.requestApplyInsets(it) } reapplySystemBarInsetsFromRoot() _binding?.content?.applyBottomSheetAnchorForOrientation(newConfig.orientation) - fullscreenController?.render(editorViewModel.isFullscreen, animate = false) + fullscreenManager?.render(editorViewModel.isFullscreen, animate = false) } private fun reapplySystemBarInsetsFromRoot() { val root = _binding?.root ?: return val rootInsets = ViewCompat.getRootWindowInsets(root) if (rootInsets == null) { + // Insets can be temporarily unavailable right after a configuration change. root.post { reapplySystemBarInsetsFromRoot() } return } @@ -1232,7 +1233,7 @@ abstract class BaseEditorActivity : lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { editorViewModel.uiState.collectLatest { uiState -> - fullscreenController?.render(uiState.isFullscreen, animate = true) + fullscreenManager?.render(uiState.isFullscreen, animate = true) } } } @@ -1473,27 +1474,37 @@ abstract class BaseEditorActivity : val startedNearTopEdge = e1.y < topEdgeThreshold val startedNearBottomEdge = e1.y > bottomEdgeThreshold - - if (isVerticalSwipe && hasVerticalVelocity && startedNearTopEdge && hasDownFlingDistance) { - if (editorViewModel.isFullscreen) { - editorViewModel.exitFullscreen() - return true - } + val isTopEdgeDismissFling = isVerticalSwipe && + hasVerticalVelocity && + startedNearTopEdge && + hasDownFlingDistance + val isBottomEdgeDismissFling = isVerticalSwipe && + hasVerticalVelocity && + startedNearBottomEdge && + hasUpFlingDistance + val isDrawerOpenFling = hasRightFlingDistance && + hasHorizontalVelocity && + isHorizontalSwipe + + // Fullscreen mode can be dismissed with an inward fling from either vertical edge. + if (isTopEdgeDismissFling && editorViewModel.isFullscreen) { + editorViewModel.exitFullscreen() + return true } - if (isVerticalSwipe && hasVerticalVelocity && startedNearBottomEdge && hasUpFlingDistance) { - if (editorViewModel.isFullscreen) { - editorViewModel.exitFullscreen() - return true - } + if (isBottomEdgeDismissFling && editorViewModel.isFullscreen) { + editorViewModel.exitFullscreen() + return true } + // Preserve the editor interaction area; drawer gestures are only enabled on the empty state. val noFilesOpen = content.viewContainer.displayedChild == 1 if (!noFilesOpen) { return false } - if (hasRightFlingDistance && hasHorizontalVelocity && isHorizontalSwipe) { + // Filter out diagonal flings so only an intentional right swipe opens the drawer. + if (isDrawerOpenFling) { binding.editorDrawerLayout.openDrawer(GravityCompat.START) return true } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt similarity index 54% rename from app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt rename to app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt index 94e1ba373f..68758c08ee 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenController.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt @@ -9,12 +9,91 @@ import com.itsaky.androidide.R import com.itsaky.androidide.databinding.ContentEditorBinding import kotlin.math.abs -class FullscreenController( +class FullscreenManager( private val contentBinding: ContentEditorBinding, private val bottomSheetBehavior: BottomSheetBehavior, private val closeDrawerAction: () -> Unit, private val onFullscreenToggleRequested: () -> Unit, ) { + private sealed interface FullscreenUiState { + val isFullscreen: Boolean + + data object Fullscreen : FullscreenUiState { + override val isFullscreen = true + } + + data object Windowed : FullscreenUiState { + override val isFullscreen = false + } + + companion object { + fun from(isFullscreen: Boolean): FullscreenUiState { + return if (isFullscreen) Fullscreen else Windowed + } + } + } + + private sealed interface FullscreenRenderCommand { + val targetState: FullscreenUiState + val animate: Boolean + + fun apply(manager: FullscreenManager) + + data class EnterFullscreen( + override val animate: Boolean, + ) : FullscreenRenderCommand { + override val targetState = FullscreenUiState.Fullscreen + + override fun apply(manager: FullscreenManager) { + manager.applyFullscreen(animate) + } + } + + data class ExitFullscreen( + override val animate: Boolean, + ) : FullscreenRenderCommand { + override val targetState = FullscreenUiState.Windowed + + override fun apply(manager: FullscreenManager) { + manager.applyNonFullscreen(animate) + } + } + + data class Refresh( + override val targetState: FullscreenUiState, + ) : FullscreenRenderCommand { + override val animate = false + + override fun apply(manager: FullscreenManager) { + if (targetState.isFullscreen) { + manager.applyFullscreen(animate = false) + } else { + manager.applyNonFullscreen(animate = false) + } + } + } + + companion object { + fun resolve( + currentState: FullscreenUiState, + targetState: FullscreenUiState, + animate: Boolean, + ): FullscreenRenderCommand { + val shouldAnimate = animate && currentState != targetState + + if (!shouldAnimate) { + return Refresh(targetState) + } + + return if (targetState.isFullscreen) { + EnterFullscreen(animate = true) + } else { + ExitFullscreen(animate = true) + } + } + } + } + private val topBar = contentBinding.editorAppBarLayout private val appBarContent = contentBinding.editorAppbarContent private val editorContainer = contentBinding.editorContainer @@ -22,8 +101,10 @@ class FullscreenController( private var isBound = false private var isTransitioning = false - private var currentFullscreen = false + private var currentState: FullscreenUiState = FullscreenUiState.Windowed private var defaultSkipCollapsed = false + private var transitionToken = 0L + private var pendingTransitionToken = 0L private val transitionDurationMs = 350L @@ -68,28 +149,22 @@ class FullscreenController( bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) bottomSheetBehavior.skipCollapsed = defaultSkipCollapsed fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + isTransitioning = false } fun render(isFullscreen: Boolean, animate: Boolean) { - val stateChanged = currentFullscreen != isFullscreen - val shouldAnimate = animate && stateChanged - currentFullscreen = isFullscreen - isTransitioning = shouldAnimate - - if (isFullscreen) { - applyFullscreen(shouldAnimate) - } else { - applyNonFullscreen(shouldAnimate) - } - - syncToggleUi(isFullscreen) + val targetState = FullscreenUiState.from(isFullscreen) + val command = + FullscreenRenderCommand.resolve( + currentState = currentState, + targetState = targetState, + animate = animate, + ) - if (shouldAnimate) { - fullscreenToggle.removeCallbacks(clearTransitioningRunnable) - fullscreenToggle.postDelayed(clearTransitioningRunnable, transitionDurationMs) - } else { - isTransitioning = false - } + currentState = command.targetState + syncTransitionState(command) + command.apply(this) + syncToggleUi(command.targetState) } private fun setupScrollFlags() { @@ -101,17 +176,35 @@ class FullscreenController( } private fun handleBottomSheetStateChange(newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED && !currentFullscreen) { + val isCollapsedInWindowedMode = + newState == BottomSheetBehavior.STATE_COLLAPSED && !currentState.isFullscreen + val isSheetRevealedWhileFullscreen = + (newState == BottomSheetBehavior.STATE_EXPANDED || + newState == BottomSheetBehavior.STATE_HALF_EXPANDED) && + currentState.isFullscreen && + !isTransitioning + + if (isCollapsedInWindowedMode) { bottomSheetBehavior.isHideable = false } - if (newState == BottomSheetBehavior.STATE_EXPANDED || - newState == BottomSheetBehavior.STATE_HALF_EXPANDED - ) { - if (currentFullscreen && !isTransitioning) { - onFullscreenToggleRequested() - } + if (isSheetRevealedWhileFullscreen) { + onFullscreenToggleRequested() + } + } + + private fun syncTransitionState(command: FullscreenRenderCommand) { + fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + + if (!command.animate) { + isTransitioning = false + transitionToken++ + return } + + isTransitioning = true + pendingTransitionToken = ++transitionToken + fullscreenToggle.postDelayed(clearTransitioningRunnable, transitionDurationMs) } private fun applyFullscreen(animate: Boolean) { @@ -145,8 +238,8 @@ class FullscreenController( } } - private fun syncToggleUi(isFullscreen: Boolean) { - if (isFullscreen) { + private fun syncToggleUi(state: FullscreenUiState) { + if (state.isFullscreen) { fullscreenToggle.setImageResource(R.drawable.ic_fullscreen_exit) fullscreenToggle.contentDescription = contentBinding.root.context.getString(R.string.desc_exit_fullscreen) @@ -158,6 +251,8 @@ class FullscreenController( } private val clearTransitioningRunnable = Runnable { - isTransitioning = false + if (pendingTransitionToken == transitionToken) { + isTransitioning = false + } } }