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")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
val coroutinesVersion = "1.7.1"
|
val coroutinesVersion = "1.7.1"
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
|
||||||
val lifecycleVersion = "2.2.0"
|
val lifecycleVersion = "2.2.0"
|
||||||
implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion")
|
implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion")
|
||||||
kapt("androidx.lifecycle:lifecycle-common-java8:$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.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.material.icons.Icons
|
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.Edit
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.PrivacyTip
|
import androidx.compose.material.icons.filled.PrivacyTip
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
@ -128,8 +128,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
|
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
|
||||||
enableAutosave = autosaveEnabled,
|
enableAutosave = autosaveEnabled,
|
||||||
enableReadability = readabilityEnabled,
|
enableReadability = readabilityEnabled
|
||||||
darkMode = darkModePreference
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Route.SETTINGS.path) {
|
composable(Route.SETTINGS.path) {
|
||||||
|
@ -173,7 +172,7 @@ enum class Route(
|
||||||
EDITOR("/", "Editor", Icons.Default.Edit),
|
EDITOR("/", "Editor", Icons.Default.Edit),
|
||||||
SETTINGS("/settings", "Settings", Icons.Default.Settings),
|
SETTINGS("/settings", "Settings", Icons.Default.Settings),
|
||||||
SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite),
|
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),
|
ABOUT("/about", "About", Icons.Default.Info),
|
||||||
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
|
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.FileHelper
|
||||||
import com.wbrawner.simplemarkdown.utility.Preference
|
import com.wbrawner.simplemarkdown.utility.Preference
|
||||||
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
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(
|
class MarkdownViewModel(
|
||||||
private val fileHelper: FileHelper,
|
private val fileHelper: FileHelper,
|
||||||
private val preferenceHelper: PreferenceHelper
|
private val preferenceHelper: PreferenceHelper
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _fileName = MutableStateFlow("Untitled.md")
|
private val _state = MutableStateFlow(EditorState())
|
||||||
val fileName = _fileName.asStateFlow()
|
val state = _state.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 saveMutex = Mutex()
|
private val saveMutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -39,71 +43,114 @@ class MarkdownViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateMarkdown(markdown: String?) {
|
fun updateMarkdown(markdown: String?) {
|
||||||
this@MarkdownViewModel._markdown.emit(markdown ?: "")
|
_state.value = _state.value.copy(
|
||||||
isDirty.set(true)
|
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?) {
|
suspend fun load(loadPath: String?) {
|
||||||
if (loadPath.isNullOrBlank()) {
|
saveMutex.withLock {
|
||||||
Timber.i("No URI provided to load, attempting to load last autosaved file")
|
val actualLoadPath = loadPath
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI]
|
?.ifBlank { null }
|
||||||
?.let {
|
?: preferenceHelper[Preference.AUTOSAVE_URI]
|
||||||
val autosaveUri = it as? String
|
?.let {
|
||||||
if (autosaveUri.isNullOrBlank()) {
|
val autosaveUri = it as? String
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
if (autosaveUri.isNullOrBlank()) {
|
||||||
} else {
|
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||||
Timber.d("Using uri from shared preferences: $it")
|
null
|
||||||
load(autosaveUri)
|
} else {
|
||||||
}
|
Timber.d("Using uri from shared preferences: $it")
|
||||||
}
|
autosaveUri
|
||||||
return
|
}
|
||||||
}
|
} ?: return
|
||||||
try {
|
Timber.d("Loading file at $actualLoadPath")
|
||||||
val uri = URI.create(loadPath)
|
try {
|
||||||
fileHelper.open(uri)
|
val uri = URI.create(actualLoadPath)
|
||||||
?.let { (name, content) ->
|
fileHelper.open(uri)
|
||||||
path.emit(uri)
|
?.let { (name, content) ->
|
||||||
_effects.emit(Effect.ClearText)
|
_state.value = _state.value.copy(
|
||||||
_fileName.emit(name)
|
path = uri,
|
||||||
_markdown.emit(content)
|
fileName = name,
|
||||||
isDirty.set(false)
|
markdown = content,
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath
|
dirty = false,
|
||||||
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath"))
|
toast = "Successfully loaded $name"
|
||||||
} catch (e: Exception) {
|
)
|
||||||
Timber.e(e, "Failed to open file at path: $loadPath")
|
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
|
||||||
|
} ?: throw IllegalStateException("Opened file was null")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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 {
|
suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
|
||||||
return saveMutex.withLock {
|
saveMutex.withLock {
|
||||||
if (savePath == null) {
|
val actualSavePath = savePath
|
||||||
Timber.w("Attempted to save file with empty path")
|
?: _state.value.path
|
||||||
if (promptSavePath) {
|
?: run {
|
||||||
_effects.emit(Effect.OpenSaveDialog {})
|
Timber.w("Attempted to save file with empty path")
|
||||||
|
if (interactive) {
|
||||||
|
_state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
|
||||||
|
}
|
||||||
|
return@withLock false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
val name = fileHelper.save(savePath, markdown.value)
|
Timber.i("Saving file to $actualSavePath...")
|
||||||
_fileName.emit(name)
|
val currentState = _state.value
|
||||||
path.emit(savePath)
|
val name = fileHelper.save(actualSavePath, currentState.markdown)
|
||||||
isDirty.set(false)
|
_state.value = currentState.copy(
|
||||||
Timber.i("Saved file ${fileName.value} to uri $savePath")
|
fileName = name,
|
||||||
Timber.i("Persisting autosave uri in shared prefs: $savePath")
|
path = actualSavePath,
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI] = savePath
|
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
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val message = "Failed to save file to $savePath"
|
val message = "Failed to save file to $actualSavePath"
|
||||||
Timber.e(e, message)
|
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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun autosave() {
|
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")
|
Timber.d("Ignoring autosave as contents haven't changed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -111,52 +158,59 @@ class MarkdownViewModel(
|
||||||
Timber.i("Ignoring autosave since manual save is already in progress")
|
Timber.i("Ignoring autosave since manual save is already in progress")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
|
Timber.d("Performing autosave")
|
||||||
if (!isAutoSaveEnabled) {
|
if (!save(interactive = false)) {
|
||||||
Timber.i("Ignoring autosave as autosave not enabled")
|
withContext(Dispatchers.IO) {
|
||||||
return
|
// 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
|
||||||
if (!save(promptSavePath = false)) {
|
// this case, we need to just save to internal file storage so that we can recover
|
||||||
// The user has left the app, with autosave enabled, and we don't already have a
|
val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
|
||||||
// Uri for them or for some reason we were unable to save to the original Uri. In
|
Timber.i("No cached uri for autosave, saving to $file instead")
|
||||||
// this case, we need to just save to internal file storage so that we can recover
|
// Here we call the fileHelper directly so that the file is still registered as dirty.
|
||||||
val file = File(fileHelper.defaultDirectory, fileName.value).toURI()
|
// This prevents the user from ending up in a scenario where they've autosaved the file
|
||||||
Timber.i("No cached uri for autosave, saving to $file instead")
|
// to an internal storage location, thus marking it as not dirty, but no longer able to
|
||||||
save(file)
|
// 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")
|
Timber.i("Resetting view model to default state")
|
||||||
if (!force && isDirty.get()) {
|
if (!force && _state.value.dirty) {
|
||||||
_effects.emit(Effect.Prompt(
|
_state.value = _state.value.copy(alert = AlertDialogModel(
|
||||||
"Would you like to save your changes?",
|
text = "Would you like to save your changes?",
|
||||||
confirm = {
|
confirmButton = AlertDialogModel.ButtonModel(
|
||||||
viewModelScope.launch {
|
text = "Yes",
|
||||||
_effects.emit(Effect.OpenSaveDialog {
|
onClick = {
|
||||||
reset(untitledFileName, false)
|
_state.value = _state.value.copy(
|
||||||
})
|
saveCallback = {
|
||||||
|
reset(untitledFileName, false)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
),
|
||||||
cancel = {
|
dismissButton = AlertDialogModel.ButtonModel(
|
||||||
viewModelScope.launch {
|
text = "No",
|
||||||
|
onClick = {
|
||||||
reset(untitledFileName, true)
|
reset(untitledFileName, true)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_fileName.emit(untitledFileName)
|
_state.value = EditorState(fileName = untitledFileName)
|
||||||
_markdown.emit("")
|
|
||||||
path.emit(null)
|
|
||||||
_effects.emit(Effect.ClearText)
|
|
||||||
isDirty.set(false)
|
|
||||||
Timber.i("Removing autosave uri from shared prefs")
|
Timber.i("Removing autosave uri from shared prefs")
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(
|
override fun <T : ViewModel> create(
|
||||||
modelClass: Class<T>,
|
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
|
package com.wbrawner.simplemarkdown.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Share
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
import androidx.compose.material3.Scaffold
|
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.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
@ -47,11 +51,9 @@ import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberDrawerState
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.res.painterResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
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.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.wbrawner.simplemarkdown.AlertDialogModel
|
||||||
|
import com.wbrawner.simplemarkdown.EditorState
|
||||||
import com.wbrawner.simplemarkdown.MarkdownViewModel
|
import com.wbrawner.simplemarkdown.MarkdownViewModel
|
||||||
import com.wbrawner.simplemarkdown.R
|
import com.wbrawner.simplemarkdown.R
|
||||||
import com.wbrawner.simplemarkdown.Route
|
import com.wbrawner.simplemarkdown.Route
|
||||||
import com.wbrawner.simplemarkdown.model.Readability
|
import com.wbrawner.simplemarkdown.model.Readability
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import kotlin.reflect.KProperty1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
|
@ -91,164 +96,216 @@ fun MainScreen(
|
||||||
viewModel: MarkdownViewModel,
|
viewModel: MarkdownViewModel,
|
||||||
enableWideLayout: Boolean,
|
enableWideLayout: Boolean,
|
||||||
enableAutosave: Boolean,
|
enableAutosave: Boolean,
|
||||||
enableReadability: Boolean,
|
enableReadability: Boolean
|
||||||
darkMode: String,
|
|
||||||
) {
|
) {
|
||||||
var lockSwiping by remember { mutableStateOf(false) }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val fileName by viewModel.fileName.collectAsState()
|
val fileName by viewModel.collectAsState(EditorState::fileName, "")
|
||||||
val openFileLauncher =
|
val markdown by viewModel.collectAsState(EditorState::markdown, "")
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
val alert by viewModel.collectAsState(EditorState::alert, null)
|
||||||
coroutineScope.launch {
|
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(enableAutosave) {
|
LaunchedEffect(enableAutosave) {
|
||||||
if (!enableAutosave) return@LaunchedEffect
|
if (!enableAutosave) return@LaunchedEffect
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(30_000)
|
delay(500)
|
||||||
viewModel.autosave()
|
viewModel.autosave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
errorMessage?.let { message ->
|
val toast by viewModel.collectAsState(EditorState::toast, null)
|
||||||
AlertDialog(
|
MainScreen(
|
||||||
onDismissRequest = { errorMessage = null },
|
fileName = fileName,
|
||||||
confirmButton = {
|
markdown = markdown,
|
||||||
TextButton(onClick = { errorMessage = null }) {
|
setMarkdown = viewModel::updateMarkdown,
|
||||||
Text("OK")
|
message = toast,
|
||||||
}
|
dismissMessage = viewModel::dismissToast,
|
||||||
},
|
alert = alert,
|
||||||
text = { Text(message) }
|
dismissAlert = viewModel::dismissAlert,
|
||||||
)
|
navigate = {
|
||||||
|
navController.navigate(it.path)
|
||||||
|
},
|
||||||
|
navigateBack = { navController.popBackStack() },
|
||||||
|
loadFile = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
viewModel.load(it.toString())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveFile = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
viewModel.save(it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
promptEffect?.let { prompt ->
|
|
||||||
|
val snackBarState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(message) {
|
||||||
|
message?.let {
|
||||||
|
snackBarState.showSnackbar(it)
|
||||||
|
dismissMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert?.let {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { errorMessage = null },
|
onDismissRequest = dismissAlert,
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = it.confirmButton.onClick) {
|
||||||
prompt.confirm()
|
Text(it.confirmButton.text)
|
||||||
promptEffect = null
|
|
||||||
}) {
|
|
||||||
Text("Yes")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = {
|
it.dismissButton?.let { dismissButton ->
|
||||||
prompt.cancel()
|
TextButton(onClick = dismissButton.onClick) {
|
||||||
promptEffect = null
|
Text(dismissButton.text)
|
||||||
}) {
|
}
|
||||||
Text("No")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
text = { Text(prompt.text) }
|
text = { Text(it.text) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
|
|
||||||
Scaffold(topBar = {
|
MarkdownNavigationDrawer(navigate) { drawerState ->
|
||||||
val context = LocalContext.current
|
Scaffold(
|
||||||
MarkdownTopAppBar(title = fileName,
|
topBar = {
|
||||||
backAsUp = false,
|
val context = LocalContext.current
|
||||||
navController = navController,
|
MarkdownTopAppBar(title = fileName,
|
||||||
drawerState = drawerState,
|
backAsUp = false,
|
||||||
actions = {
|
goBack = navigateBack,
|
||||||
IconButton(onClick = {
|
drawerState = drawerState,
|
||||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
actions = {
|
||||||
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
|
IconButton(onClick = {
|
||||||
shareIntent.type = "text/plain"
|
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||||
startActivity(
|
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
|
||||||
context, Intent.createChooser(
|
shareIntent.type = "text/plain"
|
||||||
shareIntent, context.getString(R.string.share_file)
|
startActivity(
|
||||||
), null
|
context, Intent.createChooser(
|
||||||
)
|
shareIntent, context.getString(R.string.share_file)
|
||||||
}) {
|
), null
|
||||||
Icon(imageVector = Icons.Default.Share, contentDescription = "Share")
|
)
|
||||||
}
|
}) {
|
||||||
Box {
|
Icon(imageVector = Icons.Default.Share, contentDescription = "Share")
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
|
||||||
IconButton(onClick = { menuExpanded = true }) {
|
|
||||||
Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
|
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menuExpanded,
|
Box {
|
||||||
onDismissRequest = { menuExpanded = false }) {
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
DropdownMenuItem(text = { Text("New") }, onClick = {
|
IconButton(onClick = { menuExpanded = true }) {
|
||||||
menuExpanded = false
|
Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
|
||||||
coroutineScope.launch {
|
}
|
||||||
viewModel.reset("Untitled.md")
|
DropdownMenu(expanded = menuExpanded,
|
||||||
}
|
onDismissRequest = { menuExpanded = false }) {
|
||||||
})
|
DropdownMenuItem(text = { Text("New") }, onClick = {
|
||||||
DropdownMenuItem(text = { Text("Open") }, onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
openFileLauncher.launch(arrayOf("text/*"))
|
|
||||||
})
|
|
||||||
DropdownMenuItem(text = { Text("Save") }, onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.save()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
DropdownMenuItem(text = { Text("Save as…") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
saveFileLauncher.launch(fileName)
|
reset()
|
||||||
})
|
})
|
||||||
if (!enableWideLayout) {
|
DropdownMenuItem(text = { Text("Open") }, onClick = {
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Lock Swiping")
|
|
||||||
Checkbox(
|
|
||||||
checked = lockSwiping,
|
|
||||||
onCheckedChange = { lockSwiping = !lockSwiping })
|
|
||||||
}
|
|
||||||
}, onClick = {
|
|
||||||
lockSwiping = !lockSwiping
|
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
|
openFileLauncher.launch(arrayOf("text/*"))
|
||||||
})
|
})
|
||||||
|
DropdownMenuItem(text = { Text("Save") }, onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
saveFile(null)
|
||||||
|
})
|
||||||
|
DropdownMenuItem(text = { Text("Save as…") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
saveFileLauncher.launch(fileName)
|
||||||
|
})
|
||||||
|
if (!enableWideLayout) {
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Lock Swiping")
|
||||||
|
Checkbox(
|
||||||
|
checked = lockSwiping,
|
||||||
|
onCheckedChange = { lockSwiping = !lockSwiping })
|
||||||
|
}
|
||||||
|
}, onClick = {
|
||||||
|
lockSwiping = !lockSwiping
|
||||||
|
menuExpanded = false
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}) { paddingValues ->
|
},
|
||||||
val markdown by viewModel.markdown.collectAsState()
|
snackbarHost = {
|
||||||
val (textFieldValue, setTextFieldValue) = remember(clearText) {
|
SnackbarHost(
|
||||||
val annotatedMarkdown = if (enableReadability) {
|
modifier = Modifier.imePadding(),
|
||||||
markdown.annotateReadability()
|
hostState = snackBarState
|
||||||
} else {
|
) {
|
||||||
AnnotatedString(markdown)
|
Snackbar(it)
|
||||||
}
|
|
||||||
mutableStateOf(TextFieldValue(annotatedMarkdown))
|
|
||||||
}
|
|
||||||
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
|
|
||||||
setTextFieldValue(it)
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.updateMarkdown(it.text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) { paddingValues ->
|
||||||
if (enableWideLayout) {
|
if (enableWideLayout) {
|
||||||
Row(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
Row(
|
||||||
MarkdownTextField(modifier = Modifier.fillMaxHeight().weight(1f), textFieldValue, setTextFieldAndViewModelValues)
|
modifier = Modifier
|
||||||
Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colorScheme.primary))
|
.fillMaxSize()
|
||||||
MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), markdown, darkMode)
|
.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 {
|
} else {
|
||||||
Column(
|
Column(
|
||||||
|
@ -257,12 +314,10 @@ fun MainScreen(
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
TabbedMarkdownEditor(
|
TabbedMarkdownEditor(
|
||||||
coroutineScope,
|
markdown = markdown,
|
||||||
lockSwiping,
|
setMarkdown = setMarkdown,
|
||||||
textFieldValue,
|
lockSwiping = lockSwiping,
|
||||||
setTextFieldAndViewModelValues,
|
enableReadability = enableReadability
|
||||||
markdown,
|
|
||||||
darkMode
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,13 +328,12 @@ fun MainScreen(
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun TabbedMarkdownEditor(
|
private fun TabbedMarkdownEditor(
|
||||||
coroutineScope: CoroutineScope,
|
|
||||||
lockSwiping: Boolean,
|
|
||||||
textFieldValue: TextFieldValue,
|
|
||||||
setTextFieldAndViewModelValues: (TextFieldValue) -> Unit,
|
|
||||||
markdown: String,
|
markdown: String,
|
||||||
darkMode: String
|
setMarkdown: (String) -> Unit,
|
||||||
|
lockSwiping: Boolean,
|
||||||
|
enableReadability: Boolean
|
||||||
) {
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val pagerState = rememberPagerState { 2 }
|
val pagerState = rememberPagerState { 2 }
|
||||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
Tab(text = { Text("Edit") },
|
Tab(text = { Text("Edit") },
|
||||||
|
@ -301,9 +355,14 @@ private fun TabbedMarkdownEditor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (page == 0) {
|
if (page == 0) {
|
||||||
MarkdownTextField(modifier = Modifier.fillMaxSize(), textFieldValue, setTextFieldAndViewModelValues)
|
MarkdownTextField(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
markdown = markdown,
|
||||||
|
setMarkdown = setMarkdown,
|
||||||
|
enableReadability = enableReadability
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode)
|
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -371,7 +430,7 @@ fun MarkdownNavigationDrawer(
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownTopAppBar(
|
fun MarkdownTopAppBar(
|
||||||
title: String,
|
title: String,
|
||||||
navController: NavController,
|
goBack: () -> Unit,
|
||||||
backAsUp: Boolean = true,
|
backAsUp: Boolean = true,
|
||||||
drawerState: DrawerState? = null,
|
drawerState: DrawerState? = null,
|
||||||
actions: (@Composable RowScope.() -> Unit)? = null
|
actions: (@Composable RowScope.() -> Unit)? = null
|
||||||
|
@ -382,7 +441,7 @@ fun MarkdownTopAppBar(
|
||||||
}, navigationIcon = {
|
}, navigationIcon = {
|
||||||
val (icon, contentDescription, onClick) = remember {
|
val (icon, contentDescription, onClick) = remember {
|
||||||
if (backAsUp) {
|
if (backAsUp) {
|
||||||
Triple(Icons.Default.ArrowBack, "Go Back") { navController.popBackStack() }
|
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
|
||||||
} else {
|
} else {
|
||||||
Triple(
|
Triple(
|
||||||
Icons.Default.Menu, "Main Menu"
|
Icons.Default.Menu, "Main Menu"
|
||||||
|
@ -406,10 +465,26 @@ fun MarkdownTopAppBar(
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownTextField(
|
fun MarkdownTextField(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
textFieldValue: TextFieldValue,
|
markdown: String,
|
||||||
setTextFieldValue: (TextFieldValue) -> Unit,
|
setMarkdown: (String) -> Unit,
|
||||||
enableReadability: Boolean = false
|
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(
|
TextField(
|
||||||
modifier = modifier.imePadding(),
|
modifier = modifier.imePadding(),
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
|
@ -421,15 +496,7 @@ fun MarkdownTextField(
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
),
|
),
|
||||||
value = textFieldValue,
|
value = textFieldValue,
|
||||||
onValueChange = {
|
onValueChange = setTextFieldAndViewModelValues,
|
||||||
setTextFieldValue(
|
|
||||||
if (enableReadability) {
|
|
||||||
it.copy(annotatedString = it.text.annotateReadability())
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text("Markdown here…")
|
Text("Markdown here…")
|
||||||
},
|
},
|
||||||
|
@ -439,4 +506,9 @@ fun MarkdownTextField(
|
||||||
),
|
),
|
||||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
|
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 = {
|
topBar = {
|
||||||
MarkdownTopAppBar(
|
MarkdownTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
navController = navController,
|
goBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
@ -35,8 +35,7 @@ fun MarkdownInfoScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
markdown = markdown,
|
markdown = markdown
|
||||||
"Auto"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
@ -24,9 +25,9 @@ private const val container = "<main id=\"content\"></main>"
|
||||||
|
|
||||||
@OptIn(ExperimentalStdlibApi::class)
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) {
|
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
|
||||||
val materialColors = MaterialTheme.colorScheme
|
val materialColors = MaterialTheme.colorScheme
|
||||||
val style = remember(darkMode) {
|
val style = remember(isSystemInDarkTheme()) {
|
||||||
"""body {
|
"""body {
|
||||||
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
|
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
|
||||||
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|
||||||
|
|
|
@ -35,7 +35,7 @@ import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
|
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
|
||||||
Scaffold(topBar = {
|
Scaffold(topBar = {
|
||||||
MarkdownTopAppBar(title = "Settings", navController = navController)
|
MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
|
||||||
}) { paddingValues ->
|
}) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
@ -37,7 +37,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks
|
||||||
@Composable
|
@Composable
|
||||||
fun SupportScreen(navController: NavController) {
|
fun SupportScreen(navController: NavController) {
|
||||||
Scaffold(topBar = {
|
Scaffold(topBar = {
|
||||||
MarkdownTopAppBar(title = "Support SimpleMarkdown", navController = navController)
|
MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() })
|
||||||
}) { paddingValues ->
|
}) { paddingValues ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Column(
|
Column(
|
||||||
|
|
|
@ -2,6 +2,8 @@ package com.wbrawner.simplemarkdown.utility
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -32,14 +34,21 @@ interface FileHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AndroidFileHelper(private val context: Context) : 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) {
|
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||||
val uri = source.toString().toUri()
|
val uri = source.toString().toUri()
|
||||||
context.contentResolver.takePersistableUriPermission(
|
try {
|
||||||
uri,
|
context.contentResolver.takePersistableUriPermission(
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
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")
|
context.contentResolver.openFileDescriptor(uri, "r")
|
||||||
?.use {
|
?.use {
|
||||||
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
|
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
|
||||||
|
|
|
@ -1,29 +1,39 @@
|
||||||
package com.wbrawner.simplemarkdown
|
package com.wbrawner.simplemarkdown
|
||||||
|
|
||||||
import com.wbrawner.simplemarkdown.utility.FileHelper
|
import com.wbrawner.simplemarkdown.utility.FileHelper
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
class FakeFileHelper : FileHelper {
|
class FakeFileHelper : FileHelper {
|
||||||
override val defaultDirectory: File
|
override val defaultDirectory: File by lazy {
|
||||||
get() = File.createTempFile("sm", null)
|
File.createTempFile("simplemarkdown", null)
|
||||||
.apply {
|
.apply {
|
||||||
delete()
|
delete()
|
||||||
mkdir()
|
mkdir()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var file: Pair<String, String> = "Untitled.md" to "This is a test file"
|
var file: Pair<String, String> = "Untitled.md" to "This is a test file"
|
||||||
var openedUris = ArrayDeque<URI>()
|
var openedUris = ArrayDeque<URI>()
|
||||||
var savedData = ArrayDeque<SavedData>()
|
var savedData = ArrayDeque<SavedData>()
|
||||||
|
@Volatile
|
||||||
|
var errorOnOpen: Boolean = false
|
||||||
|
@Volatile
|
||||||
|
var errorOnSave: Boolean = false
|
||||||
|
|
||||||
override suspend fun open(source: URI): Pair<String, String> {
|
override suspend fun open(source: URI): Pair<String, String> {
|
||||||
|
delay(1000)
|
||||||
|
if (errorOnOpen) error("errorOnOpen set to true")
|
||||||
openedUris.addLast(source)
|
openedUris.addLast(source)
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun save(destination: URI, content: String): String {
|
override suspend fun save(destination: URI, content: String): String {
|
||||||
|
delay(1000)
|
||||||
|
if (errorOnSave) error("errorOnSave set to true")
|
||||||
savedData.addLast(SavedData(destination, content))
|
savedData.addLast(SavedData(destination, content))
|
||||||
return file.first
|
return destination.path.substringAfterLast("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class FakePreferenceHelper: PreferenceHelper {
|
class FakePreferenceHelper: PreferenceHelper {
|
||||||
val preferences = mutableMapOf<Preference, Any?>()
|
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?) {
|
override fun set(preference: Preference, value: Any?) {
|
||||||
preferences[preference] = value
|
preferences[preference] = value
|
||||||
|
|
|
@ -1,56 +1,296 @@
|
||||||
package com.wbrawner.simplemarkdown
|
package com.wbrawner.simplemarkdown
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
import com.wbrawner.simplemarkdown.utility.Preference
|
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.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.util.Deque
|
||||||
|
import java.util.concurrent.ConcurrentLinkedDeque
|
||||||
|
|
||||||
class MarkdownViewModelTest {
|
class MarkdownViewModelTest {
|
||||||
private lateinit var fileHelper: FakeFileHelper
|
private lateinit var fileHelper: FakeFileHelper
|
||||||
private lateinit var preferenceHelper: FakePreferenceHelper
|
private lateinit var preferenceHelper: FakePreferenceHelper
|
||||||
|
private lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||||
private lateinit var viewModel: MarkdownViewModel
|
private lateinit var viewModel: MarkdownViewModel
|
||||||
|
private lateinit var viewModelScope: TestScope
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
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()
|
fileHelper = FakeFileHelper()
|
||||||
preferenceHelper = FakePreferenceHelper()
|
preferenceHelper = FakePreferenceHelper()
|
||||||
viewModel = MarkdownViewModel(fileHelper, preferenceHelper)
|
viewModelFactory = MarkdownViewModel.factory(fileHelper, preferenceHelper)
|
||||||
|
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
|
||||||
|
viewModelScope.advanceUntilIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMarkdownUpdate() = runBlocking {
|
fun testMarkdownUpdate() = runTest {
|
||||||
assertEquals("", viewModel.markdown.value)
|
assertEquals("", viewModel.state.value.markdown)
|
||||||
viewModel.updateMarkdown("Updated content")
|
viewModel.updateMarkdown("Updated content")
|
||||||
assertEquals("Updated content", viewModel.markdown.value)
|
assertEquals("Updated content", viewModel.state.value.markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLoadWithNoPathAndNoAutosaveUri() = runBlocking {
|
fun testLoadWithNoPathAndNoAutosaveUri() = runTest {
|
||||||
viewModel.load(null)
|
viewModel.load(null)
|
||||||
assertTrue(fileHelper.openedUris.isEmpty())
|
assertTrue(fileHelper.openedUris.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLoadWithNoPathAndAutosaveUri() = runBlocking {
|
fun testAutoLoad() = runTest {
|
||||||
val uri = URI.create("file:///home/user/Untitled.md")
|
val uri = URI.create("file:///home/user/Untitled.md")
|
||||||
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
|
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
|
||||||
viewModel.load(null)
|
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
|
||||||
|
viewModelScope.advanceUntilIdle()
|
||||||
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
||||||
val (fileName, contents) = fileHelper.file
|
val (fileName, contents) = fileHelper.file
|
||||||
assertEquals(fileName, viewModel.fileName.value)
|
assertEquals(fileName, viewModel.state.value.fileName)
|
||||||
assertEquals(contents, viewModel.markdown.value)
|
assertEquals(contents, viewModel.state.value.markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLoadWithPath() = runBlocking {
|
fun testLoadWithPath() = runTest {
|
||||||
val uri = URI.create("file:///home/user/Untitled.md")
|
val uri = URI.create("file:///home/user/Untitled.md")
|
||||||
viewModel.load(uri.toString())
|
viewModel.load(uri.toString())
|
||||||
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
assertEquals(uri, fileHelper.openedUris.firstOrNull())
|
||||||
val (fileName, contents) = fileHelper.file
|
val (fileName, contents) = fileHelper.file
|
||||||
assertEquals(fileName, viewModel.fileName.value)
|
assertEquals(fileName, viewModel.state.value.fileName)
|
||||||
assertEquals(contents, viewModel.markdown.value)
|
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