From ec2490d2c95111f6fae33751eb359bbf828fce00 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:09:59 +0200 Subject: [PATCH 1/2] feat(timer): native rotation for settings, lock portrait only in timer The Activity was locked to portrait globally so the custom in-app rotation on TimerScreen would always work. This meant Settings / About / ThemePicker were stuck in portrait too. Flip the default: let the Activity follow the sensor, and lock to portrait only while TimerContent is composed (DisposableEffect restores the previous requestedOrientation on dispose). Add configChanges so the orientation-lock flip during navigation doesn't recreate the Activity. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/main/AndroidManifest.xml | 2 +- .../feature/timer/timer/TimerScreen.kt | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a6fdc3..3ea4e3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt index d9c5b98..4fff8cd 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt @@ -1,6 +1,10 @@ package dev.xitee.sleeptimer.feature.timer.timer import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult @@ -38,6 +42,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -93,6 +98,8 @@ private fun TimerContent( val dialState = rememberCircularDialState() val context = LocalContext.current + LockActivityOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + val orientation by rememberDeviceOrientation() val isLandscape = orientation == DeviceOrientation.LANDSCAPE_LEFT || orientation == DeviceOrientation.LANDSCAPE_RIGHT @@ -330,6 +337,30 @@ private fun TimerContent( } } +@Composable +private fun LockActivityOrientation(orientation: Int) { + val context = LocalContext.current + DisposableEffect(context, orientation) { + val activity = context.findActivity() + if (activity == null) { + onDispose { } + } else { + val previous = activity.requestedOrientation + activity.requestedOrientation = orientation + onDispose { activity.requestedOrientation = previous } + } + } +} + +private fun Context.findActivity(): Activity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} + @Composable private fun animatedRotationAngle(orientation: DeviceOrientation): Float { // Shortest-path guard: when the bucket jumps, pick the equivalent target (±360°) From 5c5edda95ccb96ec6c3d11c70c24d4476f332f53 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:33:51 +0200 Subject: [PATCH 2/2] refactor(timer): centralize orientation control, skip tilt transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-screen DisposableEffect approach left a disposal gap when releasing the portrait lock: Android's sensor-based rotation had to re-sample and debounce, causing a 1–2s delay before Settings rotated. Move the controller up to SleepTimerNavHost as a single always-on composable driven by DeviceOrientation. When the lock releases, the current pose is already known and mapped to an explicit requestedOrientation (LANDSCAPE / REVERSE_*), so the Activity rotates immediately without Android's debounce. Also skip the NavHost fade when the device is tilted: combined with the orientation flip, the fade briefly shows Timer's counter-rotated content being rotated by the window manager, appearing upside-down. In natural portrait there is no flip, so the fade is kept. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../navigation/SleepTimerNavHost.kt | 25 +++++++++++++ .../timer/timer/AppOrientationController.kt | 37 +++++++++++++++++++ .../feature/timer/timer/DeviceOrientation.kt | 8 ++++ .../feature/timer/timer/TimerScreen.kt | 31 ---------------- 4 files changed, 70 insertions(+), 31 deletions(-) create mode 100644 feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/AppOrientationController.kt diff --git a/app/src/main/kotlin/dev/xitee/sleeptimer/navigation/SleepTimerNavHost.kt b/app/src/main/kotlin/dev/xitee/sleeptimer/navigation/SleepTimerNavHost.kt index 83d2158..1766f21 100644 --- a/app/src/main/kotlin/dev/xitee/sleeptimer/navigation/SleepTimerNavHost.kt +++ b/app/src/main/kotlin/dev/xitee/sleeptimer/navigation/SleepTimerNavHost.kt @@ -1,21 +1,46 @@ package dev.xitee.sleeptimer.navigation +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.NavDestination.Companion.hasRoute import dev.xitee.sleeptimer.feature.timer.about.AboutScreen import dev.xitee.sleeptimer.feature.timer.settings.SettingsScreen import dev.xitee.sleeptimer.feature.timer.settings.ThemePickerScreen +import dev.xitee.sleeptimer.feature.timer.timer.AppOrientationController +import dev.xitee.sleeptimer.feature.timer.timer.DeviceOrientation import dev.xitee.sleeptimer.feature.timer.timer.TimerScreen +import dev.xitee.sleeptimer.feature.timer.timer.rememberDeviceOrientation @Composable fun SleepTimerNavHost() { val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val isTimer = backStackEntry?.destination?.hasRoute() ?: true + // Skip the cross-screen fade when the device is tilted: the orientation + // flip that accompanies Timer ↔ Settings navigation combined with a fade + // briefly shows Timer's counter-rotated content being rotated by the + // window manager, appearing upside-down. In natural portrait there is no + // orientation flip, so the fade is kept. + val deviceOrientation by rememberDeviceOrientation() + val animate = deviceOrientation == DeviceOrientation.PORTRAIT + + AppOrientationController(orientation = deviceOrientation, lockPortrait = isTimer) NavHost( navController = navController, startDestination = TimerRoute, + enterTransition = { if (animate) fadeIn() else EnterTransition.None }, + exitTransition = { if (animate) fadeOut() else ExitTransition.None }, + popEnterTransition = { if (animate) fadeIn() else EnterTransition.None }, + popExitTransition = { if (animate) fadeOut() else ExitTransition.None }, ) { composable { TimerScreen( diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/AppOrientationController.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/AppOrientationController.kt new file mode 100644 index 0000000..77a2b9e --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/AppOrientationController.kt @@ -0,0 +1,37 @@ +package dev.xitee.sleeptimer.feature.timer.timer + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext + +// Owns the Activity's requestedOrientation for the whole session. Driven by the +// caller's already-tracked DeviceOrientation so there is no disposal gap when the +// lock is released — the pose is known at all times, avoiding Android's own sensor +// debounce on the handoff. +@Composable +fun AppOrientationController(orientation: DeviceOrientation, lockPortrait: Boolean) { + val context = LocalContext.current + + LaunchedEffect(lockPortrait, orientation) { + val activity = context.findActivity() ?: return@LaunchedEffect + activity.requestedOrientation = if (lockPortrait) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + orientation.toActivityInfoOrientation() + } + } +} + +private fun Context.findActivity(): Activity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/DeviceOrientation.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/DeviceOrientation.kt index ed995eb..ddd6b12 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/DeviceOrientation.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/DeviceOrientation.kt @@ -1,5 +1,6 @@ package dev.xitee.sleeptimer.feature.timer.timer +import android.content.pm.ActivityInfo import android.view.OrientationEventListener import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -22,6 +23,13 @@ fun DeviceOrientation.counterRotationDegrees(): Float = when (this) { DeviceOrientation.LANDSCAPE_RIGHT -> 90f } +fun DeviceOrientation.toActivityInfoOrientation(): Int = when (this) { + DeviceOrientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + DeviceOrientation.LANDSCAPE_LEFT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + DeviceOrientation.PORTRAIT_REVERSED -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + DeviceOrientation.LANDSCAPE_RIGHT -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE +} + @Composable fun rememberDeviceOrientation(): State { val context = LocalContext.current diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt index 4fff8cd..d9c5b98 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt @@ -1,10 +1,6 @@ package dev.xitee.sleeptimer.feature.timer.timer import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult @@ -42,7 +38,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -98,8 +93,6 @@ private fun TimerContent( val dialState = rememberCircularDialState() val context = LocalContext.current - LockActivityOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) - val orientation by rememberDeviceOrientation() val isLandscape = orientation == DeviceOrientation.LANDSCAPE_LEFT || orientation == DeviceOrientation.LANDSCAPE_RIGHT @@ -337,30 +330,6 @@ private fun TimerContent( } } -@Composable -private fun LockActivityOrientation(orientation: Int) { - val context = LocalContext.current - DisposableEffect(context, orientation) { - val activity = context.findActivity() - if (activity == null) { - onDispose { } - } else { - val previous = activity.requestedOrientation - activity.requestedOrientation = orientation - onDispose { activity.requestedOrientation = previous } - } - } -} - -private fun Context.findActivity(): Activity? { - var ctx: Context? = this - while (ctx is ContextWrapper) { - if (ctx is Activity) return ctx - ctx = ctx.baseContext - } - return null -} - @Composable private fun animatedRotationAngle(orientation: DeviceOrientation): Float { // Shortest-path guard: when the bucket jumps, pick the equivalent target (±360°)