Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- [Navigation 3 migration](project_navigation3_migration.md) — app fully on Nav3 (navigation3-ui 1.1.x stable, EntryProviderScope not EntryProviderBuilder, polymorphic NavKey registration required on iOS/web, browser history pending CMP-8924).
- [Ktlint custom enforcement preference](feedback_ktlint_custom_enforcement.md) — user prefers repo-wide rename + real enforcement (custom ktlint rule module) over docs-only convention changes.
- [Dialog sheet insets](project_dialog_sheet_insets.md) — nav-bar insets are 0 inside nested Dialogs on Android; ResponsiveDialogSheet uses LocalNavigationBarInsets (provided in App root) + hardcoded skipPartiallyExpanded=true.
- [Dialog blur lifecycle guard](project_dialog_blur_lifecycle_guard.md) — iOS devices leak CMP dialog compositions (camera→push crop), so WindowContentBlurEffect enforces the radius via the nav entry Lifecycle, not just onDispose.
- [Spontaneous logout 2.0.0](project_spontaneous_logout_2_0_0.md) — logout is client-local (orphaned valid sessions server-side); supabase-kt clears session on 4xx refresh / signOut 401-403-404 / session_not_found; endSession-by-ObserveCurrentDeviceRevoked is the uninstrumented suspect; anon push-loop + malformed-token 403s are collateral.
- [More → Profile rename](project_more_to_profile_rename.md) — the "More" tab became "Profile" (module/route/GA4 events/RC keys); old Firebase RC keys kept as deprecated for shipped app versions.
- [Root nav ViewModel home](project_root_nav_viewmodel_home.md) — core/navigation is downstream of provider/koin so it can't own a Koin module; root-level ViewModels (e.g. day/study split-pane ratio) go in a feature module already in the DI graph (feature/day_study).
Expand Down
13 changes: 13 additions & 0 deletions .claude/memory/project_dialog_blur_lifecycle_guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
name: dialog-blur-lifecycle-guard
description: "iOS device leaks CMP dialog compositions on camera→push-crop; global blur state must be lifecycle-driven, not onDispose-only"
metadata:
node_type: memory
type: project
originSessionId: ab3d07fc-3097-497b-b762-51450943f1b0
modified: 2026-08-11T19:14:27.980Z
---

On real iOS devices (not reproducible in the simulator, even with a fullscreen-modal + push-on-dismissal-completion harness), pushing a route over a Nav3 dialog-scene right after a camera dismissal can leak the CMP dialog's composition: `onDispose` inside the dialog never runs. Any app-global state set from inside a dialog composition (e.g. `WindowBlurController.radius` via `WindowContentBlurEffect`) then sticks forever — the crop screen stayed blurred.

Fix pattern (2026-08-11, branch fix/camera-crop-blur-stuck): `WindowContentBlurEffect` also observes the nav entry's `Lifecycle` (owned by the main composition via nav3 scene/decorators) with a `LifecycleEventObserver` — ON_STOP/ON_DESTROY force radius 0, ON_START restores it. iOS fires ON_STOP when a fullscreen modal (camera) covers the app and ON_START on dismissal, so blur also self-heals across backgrounding. Same commit restored camera-launch feedback (CameraLaunchOverlay scrim+spinner in ProfilePhotoPickers, FileKit onError overload resets it).
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberCameraPickerLauncher

