Fix editor crash
This commit is contained in:
+10
-6
@@ -51,6 +51,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -449,20 +450,23 @@ fun EditProfileContentScreen(
|
|||||||
setBackgroundColor(
|
setBackgroundColor(
|
||||||
androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent),
|
androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent),
|
||||||
)
|
)
|
||||||
// Set up the editor with read-only state - this handles all configuration
|
viewModel.attachEditor(this)
|
||||||
viewModel.setEditor(this, uiState.isReadOnly)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { textProcessor ->
|
|
||||||
// Re-apply configuration when read-only state changes
|
|
||||||
viewModel.setEditor(textProcessor, uiState.isReadOnly)
|
|
||||||
},
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.isReadOnly) {
|
||||||
|
viewModel.setReadOnly(uiState.isReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { viewModel.detachEditor() }
|
||||||
|
}
|
||||||
|
|
||||||
// Simple loading indicator at the top
|
// Simple loading indicator at the top
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
|
|||||||
+94
-92
@@ -1,5 +1,6 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.profile
|
package io.nekohasekai.sfa.compose.screen.profile
|
||||||
|
|
||||||
|
import android.text.TextWatcher
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
@@ -21,7 +22,6 @@ import java.io.File
|
|||||||
|
|
||||||
data class EditProfileContentUiState(
|
data class EditProfileContentUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val content: String = "",
|
|
||||||
val originalContent: String = "",
|
val originalContent: String = "",
|
||||||
val hasUnsavedChanges: Boolean = false,
|
val hasUnsavedChanges: Boolean = false,
|
||||||
val canUndo: Boolean = false,
|
val canUndo: Boolean = false,
|
||||||
@@ -49,107 +49,103 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
|
|||||||
|
|
||||||
private var profile: Profile? = null
|
private var profile: Profile? = null
|
||||||
private var editor: ManualScrollTextProcessor? = null
|
private var editor: ManualScrollTextProcessor? = null
|
||||||
|
private var textWatcher: TextWatcher? = null
|
||||||
private var configCheckJob: Job? = null
|
private var configCheckJob: Job? = null
|
||||||
|
|
||||||
fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) {
|
private val readOnlyKeyListener = android.view.View.OnKeyListener { _, _, _ -> true }
|
||||||
val isNewEditor = editor != textProcessor
|
|
||||||
|
private val readOnlySelectionCallback =
|
||||||
|
object : android.view.ActionMode.Callback {
|
||||||
|
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean = true
|
||||||
|
|
||||||
|
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
||||||
|
menu?.let { m ->
|
||||||
|
m.removeItem(android.R.id.cut)
|
||||||
|
m.removeItem(android.R.id.paste)
|
||||||
|
m.removeItem(android.R.id.pasteAsPlainText)
|
||||||
|
m.removeItem(android.R.id.replaceText)
|
||||||
|
m.removeItem(android.R.id.undo)
|
||||||
|
m.removeItem(android.R.id.redo)
|
||||||
|
m.removeItem(android.R.id.autofill)
|
||||||
|
m.removeItem(android.R.id.textAssist)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean = false
|
||||||
|
|
||||||
|
override fun onDestroyActionMode(mode: android.view.ActionMode?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachEditor(textProcessor: ManualScrollTextProcessor) {
|
||||||
editor = textProcessor
|
editor = textProcessor
|
||||||
textProcessor.resumeAutoScroll()
|
textProcessor.resumeAutoScroll()
|
||||||
|
|
||||||
// Always keep these for scrolling, focus, and selection
|
|
||||||
textProcessor.isEnabled = true
|
textProcessor.isEnabled = true
|
||||||
textProcessor.isFocusable = true
|
textProcessor.isFocusable = true
|
||||||
textProcessor.isFocusableInTouchMode = true
|
textProcessor.isFocusableInTouchMode = true
|
||||||
|
|
||||||
// Allow text selection for copying
|
|
||||||
textProcessor.setTextIsSelectable(true)
|
textProcessor.setTextIsSelectable(true)
|
||||||
|
|
||||||
// Multi-line configuration
|
|
||||||
textProcessor.setSingleLine(false)
|
textProcessor.setSingleLine(false)
|
||||||
textProcessor.maxLines = Integer.MAX_VALUE
|
textProcessor.maxLines = Integer.MAX_VALUE
|
||||||
textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or
|
textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or
|
||||||
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
||||||
android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
textProcessor.isCursorVisible = true
|
textProcessor.isCursorVisible = true
|
||||||
|
textProcessor.isLongClickable = true
|
||||||
|
|
||||||
if (isReadOnly) {
|
textWatcher = textProcessor.addTextChangedListener { editable ->
|
||||||
// Use a custom OnKeyListener that blocks all key input
|
val length = editable?.length ?: 0
|
||||||
textProcessor.setOnKeyListener { _, _, _ -> true } // Return true to consume all key events
|
val original = _uiState.value.originalContent
|
||||||
// Enable long click for selection
|
val hasUnsavedChanges = when {
|
||||||
textProcessor.isLongClickable = true
|
length != original.length -> true
|
||||||
|
editable == null -> original.isNotEmpty()
|
||||||
// Customize text selection to remove Cut and Paste options
|
else -> editable.toString() != original
|
||||||
textProcessor.customSelectionActionModeCallback =
|
|
||||||
object : android.view.ActionMode.Callback {
|
|
||||||
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
||||||
// Allow the action mode to be created
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
|
||||||
// Remove editing-related menu items, keep only Copy and Select All
|
|
||||||
menu?.let { m ->
|
|
||||||
// Remove all editing-related items
|
|
||||||
m.removeItem(android.R.id.cut)
|
|
||||||
m.removeItem(android.R.id.paste)
|
|
||||||
m.removeItem(android.R.id.pasteAsPlainText)
|
|
||||||
m.removeItem(android.R.id.replaceText)
|
|
||||||
m.removeItem(android.R.id.undo)
|
|
||||||
m.removeItem(android.R.id.redo)
|
|
||||||
m.removeItem(android.R.id.autofill)
|
|
||||||
m.removeItem(android.R.id.textAssist)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
|
|
||||||
// Let the default implementation handle allowed actions (copy, select all)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: android.view.ActionMode?) {
|
|
||||||
// No special cleanup needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For editable mode, remove the blocking listener
|
|
||||||
textProcessor.setOnKeyListener(null)
|
|
||||||
// Remove the custom selection callback to allow all text operations
|
|
||||||
textProcessor.customSelectionActionModeCallback = null
|
|
||||||
|
|
||||||
// Only add text change listener for new editors in editable mode
|
|
||||||
if (isNewEditor) {
|
|
||||||
textProcessor.addTextChangedListener { editable ->
|
|
||||||
val currentText = editable?.toString() ?: ""
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
content = currentText,
|
|
||||||
canUndo = textProcessor.canUndo(),
|
|
||||||
canRedo = textProcessor.canRedo(),
|
|
||||||
hasUnsavedChanges = currentText != state.originalContent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule background configuration check
|
|
||||||
scheduleConfigurationCheck(currentText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
canUndo = textProcessor.canUndo(),
|
||||||
|
canRedo = textProcessor.canRedo(),
|
||||||
|
hasUnsavedChanges = hasUnsavedChanges,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scheduleConfigurationCheck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleConfigurationCheck(content: String) {
|
fun setReadOnly(isReadOnly: Boolean) {
|
||||||
// Cancel previous check
|
val textProcessor = editor ?: return
|
||||||
|
if (isReadOnly) {
|
||||||
|
textProcessor.setOnKeyListener(readOnlyKeyListener)
|
||||||
|
textProcessor.customSelectionActionModeCallback = readOnlySelectionCallback
|
||||||
|
} else {
|
||||||
|
textProcessor.setOnKeyListener(null)
|
||||||
|
textProcessor.customSelectionActionModeCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachEditor() {
|
||||||
|
val textProcessor = editor ?: return
|
||||||
|
textWatcher?.let { textProcessor.removeTextChangedListener(it) }
|
||||||
|
textWatcher = null
|
||||||
|
editor = null
|
||||||
|
configCheckJob?.cancel()
|
||||||
|
configCheckJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleConfigurationCheck() {
|
||||||
configCheckJob?.cancel()
|
configCheckJob?.cancel()
|
||||||
|
|
||||||
// Clear error immediately when user is typing
|
if (_uiState.value.configurationError != null) {
|
||||||
_uiState.update { it.copy(configurationError = null) }
|
_uiState.update { it.copy(configurationError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule new check after 2 seconds of inactivity
|
|
||||||
configCheckJob =
|
configCheckJob =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
delay(2000) // Wait 2 seconds
|
delay(2000)
|
||||||
|
val content =
|
||||||
// Check configuration in background
|
withContext(Dispatchers.Main) {
|
||||||
|
editor?.text?.toString().orEmpty()
|
||||||
|
}
|
||||||
checkConfigurationInBackground(content)
|
checkConfigurationInBackground(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +202,6 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
|
|||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
content = content,
|
|
||||||
originalContent = content,
|
originalContent = content,
|
||||||
hasUnsavedChanges = false,
|
hasUnsavedChanges = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -480,21 +475,28 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
|
|||||||
|
|
||||||
fun insertSymbol(symbol: String) {
|
fun insertSymbol(symbol: String) {
|
||||||
editor?.let { textProcessor ->
|
editor?.let { textProcessor ->
|
||||||
val start = textProcessor.selectionStart
|
val text = textProcessor.text ?: return@let
|
||||||
val end = textProcessor.selectionEnd
|
val rawStart = textProcessor.selectionStart
|
||||||
val text = textProcessor.text
|
val rawEnd = textProcessor.selectionEnd
|
||||||
|
// selectionStart/End can be reversed (backward drag-selection) or -1 (no cursor)
|
||||||
if (text != null) {
|
val start: Int
|
||||||
val newText =
|
val end: Int
|
||||||
StringBuilder(text)
|
if (rawStart < 0 || rawEnd < 0) {
|
||||||
.replace(start, end, symbol)
|
start = text.length
|
||||||
.toString()
|
end = text.length
|
||||||
|
} else {
|
||||||
textProcessor.resumeAutoScroll()
|
start = minOf(rawStart, rawEnd).coerceIn(0, text.length)
|
||||||
textProcessor.setTextContent(newText)
|
end = maxOf(rawStart, rawEnd).coerceIn(0, text.length)
|
||||||
// Place cursor after the inserted symbol
|
|
||||||
textProcessor.setSelection(start + symbol.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val newText =
|
||||||
|
StringBuilder(text)
|
||||||
|
.replace(start, end, symbol)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
textProcessor.resumeAutoScroll()
|
||||||
|
textProcessor.setTextContent(newText)
|
||||||
|
textProcessor.setSelection(start + symbol.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user