Fix tests
This commit is contained in:
parent
a6616550dd
commit
e0d43d5154
13 changed files with 948 additions and 295 deletions
|
@ -153,6 +153,7 @@ dependencies {
|
|||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
val coroutinesVersion = "1.7.1"
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
|
||||
val lifecycleVersion = "2.2.0"
|
||||
implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion")
|
||||
kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.NativeKeyEvent
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasAnyDescendant
|
||||
import androidx.compose.ui.test.hasAnySibling
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performKeyPress
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performTextInputSelection
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.printToLog
|
||||
import androidx.compose.ui.text.TextRange
|
||||
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
|
||||
import androidx.test.espresso.matcher.ViewMatchers.*
|
||||
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
|
||||
import androidx.test.espresso.web.sugar.Web.onWebView
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
|
||||
import androidx.test.espresso.web.webdriver.Locator
|
||||
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.hamcrest.CoreMatchers.containsString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.Reader
|
||||
|
||||
class MarkdownTests {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val intentsRule = IntentsRule()
|
||||
|
||||
private lateinit var file: File
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
|
||||
assertTrue(requireNotNull(file.parentFile).mkdirs())
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun openAppTest() {
|
||||
val context = getInstrumentation().targetContext
|
||||
context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
.apply { context.startActivity(this) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editAndPreviewMarkdownTest() {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.typeMarkdown("# Header test")
|
||||
composeRule.checkMarkdownEquals("# Header test")
|
||||
composeRule.openPreview()
|
||||
onWebView(isAssignableFrom(WebView::class.java))
|
||||
.forceJavascriptEnabled()
|
||||
.withElement(findElement(Locator.TAG_NAME, "h1"))
|
||||
.check(webMatches(getText(), containsString("Header test")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openThenNewMarkdownTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickOpenMenuItem()
|
||||
composeRule.checkMarkdownEquals(markdownText)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickNewMenuItem()
|
||||
composeRule.verifyDialogIsNotShown()
|
||||
composeRule.checkMarkdownEquals("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editThenNewMarkdownTest() {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
composeRule.typeMarkdown(markdownText)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickNewMenuItem()
|
||||
composeRule.onNode(isDialog()).printToLog("TestDebugging")
|
||||
composeRule.verifyDialogIsShown("Would you like to save your changes?")
|
||||
composeRule.discardChanges()
|
||||
composeRule.checkMarkdownEquals("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithFileUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
composeRule.typeMarkdown(markdownText)
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickSaveMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithContentUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
composeRule.typeMarkdown(markdownText)
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(
|
||||
getApplicationContext(),
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickSaveMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithFileUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickOpenMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
composeRule.checkMarkdownEquals(markdownText)
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithContentUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(
|
||||
getApplicationContext(),
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickOpenMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
composeRule.checkMarkdownEquals(markdownText)
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun openEditAndSaveMarkdownTest() = runTest {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.checkTitleEquals("Untitled.md")
|
||||
composeRule.openMenu()
|
||||
composeRule.clickOpenMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
composeRule.verifyTextIsShown("Successfully loaded temp.md")
|
||||
composeRule.checkMarkdownEquals(markdownText)
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
val additionalText = "# More info\n\nThis is some additional text"
|
||||
composeRule.typeMarkdown(additionalText)
|
||||
composeRule.openMenu()
|
||||
composeRule.clickSaveMenuItem()
|
||||
composeRule.awaitIdle()
|
||||
composeRule.verifyTextIsShown("Successfully saved temp.md")
|
||||
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
|
||||
composeRule.checkTitleEquals("temp.md")
|
||||
}
|
||||
|
||||
private fun ComposeTestRule.checkTitleEquals(title: String) =
|
||||
onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title)))
|
||||
.assertIsDisplayed()
|
||||
|
||||
private fun ComposeTestRule.typeMarkdown(markdown: String) =
|
||||
onNode(hasSetTextAction()).performTextReplacement(markdown)
|
||||
|
||||
|
||||
private fun ComposeTestRule.checkMarkdownEquals(markdown: String) {
|
||||
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
|
||||
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
|
||||
}
|
||||
onNode(hasSetTextAction()).assert(markdownMatcher)
|
||||
}
|
||||
|
||||
private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick()
|
||||
|
||||
private fun ComposeTestRule.openMenu() =
|
||||
onNodeWithContentDescription("Editor Actions").performClick()
|
||||
|
||||
private fun ComposeTestRule.clickOpenMenuItem() = onNodeWithText("Open").performClick()
|
||||
|
||||
private fun ComposeTestRule.clickNewMenuItem() = onNodeWithText("New").performClick()
|
||||
|
||||
private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick()
|
||||
|
||||
private fun ComposeTestRule.verifyDialogIsShown(text: String) =
|
||||
onNode(isDialog().and(hasAnyDescendant(hasText(text)))).assertIsDisplayed()
|
||||
|
||||
private fun ComposeTestRule.verifyDialogIsNotShown() = onNode(isDialog()).assertIsNotDisplayed()
|
||||
|
||||
private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick()
|
||||
|
||||
private fun ComposeTestRule.verifyTextIsShown(text: String) =
|
||||
onNodeWithText(text).assertIsDisplayed()
|
||||
}
|
|
@ -14,9 +14,9 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Help
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
|
@ -128,8 +128,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
|
|||
viewModel = viewModel,
|
||||
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
|
||||
enableAutosave = autosaveEnabled,
|
||||
enableReadability = readabilityEnabled,
|
||||
darkMode = darkModePreference
|
||||
enableReadability = readabilityEnabled
|
||||
)
|
||||
}
|
||||
composable(Route.SETTINGS.path) {
|
||||
|
@ -173,7 +172,7 @@ enum class Route(
|
|||
EDITOR("/", "Editor", Icons.Default.Edit),
|
||||
SETTINGS("/settings", "Settings", Icons.Default.Settings),
|
||||
SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite),
|
||||
HELP("/help", "Help", Icons.Default.Help),
|
||||
HELP("/help", "Help", Icons.AutoMirrored.Filled.Help),
|
||||
ABOUT("/about", "About", Icons.Default.Info),
|
||||
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
|
||||
}
|
|
@ -7,30 +7,34 @@ import androidx.lifecycle.viewmodel.CreationExtras
|
|||
import com.wbrawner.simplemarkdown.utility.FileHelper
|
||||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
data class EditorState(
|
||||
val fileName: String = "Untitled.md",
|
||||
val markdown: String = "",
|
||||
val path: URI? = null,
|
||||
val dirty: Boolean = false,
|
||||
val toast: String? = null,
|
||||
val alert: AlertDialogModel? = null,
|
||||
val saveCallback: (() -> Unit)? = null
|
||||
)
|
||||
|
||||
class MarkdownViewModel(
|
||||
private val fileHelper: FileHelper,
|
||||
private val preferenceHelper: PreferenceHelper
|
||||
) : ViewModel() {
|
||||
private val _fileName = MutableStateFlow("Untitled.md")
|
||||
val fileName = _fileName.asStateFlow()
|
||||
private val _markdown = MutableStateFlow("")
|
||||
val markdown = _markdown.asStateFlow()
|
||||
private val path = MutableStateFlow<URI?>(null)
|
||||
private val isDirty = AtomicBoolean(false)
|
||||
private val _effects = MutableSharedFlow<Effect>()
|
||||
val effects = _effects.asSharedFlow()
|
||||
private val _state = MutableStateFlow(EditorState())
|
||||
val state = _state.asStateFlow()
|
||||
private val saveMutex = Mutex()
|
||||
|
||||
init {
|
||||
|
@ -39,71 +43,114 @@ class MarkdownViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun updateMarkdown(markdown: String?) {
|
||||
this@MarkdownViewModel._markdown.emit(markdown ?: "")
|
||||
isDirty.set(true)
|
||||
fun updateMarkdown(markdown: String?) {
|
||||
_state.value = _state.value.copy(
|
||||
markdown = markdown ?: "",
|
||||
dirty = true
|
||||
)
|
||||
}
|
||||
|
||||
fun dismissToast() {
|
||||
_state.value = _state.value.copy(toast = null)
|
||||
}
|
||||
|
||||
fun dismissAlert() {
|
||||
_state.value = _state.value.copy(alert = null)
|
||||
}
|
||||
|
||||
private fun unsetSaveCallback() {
|
||||
_state.value = _state.value.copy(saveCallback = null)
|
||||
}
|
||||
|
||||
suspend fun load(loadPath: String?) {
|
||||
if (loadPath.isNullOrBlank()) {
|
||||
Timber.i("No URI provided to load, attempting to load last autosaved file")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI]
|
||||
saveMutex.withLock {
|
||||
val actualLoadPath = loadPath
|
||||
?.ifBlank { null }
|
||||
?: preferenceHelper[Preference.AUTOSAVE_URI]
|
||||
?.let {
|
||||
val autosaveUri = it as? String
|
||||
if (autosaveUri.isNullOrBlank()) {
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||
null
|
||||
} else {
|
||||
Timber.d("Using uri from shared preferences: $it")
|
||||
load(autosaveUri)
|
||||
}
|
||||
}
|
||||
return
|
||||
autosaveUri
|
||||
}
|
||||
} ?: return
|
||||
Timber.d("Loading file at $actualLoadPath")
|
||||
try {
|
||||
val uri = URI.create(loadPath)
|
||||
val uri = URI.create(actualLoadPath)
|
||||
fileHelper.open(uri)
|
||||
?.let { (name, content) ->
|
||||
path.emit(uri)
|
||||
_effects.emit(Effect.ClearText)
|
||||
_fileName.emit(name)
|
||||
_markdown.emit(content)
|
||||
isDirty.set(false)
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath
|
||||
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath"))
|
||||
_state.value = _state.value.copy(
|
||||
path = uri,
|
||||
fileName = name,
|
||||
markdown = content,
|
||||
dirty = false,
|
||||
toast = "Successfully loaded $name"
|
||||
)
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
|
||||
} ?: throw IllegalStateException("Opened file was null")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to open file at path: $loadPath")
|
||||
Timber.e(e, "Failed to open file at path: $actualLoadPath")
|
||||
_state.value = _state.value.copy(
|
||||
alert = AlertDialogModel(
|
||||
text = "Failed to open file at path: $actualLoadPath",
|
||||
confirmButton = AlertDialogModel.ButtonModel("OK", onClick = ::dismissAlert)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(savePath: URI? = path.value, promptSavePath: Boolean = true): Boolean {
|
||||
return saveMutex.withLock {
|
||||
if (savePath == null) {
|
||||
suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
|
||||
saveMutex.withLock {
|
||||
val actualSavePath = savePath
|
||||
?: _state.value.path
|
||||
?: run {
|
||||
Timber.w("Attempted to save file with empty path")
|
||||
if (promptSavePath) {
|
||||
_effects.emit(Effect.OpenSaveDialog {})
|
||||
if (interactive) {
|
||||
_state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
|
||||
}
|
||||
return false
|
||||
return@withLock false
|
||||
}
|
||||
try {
|
||||
val name = fileHelper.save(savePath, markdown.value)
|
||||
_fileName.emit(name)
|
||||
path.emit(savePath)
|
||||
isDirty.set(false)
|
||||
Timber.i("Saved file ${fileName.value} to uri $savePath")
|
||||
Timber.i("Persisting autosave uri in shared prefs: $savePath")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = savePath
|
||||
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,
|
||||
dirty = false,
|
||||
toast = if (interactive) "Successfully saved $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) {
|
||||
val message = "Failed to save file to $savePath"
|
||||
val message = "Failed to save file to $actualSavePath"
|
||||
Timber.e(e, message)
|
||||
_effects.emit(Effect.Error(message))
|
||||
_state.value = _state.value.copy(
|
||||
alert = AlertDialogModel(
|
||||
text = message,
|
||||
confirmButton = AlertDialogModel.ButtonModel(
|
||||
text = "OK",
|
||||
onClick = ::dismissAlert
|
||||
)
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun autosave() {
|
||||
if (!isDirty.get()) {
|
||||
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
|
||||
if (!isAutoSaveEnabled) {
|
||||
Timber.i("Ignoring autosave as autosave not enabled")
|
||||
return
|
||||
}
|
||||
if (!_state.value.dirty) {
|
||||
Timber.d("Ignoring autosave as contents haven't changed")
|
||||
return
|
||||
}
|
||||
|
@ -111,52 +158,59 @@ class MarkdownViewModel(
|
|||
Timber.i("Ignoring autosave since manual save is already in progress")
|
||||
return
|
||||
}
|
||||
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
|
||||
if (!isAutoSaveEnabled) {
|
||||
Timber.i("Ignoring autosave as autosave not enabled")
|
||||
return
|
||||
}
|
||||
if (!save(promptSavePath = false)) {
|
||||
Timber.d("Performing autosave")
|
||||
if (!save(interactive = false)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// The user has left the app, with autosave enabled, and we don't already have a
|
||||
// Uri for them or for some reason we were unable to save to the original Uri. In
|
||||
// this case, we need to just save to internal file storage so that we can recover
|
||||
val file = File(fileHelper.defaultDirectory, fileName.value).toURI()
|
||||
val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
|
||||
Timber.i("No cached uri for autosave, saving to $file instead")
|
||||
save(file)
|
||||
// Here we call the fileHelper directly so that the file is still registered as dirty.
|
||||
// This prevents the user from ending up in a scenario where they've autosaved the file
|
||||
// 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)
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reset(untitledFileName: String, force: Boolean = false) {
|
||||
fun reset(untitledFileName: String, force: Boolean = false) {
|
||||
Timber.i("Resetting view model to default state")
|
||||
if (!force && isDirty.get()) {
|
||||
_effects.emit(Effect.Prompt(
|
||||
"Would you like to save your changes?",
|
||||
confirm = {
|
||||
viewModelScope.launch {
|
||||
_effects.emit(Effect.OpenSaveDialog {
|
||||
if (!force && _state.value.dirty) {
|
||||
_state.value = _state.value.copy(alert = AlertDialogModel(
|
||||
text = "Would you like to save your changes?",
|
||||
confirmButton = AlertDialogModel.ButtonModel(
|
||||
text = "Yes",
|
||||
onClick = {
|
||||
_state.value = _state.value.copy(
|
||||
saveCallback = {
|
||||
reset(untitledFileName, false)
|
||||
})
|
||||
}
|
||||
},
|
||||
cancel = {
|
||||
viewModelScope.launch {
|
||||
)
|
||||
}
|
||||
),
|
||||
dismissButton = AlertDialogModel.ButtonModel(
|
||||
text = "No",
|
||||
onClick = {
|
||||
reset(untitledFileName, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
))
|
||||
return
|
||||
}
|
||||
_fileName.emit(untitledFileName)
|
||||
_markdown.emit("")
|
||||
path.emit(null)
|
||||
_effects.emit(Effect.ClearText)
|
||||
isDirty.set(false)
|
||||
_state.value = EditorState(fileName = untitledFileName)
|
||||
Timber.i("Removing autosave uri from shared prefs")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(fileHelper: FileHelper, preferenceHelper: PreferenceHelper): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
fun factory(
|
||||
fileHelper: FileHelper,
|
||||
preferenceHelper: PreferenceHelper
|
||||
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(
|
||||
modelClass: Class<T>,
|
||||
|
@ -166,13 +220,12 @@ class MarkdownViewModel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Effect {
|
||||
data class OpenSaveDialog(val postSaveBlock: suspend () -> Unit) : Effect
|
||||
data class Prompt(val text: String, val confirm: () -> Unit, val cancel: () -> Unit) :
|
||||
Effect
|
||||
|
||||
data object ClearText : Effect
|
||||
data class Error(val text: String) : Effect
|
||||
}
|
||||
}
|
||||
|
||||
data class AlertDialogModel(
|
||||
val text: String,
|
||||
val confirmButton: ButtonModel,
|
||||
val dismissButton: ButtonModel? = null
|
||||
) {
|
||||
data class ButtonModel(val text: String, val onClick: () -> Unit)
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
|
@ -21,7 +22,7 @@ import androidx.compose.foundation.pager.HorizontalPager
|
|||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
|
@ -39,6 +40,9 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
|
@ -47,11 +51,9 @@ import androidx.compose.material3.TextField
|
|||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
@ -67,6 +68,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|||
import androidx.compose.ui.res.painterResource
|
||||
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
|
||||
|
@ -75,15 +77,18 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.simplemarkdown.AlertDialogModel
|
||||
import com.wbrawner.simplemarkdown.EditorState
|
||||
import com.wbrawner.simplemarkdown.MarkdownViewModel
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.Route
|
||||
import com.wbrawner.simplemarkdown.model.Readability
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
|
@ -91,90 +96,130 @@ fun MainScreen(
|
|||
viewModel: MarkdownViewModel,
|
||||
enableWideLayout: Boolean,
|
||||
enableAutosave: Boolean,
|
||||
enableReadability: Boolean,
|
||||
darkMode: String,
|
||||
enableReadability: Boolean
|
||||
) {
|
||||
var lockSwiping by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val fileName by viewModel.fileName.collectAsState()
|
||||
val openFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
coroutineScope.launch {
|
||||
viewModel.load(it.toString())
|
||||
}
|
||||
}
|
||||
val saveFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
|
||||
it?.let {
|
||||
coroutineScope.launch {
|
||||
viewModel.save(URI.create(it.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var promptEffect by remember { mutableStateOf<MarkdownViewModel.Effect.Prompt?>(null) }
|
||||
var clearText by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
is MarkdownViewModel.Effect.OpenSaveDialog -> saveFileLauncher.launch(fileName)
|
||||
is MarkdownViewModel.Effect.Error -> errorMessage = effect.text
|
||||
is MarkdownViewModel.Effect.Prompt -> promptEffect = effect
|
||||
is MarkdownViewModel.Effect.ClearText -> clearText++
|
||||
}
|
||||
}
|
||||
}
|
||||
val fileName by viewModel.collectAsState(EditorState::fileName, "")
|
||||
val markdown by viewModel.collectAsState(EditorState::markdown, "")
|
||||
val alert by viewModel.collectAsState(EditorState::alert, null)
|
||||
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
|
||||
LaunchedEffect(enableAutosave) {
|
||||
if (!enableAutosave) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
delay(30_000)
|
||||
delay(500)
|
||||
viewModel.autosave()
|
||||
}
|
||||
}
|
||||
errorMessage?.let { message ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { errorMessage = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { errorMessage = null }) {
|
||||
Text("OK")
|
||||
val toast by viewModel.collectAsState(EditorState::toast, null)
|
||||
MainScreen(
|
||||
fileName = fileName,
|
||||
markdown = markdown,
|
||||
setMarkdown = viewModel::updateMarkdown,
|
||||
message = toast,
|
||||
dismissMessage = viewModel::dismissToast,
|
||||
alert = alert,
|
||||
dismissAlert = viewModel::dismissAlert,
|
||||
navigate = {
|
||||
navController.navigate(it.path)
|
||||
},
|
||||
navigateBack = { navController.popBackStack() },
|
||||
loadFile = {
|
||||
coroutineScope.launch {
|
||||
viewModel.load(it.toString())
|
||||
}
|
||||
},
|
||||
text = { Text(message) }
|
||||
)
|
||||
saveFile = {
|
||||
coroutineScope.launch {
|
||||
viewModel.save(it)
|
||||
}
|
||||
promptEffect?.let { prompt ->
|
||||
},
|
||||
saveCallback = saveCallback,
|
||||
reset = {
|
||||
viewModel.reset("Untitled.md")
|
||||
},
|
||||
enableWideLayout = enableWideLayout,
|
||||
enableReadability = enableReadability,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
fileName: String = "Untitled.md",
|
||||
markdown: String = "",
|
||||
setMarkdown: (String) -> Unit = {},
|
||||
message: String? = null,
|
||||
dismissMessage: () -> Unit = {},
|
||||
alert: AlertDialogModel? = null,
|
||||
dismissAlert: () -> Unit = {},
|
||||
navigate: (Route) -> Unit = {},
|
||||
navigateBack: () -> Unit = {},
|
||||
loadFile: (Uri?) -> Unit = {},
|
||||
saveFile: (URI?) -> Unit = {},
|
||||
saveCallback: (() -> Unit)? = null,
|
||||
reset: () -> Unit = {},
|
||||
enableWideLayout: Boolean = false,
|
||||
enableReadability: Boolean = false
|
||||
) {
|
||||
var lockSwiping by remember { mutableStateOf(false) }
|
||||
val openFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
loadFile(it)
|
||||
}
|
||||
val saveFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
|
||||
it?.let { uri -> saveFile(URI.create(uri.toString())) }
|
||||
}
|
||||
saveCallback?.let { callback ->
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
|
||||
it?.let { uri -> saveFile(URI.create(uri.toString())) }
|
||||
callback()
|
||||
}
|
||||
LaunchedEffect(callback) {
|
||||
launcher.launch(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
val snackBarState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
snackBarState.showSnackbar(it)
|
||||
dismissMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alert?.let {
|
||||
AlertDialog(
|
||||
onDismissRequest = { errorMessage = null },
|
||||
onDismissRequest = dismissAlert,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
prompt.confirm()
|
||||
promptEffect = null
|
||||
}) {
|
||||
Text("Yes")
|
||||
TextButton(onClick = it.confirmButton.onClick) {
|
||||
Text(it.confirmButton.text)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
prompt.cancel()
|
||||
promptEffect = null
|
||||
}) {
|
||||
Text("No")
|
||||
it.dismissButton?.let { dismissButton ->
|
||||
TextButton(onClick = dismissButton.onClick) {
|
||||
Text(dismissButton.text)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = { Text(prompt.text) }
|
||||
text = { Text(it.text) }
|
||||
)
|
||||
}
|
||||
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
|
||||
Scaffold(topBar = {
|
||||
|
||||
MarkdownNavigationDrawer(navigate) { drawerState ->
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val context = LocalContext.current
|
||||
MarkdownTopAppBar(title = fileName,
|
||||
backAsUp = false,
|
||||
navController = navController,
|
||||
goBack = navigateBack,
|
||||
drawerState = drawerState,
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
|
||||
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
|
||||
shareIntent.type = "text/plain"
|
||||
startActivity(
|
||||
context, Intent.createChooser(
|
||||
|
@ -193,9 +238,7 @@ fun MainScreen(
|
|||
onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(text = { Text("New") }, onClick = {
|
||||
menuExpanded = false
|
||||
coroutineScope.launch {
|
||||
viewModel.reset("Untitled.md")
|
||||
}
|
||||
reset()
|
||||
})
|
||||
DropdownMenuItem(text = { Text("Open") }, onClick = {
|
||||
menuExpanded = false
|
||||
|
@ -203,9 +246,7 @@ fun MainScreen(
|
|||
})
|
||||
DropdownMenuItem(text = { Text("Save") }, onClick = {
|
||||
menuExpanded = false
|
||||
coroutineScope.launch {
|
||||
viewModel.save()
|
||||
}
|
||||
saveFile(null)
|
||||
})
|
||||
DropdownMenuItem(text = { Text("Save as…") },
|
||||
onClick = {
|
||||
|
@ -227,28 +268,44 @@ fun MainScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}) { paddingValues ->
|
||||
val markdown by viewModel.markdown.collectAsState()
|
||||
val (textFieldValue, setTextFieldValue) = remember(clearText) {
|
||||
val annotatedMarkdown = if (enableReadability) {
|
||||
markdown.annotateReadability()
|
||||
} else {
|
||||
AnnotatedString(markdown)
|
||||
}
|
||||
mutableStateOf(TextFieldValue(annotatedMarkdown))
|
||||
}
|
||||
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
|
||||
setTextFieldValue(it)
|
||||
coroutineScope.launch {
|
||||
viewModel.updateMarkdown(it.text)
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
modifier = Modifier.imePadding(),
|
||||
hostState = snackBarState
|
||||
) {
|
||||
Snackbar(it)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (enableWideLayout) {
|
||||
Row(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
MarkdownTextField(modifier = Modifier.fillMaxHeight().weight(1f), textFieldValue, setTextFieldAndViewModelValues)
|
||||
Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colorScheme.primary))
|
||||
MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), markdown, darkMode)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
MarkdownTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
enableReadability = enableReadability
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
MarkdownPreview(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
markdown = markdown
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
|
@ -257,12 +314,10 @@ fun MainScreen(
|
|||
.padding(paddingValues)
|
||||
) {
|
||||
TabbedMarkdownEditor(
|
||||
coroutineScope,
|
||||
lockSwiping,
|
||||
textFieldValue,
|
||||
setTextFieldAndViewModelValues,
|
||||
markdown,
|
||||
darkMode
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
lockSwiping = lockSwiping,
|
||||
enableReadability = enableReadability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -273,13 +328,12 @@ fun MainScreen(
|
|||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun TabbedMarkdownEditor(
|
||||
coroutineScope: CoroutineScope,
|
||||
lockSwiping: Boolean,
|
||||
textFieldValue: TextFieldValue,
|
||||
setTextFieldAndViewModelValues: (TextFieldValue) -> Unit,
|
||||
markdown: String,
|
||||
darkMode: String
|
||||
setMarkdown: (String) -> Unit,
|
||||
lockSwiping: Boolean,
|
||||
enableReadability: Boolean
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { 2 }
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
Tab(text = { Text("Edit") },
|
||||
|
@ -301,9 +355,14 @@ private fun TabbedMarkdownEditor(
|
|||
}
|
||||
}
|
||||
if (page == 0) {
|
||||
MarkdownTextField(modifier = Modifier.fillMaxSize(), textFieldValue, setTextFieldAndViewModelValues)
|
||||
MarkdownTextField(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
enableReadability = enableReadability
|
||||
)
|
||||
} else {
|
||||
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode)
|
||||
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -371,7 +430,7 @@ fun MarkdownNavigationDrawer(
|
|||
@Composable
|
||||
fun MarkdownTopAppBar(
|
||||
title: String,
|
||||
navController: NavController,
|
||||
goBack: () -> Unit,
|
||||
backAsUp: Boolean = true,
|
||||
drawerState: DrawerState? = null,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null
|
||||
|
@ -382,7 +441,7 @@ fun MarkdownTopAppBar(
|
|||
}, navigationIcon = {
|
||||
val (icon, contentDescription, onClick) = remember {
|
||||
if (backAsUp) {
|
||||
Triple(Icons.Default.ArrowBack, "Go Back") { navController.popBackStack() }
|
||||
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
|
||||
} else {
|
||||
Triple(
|
||||
Icons.Default.Menu, "Main Menu"
|
||||
|
@ -406,10 +465,26 @@ fun MarkdownTopAppBar(
|
|||
@Composable
|
||||
fun MarkdownTextField(
|
||||
modifier: Modifier,
|
||||
textFieldValue: TextFieldValue,
|
||||
setTextFieldValue: (TextFieldValue) -> Unit,
|
||||
markdown: String,
|
||||
setMarkdown: (String) -> Unit,
|
||||
enableReadability: Boolean = false
|
||||
) {
|
||||
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
|
||||
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
|
||||
val textFieldValue = remember(markdown) {
|
||||
val annotatedMarkdown = if (enableReadability) {
|
||||
markdown.annotateReadability()
|
||||
} else {
|
||||
AnnotatedString(markdown)
|
||||
}
|
||||
TextFieldValue(annotatedMarkdown, selection, composition)
|
||||
}
|
||||
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
|
||||
setSelection(it.selection)
|
||||
setComposition(it.composition)
|
||||
setMarkdown(it.text)
|
||||
}
|
||||
|
||||
TextField(
|
||||
modifier = modifier.imePadding(),
|
||||
colors = TextFieldDefaults.colors(
|
||||
|
@ -421,15 +496,7 @@ fun MarkdownTextField(
|
|||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
setTextFieldValue(
|
||||
if (enableReadability) {
|
||||
it.copy(annotatedString = it.text.annotateReadability())
|
||||
} else {
|
||||
it
|
||||
}
|
||||
)
|
||||
},
|
||||
onValueChange = setTextFieldAndViewModelValues,
|
||||
placeholder = {
|
||||
Text("Markdown here…")
|
||||
},
|
||||
|
@ -440,3 +507,8 @@ fun MarkdownTextField(
|
|||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <P> MarkdownViewModel.collectAsState(prop: KProperty1<EditorState, P>, initial: P): State<P> =
|
||||
state.map { prop.get(it) }
|
||||
.collectAsState(initial)
|
|
@ -22,7 +22,7 @@ fun MarkdownInfoScreen(
|
|||
topBar = {
|
||||
MarkdownTopAppBar(
|
||||
title = title,
|
||||
navController = navController,
|
||||
goBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
@ -35,8 +35,7 @@ fun MarkdownInfoScreen(
|
|||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
markdown = markdown,
|
||||
"Auto"
|
||||
markdown = markdown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.ui
|
|||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
@ -24,9 +25,9 @@ private const val container = "<main id=\"content\"></main>"
|
|||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
@Composable
|
||||
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) {
|
||||
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
|
||||
val materialColors = MaterialTheme.colorScheme
|
||||
val style = remember(darkMode) {
|
||||
val style = remember(isSystemInDarkTheme()) {
|
||||
"""body {
|
||||
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
|
||||
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|
||||
|
|
|
@ -35,7 +35,7 @@ import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
|||
@Composable
|
||||
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
|
||||
Scaffold(topBar = {
|
||||
MarkdownTopAppBar(title = "Settings", navController = navController)
|
||||
MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
|
||||
}) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
|
@ -37,7 +37,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks
|
|||
@Composable
|
||||
fun SupportScreen(navController: NavController) {
|
||||
Scaffold(topBar = {
|
||||
MarkdownTopAppBar(title = "Support SimpleMarkdown", navController = navController)
|
||||
MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() })
|
||||
}) { paddingValues ->
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.wbrawner.simplemarkdown.utility
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -32,14 +34,21 @@ interface FileHelper {
|
|||
}
|
||||
|
||||
class AndroidFileHelper(private val context: Context) : FileHelper {
|
||||
override val defaultDirectory: File = context.filesDir
|
||||
override val defaultDirectory: File = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
|
||||
?: context.filesDir
|
||||
|
||||
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val uri = source.toString().toUri()
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// We weren't granted the persistent read/write permission for this file.
|
||||
// TODO: Return whether or not we got the persistent permission in order to determine
|
||||
// whether or not we should show this file in the recent files section
|
||||
}
|
||||
context.contentResolver.openFileDescriptor(uri, "r")
|
||||
?.use {
|
||||
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
|
||||
|
|
|
@ -1,29 +1,39 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import com.wbrawner.simplemarkdown.utility.FileHelper
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
class FakeFileHelper : FileHelper {
|
||||
override val defaultDirectory: File
|
||||
get() = File.createTempFile("sm", null)
|
||||
override val defaultDirectory: File by lazy {
|
||||
File.createTempFile("simplemarkdown", null)
|
||||
.apply {
|
||||
delete()
|
||||
mkdir()
|
||||
}
|
||||
}
|
||||
|
||||
var file: Pair<String, String> = "Untitled.md" to "This is a test file"
|
||||
var openedUris = ArrayDeque<URI>()
|
||||
var savedData = ArrayDeque<SavedData>()
|
||||
@Volatile
|
||||
var errorOnOpen: Boolean = false
|
||||
@Volatile
|
||||
var errorOnSave: Boolean = false
|
||||
|
||||
override suspend fun open(source: URI): Pair<String, String> {
|
||||
delay(1000)
|
||||
if (errorOnOpen) error("errorOnOpen set to true")
|
||||
openedUris.addLast(source)
|
||||
return file
|
||||
}
|
||||
|
||||
override suspend fun save(destination: URI, content: String): String {
|
||||
delay(1000)
|
||||
if (errorOnSave) error("errorOnSave set to true")
|
||||
savedData.addLast(SavedData(destination, content))
|
||||
return file.first
|
||||
return destination.path.substringAfterLast("/")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
|
||||
class FakePreferenceHelper: PreferenceHelper {
|
||||
val preferences = mutableMapOf<Preference, Any?>()
|
||||
override fun get(preference: Preference): Any? = preferences[preference]
|
||||
override fun get(preference: Preference): Any? = preferences[preference]?: preference.default
|
||||
|
||||
override fun set(preference: Preference, value: Any?) {
|
||||
preferences[preference] = value
|
||||
|
|
|
@ -1,56 +1,296 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestResult
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.Deque
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
|
||||
class MarkdownViewModelTest {
|
||||
private lateinit var fileHelper: FakeFileHelper
|
||||
private lateinit var preferenceHelper: FakePreferenceHelper
|
||||
private lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||
private lateinit var viewModel: MarkdownViewModel
|
||||
private lateinit var viewModelScope: TestScope
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Before
|
||||
fun setup() {
|
||||
Timber.plant(object: Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
println("$tag/$priority: $message")
|
||||
t?.printStackTrace()
|
||||
}
|
||||
})
|
||||
val scheduler = StandardTestDispatcher()
|
||||
Dispatchers.setMain(scheduler)
|
||||
viewModelScope = TestScope(scheduler)
|
||||
fileHelper = FakeFileHelper()
|
||||
preferenceHelper = FakePreferenceHelper()
|
||||
viewModel = MarkdownViewModel(fileHelper, preferenceHelper)
|
||||
viewModelFactory = MarkdownViewModel.factory(fileHelper, preferenceHelper)
|
||||
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
|
||||
viewModelScope.advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMarkdownUpdate() = runBlocking {
|
||||
assertEquals("", viewModel.markdown.value)
|
||||
fun testMarkdownUpdate() = runTest {
|
||||
assertEquals("", viewModel.state.value.markdown)
|
||||
viewModel.updateMarkdown("Updated content")
|
||||
assertEquals("Updated content", viewModel.markdown.value)
|
||||
assertEquals("Updated content", viewModel.state.value.markdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithNoPathAndNoAutosaveUri() = runBlocking {
|
||||
fun testLoadWithNoPathAndNoAutosaveUri() = runTest {
|
||||
viewModel.load(null)
|
||||
assertTrue(fileHelper.openedUris.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithNoPathAndAutosaveUri() = runBlocking {
|
||||
fun testAutoLoad() = runTest {
|
||||
val uri = URI.create("file:///home/user/Untitled.md")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
|
||||
viewModel.load(null)
|
||||
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
|
||||
viewModelScope.advanceUntilIdle()
|
||||
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
||||
val (fileName, contents) = fileHelper.file
|
||||
assertEquals(fileName, viewModel.fileName.value)
|
||||
assertEquals(contents, viewModel.markdown.value)
|
||||
assertEquals(fileName, viewModel.state.value.fileName)
|
||||
assertEquals(contents, viewModel.state.value.markdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithPath() = runBlocking {
|
||||
fun testLoadWithPath() = runTest {
|
||||
val uri = URI.create("file:///home/user/Untitled.md")
|
||||
viewModel.load(uri.toString())
|
||||
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
||||
val (fileName, contents) = fileHelper.file
|
||||
assertEquals(fileName, viewModel.fileName.value)
|
||||
assertEquals(contents, viewModel.markdown.value)
|
||||
assertEquals(fileName, viewModel.state.value.fileName)
|
||||
assertEquals(contents, viewModel.state.value.markdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithEmptyPath() = runTest {
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = ""
|
||||
viewModel.load("")
|
||||
assertEquals(null, preferenceHelper[Preference.AUTOSAVE_URI])
|
||||
assertTrue(fileHelper.openedUris.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithInvalidUri() = runTest {
|
||||
viewModel.load(":/:/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLoadWithError() = runTest {
|
||||
fileHelper.errorOnOpen = true
|
||||
val uri = URI.create("file:///home/user/Untitled.md")
|
||||
viewModel.load(uri.toString())
|
||||
assertNotNull(viewModel.state.value.alert)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveWithNullPath() = runTest {
|
||||
assertFalse(viewModel.save(null, false))
|
||||
assertNull(viewModel.state.value.alert)
|
||||
assertNull(viewModel.state.value.saveCallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveWithNullPathAndPrompt() = runTest {
|
||||
assertFalse(viewModel.save(null, true))
|
||||
assertNotNull(viewModel.state.value.saveCallback)
|
||||
viewModel.state.value.saveCallback!!.invoke()
|
||||
assertNull(viewModel.state.value.saveCallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveWithValidPath() = runTest {
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
val testMarkdown = "# Test"
|
||||
viewModel.updateMarkdown(testMarkdown)
|
||||
assertEquals(testMarkdown, viewModel.state.value.markdown)
|
||||
assertTrue(viewModel.save(uri))
|
||||
assertEquals("Saved.md", viewModel.state.value.fileName)
|
||||
assertEquals(uri, fileHelper.savedData.last().uri)
|
||||
assertEquals(testMarkdown, fileHelper.savedData.last().content)
|
||||
assertEquals(uri, preferenceHelper[Preference.AUTOSAVE_URI])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveWithException() = runTest {
|
||||
val uri = URI.create("file:///home/user/Untitled.md")
|
||||
val testMarkdown = "# Test"
|
||||
viewModel.updateMarkdown(testMarkdown)
|
||||
assertEquals(testMarkdown, viewModel.state.value.markdown)
|
||||
fileHelper.errorOnSave = true
|
||||
assertNull(viewModel.state.value.alert)
|
||||
assertFalse(viewModel.save(uri))
|
||||
assertNotNull(viewModel.state.value.alert)
|
||||
requireNotNull(viewModel.state.value.alert?.confirmButton?.onClick).invoke()
|
||||
assertNull(viewModel.state.value.alert)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResetWithSavedChanges() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
assertTrue(viewModel.save(uri))
|
||||
assertFalse(viewModel.state.value.dirty)
|
||||
assertNull(viewModel.state.value.alert)
|
||||
viewModel.reset("New.md")
|
||||
assertNull(viewModel.state.value.alert)
|
||||
with(viewModel.state.value) {
|
||||
assertEquals("New.md", fileName)
|
||||
assertEquals("", markdown)
|
||||
assertNull(path)
|
||||
assertNull(saveCallback)
|
||||
assertNull(alert)
|
||||
assertFalse(dirty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResetWithUnsavedChanges() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
assertNull(viewModel.state.value.alert)
|
||||
viewModel.reset("Untitled.md")
|
||||
with(viewModel.state.value.alert) {
|
||||
assertNotNull(this)
|
||||
requireNotNull(this)
|
||||
val onClick = dismissButton?.onClick
|
||||
assertNotNull(onClick)
|
||||
requireNotNull(onClick)
|
||||
onClick.invoke()
|
||||
}
|
||||
assertEquals(viewModel.state.value, EditorState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResetWithUnsavedChangesAndPrompt() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
assertNull(viewModel.state.value.alert)
|
||||
viewModel.reset("Untitled.md")
|
||||
assertNull(viewModel.state.value.saveCallback)
|
||||
with(viewModel.state.value.alert) {
|
||||
assertNotNull(this)
|
||||
requireNotNull(this)
|
||||
confirmButton.onClick.invoke()
|
||||
}
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
viewModel.save(uri)
|
||||
assertNotNull(viewModel.state.value.saveCallback)
|
||||
requireNotNull(viewModel.state.value.saveCallback).invoke()
|
||||
assertEquals(viewModel.state.value, EditorState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testForceResetWithUnsavedChanges() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
assertTrue(viewModel.save(uri))
|
||||
assertFalse(viewModel.state.value.dirty)
|
||||
viewModel.updateMarkdown("# Test\n\nDirty changes")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
assertNull(viewModel.state.value.alert)
|
||||
viewModel.reset("Unsaved.md", true)
|
||||
assertNull(viewModel.state.value.alert)
|
||||
with(viewModel.state.value) {
|
||||
assertEquals("Unsaved.md", fileName)
|
||||
assertEquals("", markdown)
|
||||
assertNull(path)
|
||||
assertNull(saveCallback)
|
||||
assertNull(alert)
|
||||
assertFalse(dirty)
|
||||
}
|
||||
assertNull(preferenceHelper[Preference.AUTOSAVE_URI])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAutosaveWithPreferenceDisabled() = runTest {
|
||||
preferenceHelper[Preference.AUTOSAVE_ENABLED] = false
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
viewModel.autosave()
|
||||
assertEquals(0, fileHelper.savedData.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAutosaveWithNoNewData() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
assertTrue(viewModel.save(uri))
|
||||
assertEquals(1, fileHelper.savedData.count())
|
||||
assertFalse(viewModel.state.value.dirty)
|
||||
viewModel.autosave()
|
||||
assertEquals(1, fileHelper.savedData.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAutosaveWithSaveInProgress() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
val coroutineScope = TestScope(StandardTestDispatcher())
|
||||
coroutineScope.launch {
|
||||
assertTrue(viewModel.save(uri))
|
||||
}
|
||||
coroutineScope.advanceTimeBy(500)
|
||||
assertEquals(0, fileHelper.savedData.count())
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
viewModel.autosave()
|
||||
assertEquals(0, fileHelper.savedData.count())
|
||||
coroutineScope.advanceTimeBy(1000)
|
||||
assertEquals(1, fileHelper.savedData.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAutosaveWithUnknownUri() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
viewModel.autosave()
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
assertEquals(1, fileHelper.savedData.count())
|
||||
assertEquals(
|
||||
File(fileHelper.defaultDirectory, "Untitled.md").toURI(),
|
||||
fileHelper.savedData.first().uri
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAutosaveWithKnownUri() = runTest {
|
||||
viewModel.updateMarkdown("# Test")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
val uri = URI.create("file:///home/user/Saved.md")
|
||||
assertTrue(viewModel.save(uri))
|
||||
assertEquals(1, fileHelper.savedData.count())
|
||||
assertFalse(viewModel.state.value.dirty)
|
||||
viewModel.updateMarkdown("# Test\n\nDirty changes")
|
||||
assertTrue(viewModel.state.value.dirty)
|
||||
viewModel.autosave()
|
||||
assertEquals(2, fileHelper.savedData.count())
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue