From 3b7a6fd57caf81371894e0a2bb8af024bde9513a Mon Sep 17 00:00:00 2001 From: William Brawner Date: Wed, 6 Nov 2024 21:39:22 -0700 Subject: [PATCH 1/2] Fix scroll position not updating while typing in edit pane --- .../simplemarkdown/ui/MarkdownTextField.kt | 77 ++++++++----------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt index 2efdad7..edcd40b 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt @@ -1,21 +1,16 @@ package com.wbrawner.simplemarkdown.ui import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf @@ -23,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -70,45 +64,38 @@ fun MarkdownTextField( fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface ) - Column( - modifier = modifier - .fillMaxSize() - .imePadding() - .verticalScroll(rememberScrollState()) - ) { - CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { - BasicTextField( - value = textFieldValue, - modifier = Modifier.fillMaxSize(), - onValueChange = setTextFieldAndViewModelValues, - enabled = true, - readOnly = false, - textStyle = textStyle, - cursorBrush = SolidColor(colors.cursorColor), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - keyboardActions = KeyboardActions.Default, - interactionSource = interactionSource, - singleLine = false, - maxLines = Int.MAX_VALUE, - minLines = 1, - decorationBox = @Composable { innerTextField -> - // places leading icon, text field with label and placeholder, trailing icon - TextFieldDefaults.DecorationBox( - value = textFieldValue.text, - visualTransformation = VisualTransformation.None, - innerTextField = innerTextField, - placeholder = { - Text(stringResource(R.string.markdown_here)) - }, - singleLine = false, - enabled = true, - interactionSource = interactionSource, - colors = colors, - contentPadding = PaddingValues(8.dp) - ) - } - ) - } + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + value = textFieldValue, + modifier = modifier.imePadding(), + onValueChange = setTextFieldAndViewModelValues, + enabled = true, + readOnly = false, + textStyle = textStyle, + cursorBrush = SolidColor(colors.cursorColor), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + keyboardActions = KeyboardActions.Default, + interactionSource = interactionSource, + singleLine = false, + maxLines = Int.MAX_VALUE, + minLines = 1, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = textFieldValue.text, + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + placeholder = { + Text(stringResource(R.string.markdown_here)) + }, + singleLine = false, + enabled = true, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(8.dp) + ) + }, + ) } } -- 2.45.2 From 6248f464d5a598e0daa111efaa4f5aa1593f45de Mon Sep 17 00:00:00 2001 From: William Brawner Date: Wed, 6 Nov 2024 21:30:02 -0700 Subject: [PATCH 2/2] Fix issues with reloading files when navigating between app screens --- .../wbrawner/simplemarkdown/MarkdownTests.kt | 20 +- .../simplemarkdown/robot/MainScreenRobot.kt | 6 +- .../robot/MarkdownInfoScreenRobot.kt | 9 +- .../wbrawner/simplemarkdown/MainActivity.kt | 21 +- .../simplemarkdown/MarkdownViewModel.kt | 182 +++++++++++------- .../wbrawner/simplemarkdown/ui/MainScreen.kt | 43 +---- .../simplemarkdown/ui/MarkdownTextField.kt | 41 +--- .../simplemarkdown/MarkdownViewModelTest.kt | 25 +-- 8 files changed, 193 insertions(+), 154 deletions(-) diff --git a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt index 6bf015a..1471faa 100644 --- a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt +++ b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt @@ -9,7 +9,6 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.core.content.FileProvider import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider.getApplicationContext -import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.rule.IntentsRule @@ -233,4 +232,23 @@ class MarkdownTests { checkTitleEquals("temp.md") } } + + @Test + fun editAndViewHelpMarkdownTest() = runTest { + ActivityScenario.launch(MainActivity::class.java) + onMainScreen(composeRule) { + checkTitleEquals("Untitled.md") + typeMarkdown("# Header test") + checkMarkdownEquals("# Header test") + openDrawer() + } onNavigationDrawer { + openHelpPage() + } onHelpScreen { + checkTitleEquals("Help") + verifyH1("Headings/Titles") + pressBack() + } onMainScreen { + checkMarkdownEquals("# Header test") + } + } } diff --git a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MainScreenRobot.kt b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MainScreenRobot.kt index 161ef63..42728c7 100644 --- a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MainScreenRobot.kt +++ b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MainScreenRobot.kt @@ -27,8 +27,10 @@ fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit suspend fun CoroutineScope.onMainScreen( composeRule: ComposeTestRule, block: suspend MainScreenRobot.() -> Unit -) { - block.invoke(MainScreenRobot(composeRule)) +): MainScreenRobot { + val mainScreenRobot = MainScreenRobot(composeRule) + block.invoke(mainScreenRobot) + return mainScreenRobot } class MainScreenRobot(private val composeRule: ComposeTestRule) : diff --git a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MarkdownInfoScreenRobot.kt b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MarkdownInfoScreenRobot.kt index 7d56a53..262054b 100644 --- a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MarkdownInfoScreenRobot.kt +++ b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/robot/MarkdownInfoScreenRobot.kt @@ -1,7 +1,14 @@ package com.wbrawner.simplemarkdown.robot import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) : TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule), - WebViewRobot by EspressoWebViewRobot() \ No newline at end of file + WebViewRobot by EspressoWebViewRobot() { + fun pressBack() = composeTestRule.onNodeWithContentDescription("Back").performClick() + + infix fun onMainScreen(block: MainScreenRobot.() -> Unit) = + MainScreenRobot(composeTestRule).apply(block) +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt index 8516083..71c7f1c 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt @@ -1,5 +1,7 @@ package com.wbrawner.simplemarkdown +import android.app.ComponentCaller +import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent @@ -26,11 +28,13 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.core.app.ActivityCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -42,6 +46,7 @@ import com.wbrawner.simplemarkdown.ui.SettingsScreen import com.wbrawner.simplemarkdown.ui.SupportScreen import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme import com.wbrawner.simplemarkdown.utility.Preference +import kotlinx.coroutines.launch import org.acra.ACRA class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback { @@ -60,8 +65,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes setContent { val autosaveEnabled by preferenceHelper.observe(Preference.AUTOSAVE_ENABLED) .collectAsState() - val readabilityEnabled by preferenceHelper.observe(Preference.READABILITY_ENABLED) - .collectAsState() val darkModePreference by preferenceHelper.observe(Preference.DARK_MODE) .collectAsState() LaunchedEffect(darkModePreference) { @@ -91,6 +94,10 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes LaunchedEffect(errorReporterPreference) { ACRA.errorReporter.setEnabled(errorReporterPreference) } + val intentData = remember(intent) { intent?.data } + LaunchedEffect(intentData) { + viewModel.load(intentData?.toString()) + } val windowSizeClass = calculateWindowSizeClass(this) SimpleMarkdownTheme { val navController = rememberNavController() @@ -121,7 +128,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes viewModel = viewModel, enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded, enableAutosave = autosaveEnabled, - enableReadability = readabilityEnabled ) } composable(Route.SETTINGS.path) { @@ -155,6 +161,15 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes } } } + + override fun onNewIntent(intent: Intent, caller: ComponentCaller) { + super.onNewIntent(intent, caller) + lifecycleScope.launch { + intent.data?.let { + viewModel.load(it.toString()) + } + } + } } enum class Route( diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt index 161de7b..4562ce3 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt @@ -1,11 +1,16 @@ package com.wbrawner.simplemarkdown import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.wbrawner.simplemarkdown.core.LocalOnlyException +import com.wbrawner.simplemarkdown.model.Readability import com.wbrawner.simplemarkdown.utility.FileHelper import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.PreferenceHelper @@ -23,21 +28,17 @@ import java.net.URI data class EditorState( val fileName: String = "Untitled.md", - val markdown: String = "", + val markdown: TextFieldValue = TextFieldValue(""), val path: URI? = null, val toast: ParameterizedText? = null, val alert: AlertDialogModel? = null, val saveCallback: (() -> Unit)? = null, - /** - * Used to signal to the view that it should reload due to an external change, like loading - * a new file - */ - val reloadToggle: Int = 0, val lockSwiping: Boolean = false, - private val initialMarkdown: String = "", + val enableReadability: Boolean = false, + val initialMarkdown: String = "", ) { val dirty: Boolean - get() = markdown != initialMarkdown + get() = markdown.text != initialMarkdown } class MarkdownViewModel( @@ -51,27 +52,41 @@ class MarkdownViewModel( init { preferenceHelper.observe(Preference.LOCK_SWIPING) .onEach { - _state.value = _state.value.copy(lockSwiping = it) + updateState { copy(lockSwiping = it) } + } + .launchIn(viewModelScope) + preferenceHelper.observe(Preference.READABILITY_ENABLED) + .onEach { + updateState { + copy( + enableReadability = it, + markdown = markdown.copy(annotatedString = markdown.text.annotate(it)), + ) + } } .launchIn(viewModelScope) } - fun updateMarkdown(markdown: String?) { - _state.value = _state.value.copy( - markdown = markdown ?: "", - ) + fun updateMarkdown(markdown: String?) = updateMarkdown(TextFieldValue(markdown.orEmpty())) + + fun updateMarkdown(markdown: TextFieldValue) { + updateState { + copy( + markdown = markdown.copy(annotatedString = markdown.text.annotate(enableReadability)), + ) + } } fun dismissToast() { - _state.value = _state.value.copy(toast = null) + updateState { copy(toast = null) } } fun dismissAlert() { - _state.value = _state.value.copy(alert = null) + updateState { copy(alert = null) } } private fun unsetSaveCallback() { - _state.value = _state.value.copy(saveCallback = null) + updateState { copy(saveCallback = null) } } suspend fun load(loadPath: String?) { @@ -94,25 +109,30 @@ class MarkdownViewModel( val uri = URI.create(actualLoadPath) fileHelper.open(uri) ?.let { (name, content) -> - val currentState = _state.value - _state.value = currentState.copy( - path = uri, - fileName = name, - markdown = content, - initialMarkdown = content, - reloadToggle = currentState.reloadToggle.inv(), - toast = ParameterizedText(R.string.file_loaded, arrayOf(name)) - ) + updateState { + copy( + path = uri, + fileName = name, + markdown = TextFieldValue(content), + initialMarkdown = content, + toast = ParameterizedText(R.string.file_loaded, arrayOf(name)) + ) + } preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath } ?: throw IllegalStateException("Opened file was null") } catch (e: Exception) { Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath") - _state.value = _state.value.copy( - alert = AlertDialogModel( - text = ParameterizedText(R.string.file_load_error), - confirmButton = AlertDialogModel.ButtonModel(ParameterizedText(R.string.ok), onClick = ::dismissAlert) + updateState { + copy( + alert = AlertDialogModel( + text = ParameterizedText(R.string.file_load_error), + confirmButton = AlertDialogModel.ButtonModel( + ParameterizedText(R.string.ok), + onClick = ::dismissAlert + ) + ) ) - ) + } } } } @@ -124,35 +144,44 @@ class MarkdownViewModel( ?: run { Timber.w("Attempted to save file with empty path") if (interactive) { - _state.value = _state.value.copy(saveCallback = ::unsetSaveCallback) + updateState { + copy(saveCallback = ::unsetSaveCallback) + } } return@withLock false } try { Timber.i("Saving file to $actualSavePath...") val currentState = _state.value - val name = fileHelper.save(actualSavePath, currentState.markdown) - _state.value = currentState.copy( - fileName = name, - path = actualSavePath, - initialMarkdown = currentState.markdown, - toast = if (interactive) ParameterizedText(R.string.file_saved, arrayOf(name)) else null - ) + val name = fileHelper.save(actualSavePath, currentState.markdown.text) + updateState { + currentState.copy( + fileName = name, + path = actualSavePath, + initialMarkdown = currentState.markdown.text, + toast = if (interactive) ParameterizedText( + R.string.file_saved, + arrayOf(name) + ) else null + ) + } Timber.i("Saved file $name to uri $actualSavePath") Timber.i("Persisting autosave uri in shared prefs: $actualSavePath") preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath true } catch (e: Exception) { Timber.e(e, "Failed to save file to $actualSavePath") - _state.value = _state.value.copy( - alert = AlertDialogModel( - text = ParameterizedText(R.string.file_save_error), - confirmButton = AlertDialogModel.ButtonModel( - text = ParameterizedText(R.string.ok), - onClick = ::dismissAlert + updateState { + copy( + alert = AlertDialogModel( + text = ParameterizedText(R.string.file_save_error), + confirmButton = AlertDialogModel.ButtonModel( + text = ParameterizedText(R.string.ok), + onClick = ::dismissAlert + ) ) ) - ) + } false } } @@ -184,7 +213,7 @@ class MarkdownViewModel( // to an internal storage location, thus marking it as not dirty, but no longer able to // access the file if the accidentally go to create a new file without properly saving // the current one - fileHelper.save(file, _state.value.markdown) + fileHelper.save(file, _state.value.markdown.text) preferenceHelper[Preference.AUTOSAVE_URI] = file } } @@ -193,33 +222,35 @@ class MarkdownViewModel( fun reset(untitledFileName: String, force: Boolean = false) { Timber.i("Resetting view model to default state") if (!force && _state.value.dirty) { - _state.value = _state.value.copy(alert = AlertDialogModel( - text = ParameterizedText(R.string.prompt_save_changes), - confirmButton = AlertDialogModel.ButtonModel( - text = ParameterizedText(R.string.yes), - onClick = { - _state.value = _state.value.copy( - saveCallback = { - reset(untitledFileName, false) - } - ) - } - ), - dismissButton = AlertDialogModel.ButtonModel( - text = ParameterizedText(R.string.no), - onClick = { - reset(untitledFileName, true) - } - ) - )) + updateState { + copy(alert = AlertDialogModel( + text = ParameterizedText(R.string.prompt_save_changes), + confirmButton = AlertDialogModel.ButtonModel( + text = ParameterizedText(R.string.yes), + onClick = { + _state.value = _state.value.copy( + saveCallback = { + reset(untitledFileName, false) + } + ) + } + ), + dismissButton = AlertDialogModel.ButtonModel( + text = ParameterizedText(R.string.no), + onClick = { + reset(untitledFileName, true) + } + ) + )) + } return } - _state.value = + updateState { EditorState( fileName = untitledFileName, - reloadToggle = _state.value.reloadToggle.inv(), lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean ) + } Timber.i("Removing autosave uri from shared prefs") preferenceHelper[Preference.AUTOSAVE_URI] = null } @@ -228,6 +259,10 @@ class MarkdownViewModel( preferenceHelper[Preference.LOCK_SWIPING] = enabled } + private fun updateState(block: EditorState.() -> EditorState) { + _state.value = _state.value.block() + } + companion object { fun factory( fileHelper: FileHelper, @@ -270,4 +305,17 @@ data class ParameterizedText(@StringRes val text: Int, val params: Array = result = 31 * result + params.contentHashCode() return result } +} + +private fun String.annotate(enableReadability: Boolean): AnnotatedString { + if (!enableReadability) return AnnotatedString(this) + val readability = Readability(this) + val annotated = AnnotatedString.Builder(this) + for (sentence in readability.sentences()) { + var color = Color.Transparent + if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100) + if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100) + annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end()) + } + return annotated.toAnnotatedString() } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt index f30e3a8..f2842d3 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat.startActivity import androidx.navigation.NavController @@ -61,7 +62,6 @@ import com.wbrawner.simplemarkdown.MarkdownViewModel import com.wbrawner.simplemarkdown.ParameterizedText import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.Route -import com.wbrawner.simplemarkdown.utility.activity import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -76,21 +76,14 @@ fun MainScreen( viewModel: MarkdownViewModel, enableWideLayout: Boolean, enableAutosave: Boolean, - enableReadability: Boolean ) { val coroutineScope = rememberCoroutineScope() val fileName by viewModel.collectAsState(EditorState::fileName, "") - val initialMarkdown by viewModel.collectAsState(EditorState::markdown, "") - val reloadToggle by viewModel.collectAsState(EditorState::reloadToggle, 0) - val markdown by viewModel.collectAsState(EditorState::markdown, "") + val markdown by viewModel.collectAsState(EditorState::markdown, TextFieldValue("")) val dirty by viewModel.collectAsState(EditorState::dirty, false) val alert by viewModel.collectAsState(EditorState::alert, null) val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null) val lockSwiping by viewModel.collectAsState(EditorState::lockSwiping, false) - val intentData = LocalContext.current.activity?.intent?.data - LaunchedEffect(intentData) { - viewModel.load(intentData?.toString()) - } LaunchedEffect(enableAutosave) { if (!enableAutosave) return@LaunchedEffect while (isActive) { @@ -102,8 +95,6 @@ fun MainScreen( MainScreen( dirty = dirty, fileName = fileName, - reloadToggle = reloadToggle, - initialMarkdown = initialMarkdown, markdown = markdown, setMarkdown = viewModel::updateMarkdown, lockSwiping = lockSwiping, @@ -131,7 +122,6 @@ fun MainScreen( viewModel.reset("Untitled.md") }, enableWideLayout = enableWideLayout, - enableReadability = enableReadability, ) } @@ -140,10 +130,8 @@ fun MainScreen( private fun MainScreen( fileName: String = "Untitled.md", dirty: Boolean = false, - reloadToggle: Int = 0, - initialMarkdown: String = "", - markdown: String = "", - setMarkdown: (String) -> Unit = {}, + markdown: TextFieldValue = TextFieldValue(""), + setMarkdown: (TextFieldValue) -> Unit = {}, lockSwiping: Boolean, toggleLockSwiping: (Boolean) -> Unit, message: String? = null, @@ -157,7 +145,6 @@ private fun MainScreen( saveCallback: (() -> Unit)? = null, reset: () -> Unit = {}, enableWideLayout: Boolean = false, - enableReadability: Boolean = false ) { val openFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { @@ -218,7 +205,7 @@ private fun MainScreen( actions = { IconButton(onClick = { val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, markdown) + shareIntent.putExtra(Intent.EXTRA_TEXT, markdown.text) shareIntent.type = "text/plain" startActivity( context, Intent.createChooser( @@ -291,10 +278,8 @@ private fun MainScreen( modifier = Modifier .fillMaxHeight() .weight(1f), - reload = reloadToggle, markdown = markdown, setMarkdown = setMarkdown, - enableReadability = enableReadability, ) Spacer( modifier = Modifier @@ -306,7 +291,7 @@ private fun MainScreen( modifier = Modifier .fillMaxHeight() .weight(1f), - markdown = markdown + markdown = markdown.text ) } } else { @@ -316,12 +301,9 @@ private fun MainScreen( .padding(paddingValues) ) { TabbedMarkdownEditor( - initialMarkdown = initialMarkdown, markdown = markdown, setMarkdown = setMarkdown, lockSwiping = lockSwiping, - enableReadability = enableReadability, - reloadToggle = reloadToggle, scrollBehavior = scrollBehavior ) } @@ -333,12 +315,9 @@ private fun MainScreen( @Composable @OptIn(ExperimentalMaterial3Api::class) private fun TabbedMarkdownEditor( - initialMarkdown: String, - markdown: String, - setMarkdown: (String) -> Unit, + markdown: TextFieldValue, + setMarkdown: (TextFieldValue) -> Unit, lockSwiping: Boolean, - enableReadability: Boolean, - reloadToggle: Int, scrollBehavior: TopAppBarScrollBehavior ) { val coroutineScope = rememberCoroutineScope() @@ -368,17 +347,15 @@ private fun TabbedMarkdownEditor( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), - markdown = initialMarkdown, + markdown = markdown, setMarkdown = setMarkdown, - enableReadability = enableReadability, - reload = reloadToggle, ) } else { MarkdownText( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), - markdown + markdown.text ) } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt index edcd40b..aa3bba6 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt @@ -13,15 +13,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardCapitalization @@ -29,28 +25,14 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.wbrawner.simplemarkdown.R -import com.wbrawner.simplemarkdown.model.Readability @OptIn(ExperimentalMaterial3Api::class) @Composable fun MarkdownTextField( modifier: Modifier = Modifier, - markdown: String, - setMarkdown: (String) -> Unit, - reload: Int = 0, - enableReadability: Boolean = false, + markdown: TextFieldValue, + setMarkdown: (TextFieldValue) -> Unit, ) { - val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) } - val (composition, setComposition) = remember { mutableStateOf(null) } - val (textFieldValue, setTextFieldValue) = remember(reload) { - mutableStateOf(TextFieldValue(markdown.annotate(enableReadability), selection, composition)) - } - val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = { - setSelection(it.selection) - setComposition(it.composition) - setTextFieldValue(it.copy(annotatedString = it.text.annotate(enableReadability))) - setMarkdown(it.text) - } val colors = TextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, @@ -66,9 +48,9 @@ fun MarkdownTextField( ) CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { BasicTextField( - value = textFieldValue, + value = markdown, modifier = modifier.imePadding(), - onValueChange = setTextFieldAndViewModelValues, + onValueChange = setMarkdown, enabled = true, readOnly = false, textStyle = textStyle, @@ -82,7 +64,7 @@ fun MarkdownTextField( decorationBox = @Composable { innerTextField -> // places leading icon, text field with label and placeholder, trailing icon TextFieldDefaults.DecorationBox( - value = textFieldValue.text, + value = markdown.text, visualTransformation = VisualTransformation.None, innerTextField = innerTextField, placeholder = { @@ -98,16 +80,3 @@ fun MarkdownTextField( ) } } - -private fun String.annotate(enableReadability: Boolean): AnnotatedString { - if (!enableReadability) return AnnotatedString(this) - val readability = Readability(this) - val annotated = AnnotatedString.Builder(this) - for (sentence in readability.sentences()) { - var color = Color.Transparent - if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100) - if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100) - annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end()) - } - return annotated.toAnnotatedString() -} \ No newline at end of file diff --git a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt index a360c8d..9971585 100644 --- a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt +++ b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt @@ -1,5 +1,6 @@ package com.wbrawner.simplemarkdown +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.wbrawner.simplemarkdown.utility.Preference @@ -52,9 +53,9 @@ class MarkdownViewModelTest { @Test fun testMarkdownUpdate() = runTest { - assertEquals("", viewModel.state.value.markdown) + assertEquals("".asTextFieldValue(), viewModel.state.value.markdown) viewModel.updateMarkdown("Updated content") - assertEquals("Updated content", viewModel.state.value.markdown) + assertEquals("Updated content".asTextFieldValue(), viewModel.state.value.markdown) } @Test @@ -68,11 +69,11 @@ class MarkdownViewModelTest { val uri = URI.create("file:///home/user/Untitled.md") preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString() viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty) - viewModelScope.advanceUntilIdle() + viewModel.load(null) assertEquals(uri, fileHelper.openedUris.firstOrNull()) val (fileName, contents) = fileHelper.file assertEquals(fileName, viewModel.state.value.fileName) - assertEquals(contents, viewModel.state.value.markdown) + assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown) } @Test @@ -82,7 +83,7 @@ class MarkdownViewModelTest { assertEquals(uri, fileHelper.openedUris.firstOrNull()) val (fileName, contents) = fileHelper.file assertEquals(fileName, viewModel.state.value.fileName) - assertEquals(contents, viewModel.state.value.markdown) + assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown) } @Test @@ -126,7 +127,7 @@ class MarkdownViewModelTest { val uri = URI.create("file:///home/user/Saved.md") val testMarkdown = "# Test" viewModel.updateMarkdown(testMarkdown) - assertEquals(testMarkdown, viewModel.state.value.markdown) + assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown) assertTrue(viewModel.save(uri)) assertEquals("Saved.md", viewModel.state.value.fileName) assertEquals(uri, fileHelper.savedData.last().uri) @@ -139,7 +140,7 @@ class MarkdownViewModelTest { val uri = URI.create("file:///home/user/Untitled.md") val testMarkdown = "# Test" viewModel.updateMarkdown(testMarkdown) - assertEquals(testMarkdown, viewModel.state.value.markdown) + assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown) fileHelper.errorOnSave = true assertNull(viewModel.state.value.alert) assertFalse(viewModel.save(uri)) @@ -159,7 +160,7 @@ class MarkdownViewModelTest { assertNull(viewModel.state.value.alert) with(viewModel.state.value) { assertEquals("New.md", fileName) - assertEquals("", markdown) + assertEquals("".asTextFieldValue(), markdown) assertNull(path) assertNull(saveCallback) assertNull(alert) @@ -181,7 +182,7 @@ class MarkdownViewModelTest { requireNotNull(onClick) onClick.invoke() } - assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv())) + assertEquals(viewModel.state.value, EditorState()) } @Test @@ -200,7 +201,7 @@ class MarkdownViewModelTest { viewModel.save(uri) assertNotNull(viewModel.state.value.saveCallback) requireNotNull(viewModel.state.value.saveCallback).invoke() - assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv())) + assertEquals(viewModel.state.value, EditorState()) } @Test @@ -217,7 +218,7 @@ class MarkdownViewModelTest { assertNull(viewModel.state.value.alert) with(viewModel.state.value) { assertEquals("Unsaved.md", fileName) - assertEquals("", markdown) + assertEquals("".asTextFieldValue(), markdown) assertNull(path) assertNull(saveCallback) assertNull(alert) @@ -303,4 +304,6 @@ class MarkdownViewModelTest { assertFalse(preferenceHelper.preferences[Preference.LOCK_SWIPING] as Boolean) assertFalse(viewModel.state.value.lockSwiping) } + + private fun String.asTextFieldValue() = TextFieldValue(this) } \ No newline at end of file -- 2.45.2