diff --git a/.claude/agent-memory/ui-ux-reviewer/MEMORY.md b/.claude/agent-memory/ui-ux-reviewer/MEMORY.md index e65c2e7..23071c2 100644 --- a/.claude/agent-memory/ui-ux-reviewer/MEMORY.md +++ b/.claude/agent-memory/ui-ux-reviewer/MEMORY.md @@ -17,3 +17,4 @@ See `patterns.md` for detailed design conventions. - MainScreen uses `@Immutable` data class for ProfileSheetState (good practice) - Drag gesture threshold: 60f px for swipe-open/close channel list - LazyListState for channel list is owned by ViewModel (correct — survives recomposition) +- IME insets: enableEdgeToEdge() is active; Scaffold default contentWindowInsets excludes IME; MessageTimeline Column must carry `.imePadding()` before static padding — without it the keyboard overlays the input bar. AndroidView EditText does not auto-participate in Compose IME avoidance. Never use windowSoftInputMode=adjustResize with edge-to-edge. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e6d7559..c274187 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,8 @@ + android:launchMode="singleTop" + android:windowSoftInputMode="adjustNothing"> diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 8dbb167..c716276 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -44,6 +45,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path @@ -289,7 +292,8 @@ fun MessageTimeline( .padding( top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding(), - ), + ) + .imePadding(), ) { if (selectedChannel != null) { if (selectedThread != null) { @@ -1372,18 +1376,78 @@ private fun MessageInput( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - TextField( - value = text, - onValueChange = { text = it }, - placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp), - maxLines = 8, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - ), + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant + val onSurface = MaterialTheme.colorScheme.onSurface + val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant + val textStyle = MaterialTheme.typography.bodyLarge + val density = LocalDensity.current + AndroidView( + factory = { ctx -> + object : android.widget.EditText(ctx) { + override fun requestRectangleOnScreen(rect: android.graphics.Rect?, immediate: Boolean): Boolean { + // Disable system scroll-into-view; Compose imePadding() handles it + return false + } + }.apply { + hint = "Message #$channelName" + setHintTextColor(onSurfaceVariant.toArgb()) + setTextColor(onSurface.toArgb()) + setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, textStyle.fontSize.value) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(surfaceVariant.toArgb()) + cornerRadius = with(density) { 8.dp.toPx() } + } + setPadding( + with(density) { 16.dp.toPx().toInt() }, + with(density) { 12.dp.toPx().toInt() }, + with(density) { 16.dp.toPx().toInt() }, + with(density) { 12.dp.toPx().toInt() }, + ) + maxLines = 8 + inputType = android.text.InputType.TYPE_CLASS_TEXT or + android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or + android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + isSingleLine = false + + // Prevent EditText from fighting with Compose's imePadding() + imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI + + addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: android.text.Editable?) { + text = s?.toString() ?: "" + } + }) + + androidx.core.view.ViewCompat.setOnReceiveContentListener( + this, + arrayOf("image/*"), + ) { _, payload -> + val clip = payload.clip + var remaining = payload + for (i in 0 until clip.itemCount) { + val uri = clip.getItemAt(i).uri + if (uri != null) { + attachedUris = attachedUris + uri + } + } + // Return null to indicate all content was consumed + if (clip.itemCount > 0 && (0 until clip.itemCount).any { clip.getItemAt(it).uri != null }) null + else remaining + } + } + }, + update = { editText -> + if (editText.text.toString() != text) { + editText.setText(text) + editText.setSelection(text.length) + } + editText.hint = "Message #$channelName" + }, + modifier = Modifier + .weight(1f) + .heightIn(max = 160.dp), ) if (emojiPacks.isNotEmpty()) { IconButton(onClick = { showEmojiPackPicker = true }) {