@Composable
actual fun rememberCameraPicker(onResult: (PlatformFile?) -> Unit): () -> Unit {
val launcher = rememberCameraPickerLauncher(onResult = onResult)
val launcher = rememberCameraPickerLauncher(
onError = { onResult(null) },
onResult = onResult,
)
return { launcher.launch(cameraFacing = FileKitCameraFacing.Front) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,6 @@ fun EntryProviderScope<NavKey>.editPhotoSource(
) {
val viewModel = koinViewModel<ProfilePhotoViewModel>()
val uiState by viewModel.uiState.collectAsState()
ProfilePhotoPickers(
viewModel = viewModel,
onOpenCrop = onNavigateReplacingTop,
onPhotoChanged = onNavigateBack,
)
ResponsiveDialogSheet(
onCloseClick = onNavigateBack,
title = stringResource(Res.string.edit_profile_photo_title),
Expand All @@ -42,5 +37,10 @@ fun EntryProviderScope<NavKey>.editPhotoSource(
onEvent = viewModel::onEvent,
)
}
ProfilePhotoPickers(
viewModel = viewModel,
onOpenCrop = onNavigateReplacingTop,
onPhotoChanged = onNavigateBack,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ fun EntryProviderScope<NavKey>.expandedPhoto(
) {
val viewModel = koinViewModel<ProfilePhotoViewModel>()
val uiState by viewModel.uiState.collectAsState()
ProfilePhotoPickers(
viewModel = viewModel,
onOpenCrop = onNavigate,
onPhotoChanged = {},
)
ExpandedPhotoOverlay(
profile = uiState.profile.valueOrNull(),
isCameraAvailable = uiState.isCameraAvailable,
onDismiss = onNavigateBack,
onEvent = viewModel::onEvent,
)
ProfilePhotoPickers(
viewModel = viewModel,
onOpenCrop = onNavigate,
onPhotoChanged = {},
)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.quare.bibleplanner.feature.editprofile.presentation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavKey
import com.quare.bibleplanner.feature.editprofile.presentation.content.CameraLaunchOverlay
import com.quare.bibleplanner.feature.editprofile.presentation.content.rememberCameraPicker
import com.quare.bibleplanner.feature.editprofile.presentation.model.ProfilePhotoUiEvent
import com.quare.bibleplanner.feature.editprofile.presentation.utils.ProfilePhotoUiActionCollector
Expand All @@ -16,7 +21,9 @@ internal fun ProfilePhotoPickers(
onOpenCrop: (NavKey) -> Unit,
onPhotoChanged: () -> Unit,
) {
var isLaunchingCamera by remember { mutableStateOf(false) }
val onFilePicked: (PlatformFile?) -> Unit = { file ->
isLaunchingCamera = false
viewModel.onEvent(ProfilePhotoUiEvent.OnImagePicked(file))
}
val galleryLauncher = rememberFilePickerLauncher(
Expand All @@ -30,6 +37,12 @@ internal fun ProfilePhotoPickers(
onOpenCrop = onOpenCrop,
onPhotoChanged = onPhotoChanged,
onLaunchGalleryPicker = galleryLauncher::launch,
onLaunchCameraPicker = launchCamera,
onLaunchCameraPicker = {
isLaunchingCamera = true
launchCamera()
},
)
if (isLaunchingCamera) {
CameraLaunchOverlay()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.quare.bibleplanner.feature.editprofile.presentation.content

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color

private val scrimColor = Color.Black.copy(alpha = 0.35f)

@Composable
internal fun CameraLaunchOverlay(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(scrimColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = Color.White)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,67 @@
package com.quare.bibleplanner.feature.editprofile.presentation.content

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.dialogs.FileKitCameraFacing
import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings
import io.github.vinceglb.filekit.dialogs.compose.rememberCameraPickerLauncher
import kotlinx.cinterop.ExperimentalForeignApi
import platform.UIKit.UIApplication
import platform.UIKit.UIColor
import platform.UIKit.UIScreen
import platform.UIKit.UIViewController
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowLevelAlert
import platform.UIKit.UIWindowScene

@Composable
actual fun rememberCameraPicker(onResult: (PlatformFile?) -> Unit): () -> Unit {
val launcher = rememberCameraPickerLauncher(onResult = onResult)
return { launcher.launch(cameraFacing = FileKitCameraFacing.Front) }
val presenterWindow = remember { CameraPresenterWindow() }
val launcher = rememberCameraPickerLauncher(
openCameraSettings = FileKitOpenCameraSettings(presenter = presenterWindow.hostViewController),
onError = {
presenterWindow.detach()
onResult(null)
},
onResult = { file ->
presenterWindow.detach()
onResult(file)
},
)
return {
presenterWindow.attach()
launcher.launch(cameraFacing = FileKitCameraFacing.Front)
}
}

private class CameraPresenterWindow {
val hostViewController = UIViewController()
private var window: UIWindow? = null
private var previousKeyWindow: UIWindow? = null

@OptIn(ExperimentalForeignApi::class)
fun attach() {
val application = UIApplication.sharedApplication
previousKeyWindow = application.keyWindow
val scene = application.connectedScenes.firstNotNullOfOrNull { it as? UIWindowScene }
val newWindow = if (scene != null) {
UIWindow(windowScene = scene)
} else {
UIWindow(frame = UIScreen.mainScreen.bounds)
}
newWindow.rootViewController = hostViewController
newWindow.windowLevel = UIWindowLevelAlert + 1.0
newWindow.backgroundColor = UIColor.clearColor
newWindow.makeKeyAndVisible()
window = newWindow
}

fun detach() {
window?.setHidden(true)
window?.rootViewController = null
window = null
previousKeyWindow?.makeKeyAndVisible()
previousKeyWindow = null
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"2.5.0": [
"You can now delete your account from within the app, in Profile › Account and data. Before you confirm, the app shows everything that will be erased and reminds you to cancel your subscription in the store where you subscribed.",
"The delete all progress screen is clearer now: it shows what gets removed and what stays with you, such as your account, your subscription and your preferences."
"The delete all progress screen is clearer now: it shows what gets removed and what stays with you, such as your account, your subscription and your preferences.",
"Fixed problems when taking your profile photo with the camera on iPhone: the adjustment screen no longer appears blurred and taps keep working after you close the editing windows.",
"A loading indicator now appears while the camera opens."
],
"2.4.0": [
"Fixed a problem that could show an error on the Bible versions screen even with a working internet connection.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"2.5.0": [
"Ahora puedes eliminar tu cuenta desde la propia app, en Perfil › Cuenta y datos. Antes de confirmar, la app muestra todo lo que se borrará y te recuerda cancelar la suscripción en la tienda donde te suscribiste.",
"La pantalla para eliminar todo el progreso es más clara: ahora muestra qué se elimina y qué se mantiene, como tu cuenta, tu suscripción y tus preferencias."
"La pantalla para eliminar todo el progreso es más clara: ahora muestra qué se elimina y qué se mantiene, como tu cuenta, tu suscripción y tus preferencias.",
"Se corrigieron problemas al tomar la foto de perfil con la cámara en iPhone: la pantalla de ajuste ya no se ve borrosa y los toques siguen funcionando después de cerrar las ventanas de edición.",
"Ahora aparece un indicador de carga mientras la cámara se abre."
],
"2.4.0": [
"Se corrigió un problema que podía mostrar un error en la pantalla de versiones de la Biblia incluso con internet funcionando.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"2.5.0": [
"Agora você pode excluir sua conta pelo próprio app, em Perfil › Conta e dados. Antes de confirmar, o app mostra tudo o que será apagado e lembra que a assinatura precisa ser cancelada na loja onde você assinou.",
"A tela de apagar todo o progresso ficou mais clara: agora ela mostra o que é removido e o que continua com você, como sua conta, sua assinatura e suas preferências."
"A tela de apagar todo o progresso ficou mais clara: agora ela mostra o que é removido e o que continua com você, como sua conta, sua assinatura e suas preferências.",
"Corrigidos problemas ao tirar a foto de perfil com a câmera no iPhone: a tela de ajuste não fica mais desfocada e os toques continuam funcionando depois de fechar as janelas de edição.",
"Um indicador de carregamento agora aparece enquanto a câmera abre."
],
"2.4.0": [
"Corrigido um problema que podia mostrar um erro na tela de versões da Bíblia mesmo com internet funcionando.",
Expand Down
3 changes: 2 additions & 1 deletion ui/utils/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ kotlin {
// Analytics
api(projects.core.provider.analytics)

// View Model
// Lifecycle
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.lifecycle.viewmodelCompose)

// DateTime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner

@Composable
fun WindowContentBlurEffect(radius: Dp) {
val controller = LocalWindowBlurController.current
DisposableEffect(controller, radius) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(controller, radius, lifecycle) {
controller.radius = radius
onDispose { controller.radius = 0.dp }
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> controller.radius = radius

Lifecycle.Event.ON_STOP,
Lifecycle.Event.ON_DESTROY,
-> controller.radius = 0.dp

else -> Unit
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
controller.radius = 0.dp
}
}
}
Loading