Compare commits

..

No commits in common. "main" and "fix-free-release" have entirely different histories.

24 changed files with 285 additions and 364 deletions

View file

@ -15,7 +15,7 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v4 uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v3
unit_tests: unit_tests:
name: Run Unit Tests name: Run Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -30,10 +30,10 @@ jobs:
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3 uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Run unit tests - name: Run unit tests
run: ./gradlew check uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v3
with:
arguments: check
- name: Publish JUnit Results - name: Publish JUnit Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
if: always() if: always()
@ -55,10 +55,10 @@ jobs:
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3 uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle - name: Build with Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4 uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
- name: Build APKs with:
run: ./gradlew assemblePlayDebug assemblePlayDebugAndroidTest arguments: assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh - name: Grant execute permission for flank_auth.sh
run: chmod +x flank_auth.sh run: chmod +x flank_auth.sh
- name: Add auth for flank - name: Add auth for flank
@ -67,4 +67,6 @@ jobs:
run: | run: |
./flank_auth.sh ./flank_auth.sh
- name: Run UI tests - name: Run UI tests
run: ./gradlew runFlank uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
with:
arguments: runFlank

View file

@ -37,7 +37,7 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Run unit tests - name: Run unit tests
uses: gradle/gradle-build-action@v3 uses: gradle/gradle-build-action@v2
with: with:
arguments: testPlayDebugUnitTest arguments: testPlayDebugUnitTest
- name: Publish JUnit Results - name: Publish JUnit Results
@ -61,7 +61,7 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Build with Gradle - name: Build with Gradle
uses: gradle/gradle-build-action@v3 uses: gradle/gradle-build-action@v2
with: with:
arguments: assemblePlayDebug assemblePlayDebugAndroidTest arguments: assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh - name: Grant execute permission for flank_auth.sh
@ -72,6 +72,6 @@ jobs:
run: | run: |
./flank_auth.sh ./flank_auth.sh
- name: Run UI tests - name: Run UI tests
uses: gradle/gradle-build-action@v3 uses: gradle/gradle-build-action@v2
with: with:
arguments: runFlank arguments: runFlank

View file

@ -21,7 +21,6 @@ try {
keystoreProperties["keyPassword"] = "" keystoreProperties["keyPassword"] = ""
keystoreProperties["storeFile"] = File.createTempFile("temp", ".tmp").absolutePath keystoreProperties["storeFile"] = File.createTempFile("temp", ".tmp").absolutePath
keystoreProperties["storePassword"] = "" keystoreProperties["storePassword"] = ""
keystoreProperties["publishCredentialsFile"] = ""
} }
android { android {
@ -38,18 +37,18 @@ android {
} }
compileSdk = libs.versions.maxSdk.get().toInt() compileSdk = libs.versions.maxSdk.get().toInt()
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "1.8"
} }
defaultConfig { defaultConfig {
applicationId = "com.wbrawner.simplemarkdown" applicationId = "com.wbrawner.simplemarkdown"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt() targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = 45 versionCode = 44
versionName = "2024.10.0" versionName = "1.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true" testInstrumentationRunnerArguments["clearPackageData"] = "true"
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "true") buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "true")
@ -99,11 +98,7 @@ android {
} }
} }
lint { lint {
disable += listOf( disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
warningsAsErrors = true warningsAsErrors = true
} }
} }
@ -113,9 +108,6 @@ play {
enabled.set(false) enabled.set(false)
track.set("production") track.set("production")
defaultToAppBundles.set(true) defaultToAppBundles.set(true)
(keystoreProperties["publishCredentialsFile"] as? String)?.ifBlank { null }?.let {
serviceAccountCredentials.set(file(it))
}
} }
dependencies { dependencies {
@ -162,6 +154,7 @@ dependencies {
androidTestImplementation(libs.androidx.core) androidTestImplementation(libs.androidx.core)
androidTestImplementation(libs.androidx.monitor) androidTestImplementation(libs.androidx.monitor)
androidTestImplementation(libs.junit) androidTestImplementation(libs.junit)
androidTestImplementation(libs.hamcrest.core)
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.animation.core) implementation(libs.androidx.animation.core)

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext 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.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsRule import androidx.test.espresso.intent.rule.IntentsRule
@ -181,29 +182,6 @@ class MarkdownTests {
} }
} }
@Test
fun launchWithContentUriTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val fileUri = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
ActivityScenario.launch<MainActivity>(
Intent(
Intent.ACTION_VIEW,
fileUri,
getInstrumentation().targetContext,
MainActivity::class.java
)
)
onMainScreen(composeRule) {
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test @Test
fun openEditAndSaveMarkdownTest() = runTest { fun openEditAndSaveMarkdownTest() = runTest {
@ -232,23 +210,4 @@ class MarkdownTests {
checkTitleEquals("temp.md") checkTitleEquals("temp.md")
} }
} }
@Test
fun editAndViewHelpMarkdownTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
pressBack()
} onMainScreen {
checkMarkdownEquals("# Header test")
}
}
} }

View file

@ -27,10 +27,8 @@ fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit
suspend fun CoroutineScope.onMainScreen( suspend fun CoroutineScope.onMainScreen(
composeRule: ComposeTestRule, composeRule: ComposeTestRule,
block: suspend MainScreenRobot.() -> Unit block: suspend MainScreenRobot.() -> Unit
): MainScreenRobot { ) {
val mainScreenRobot = MainScreenRobot(composeRule) block.invoke(MainScreenRobot(composeRule))
block.invoke(mainScreenRobot)
return mainScreenRobot
} }
class MainScreenRobot(private val composeRule: ComposeTestRule) : class MainScreenRobot(private val composeRule: ComposeTestRule) :

View file

@ -1,14 +1,7 @@
package com.wbrawner.simplemarkdown.robot package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) : class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule), TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
WebViewRobot by EspressoWebViewRobot() { WebViewRobot by EspressoWebViewRobot()
fun pressBack() = composeTestRule.onNodeWithContentDescription("Back").performClick()
infix fun onMainScreen(block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeTestRule).apply(block)
}

View file

@ -7,7 +7,6 @@
<application <application
android:name=".MarkdownApplication" android:name=".MarkdownApplication"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:resizeableActivity="true" android:resizeableActivity="true"
@ -15,7 +14,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.App.Starting" android:theme="@style/Theme.App.Starting"
tools:ignore="AllowBackup" tools:ignore="AllowBackup"
tools:targetApi="tiramisu"> tools:targetApi="n">
<activity android:name=".MainActivity" <activity android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name_short"> android:label="@string/app_name_short">

View file

@ -1,7 +1,5 @@
package com.wbrawner.simplemarkdown package com.wbrawner.simplemarkdown
import android.app.ComponentCaller
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -10,11 +8,12 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.EaseIn import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleOut 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.automirrored.filled.Help
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@ -28,13 +27,11 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@ -46,7 +43,6 @@ import com.wbrawner.simplemarkdown.ui.SettingsScreen
import com.wbrawner.simplemarkdown.ui.SupportScreen import com.wbrawner.simplemarkdown.ui.SupportScreen
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.launch
import org.acra.ACRA import org.acra.ACRA
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback { class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
@ -65,6 +61,8 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
setContent { setContent {
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED) val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
.collectAsState() .collectAsState()
val readabilityEnabled by preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.collectAsState()
val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE) val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE)
.collectAsState() .collectAsState()
LaunchedEffect(darkModePreference) { LaunchedEffect(darkModePreference) {
@ -94,10 +92,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
LaunchedEffect(errorReporterPreference) { LaunchedEffect(errorReporterPreference) {
ACRA.errorReporter.setEnabled(errorReporterPreference) ACRA.errorReporter.setEnabled(errorReporterPreference)
} }
val intentData = remember(intent) { intent?.data }
LaunchedEffect(intentData) {
viewModel.load(intentData?.toString())
}
val windowSizeClass = calculateWindowSizeClass(this) val windowSizeClass = calculateWindowSizeClass(this)
SimpleMarkdownTheme { SimpleMarkdownTheme {
val navController = rememberNavController() val navController = rememberNavController()
@ -114,9 +108,13 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
towards = AnimatedContentTransitionScope.SlideDirection.Start towards = AnimatedContentTransitionScope.SlideDirection.Start
) )
}, },
popEnterTransition = { fadeIn() }, popEnterTransition = { EnterTransition.None },
popExitTransition = { popExitTransition = {
scaleOut(targetScale = 0.9f) + slideOutOfContainer( fadeOut(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseIn), animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.End towards = AnimatedContentTransitionScope.SlideDirection.End
) )
@ -128,6 +126,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
) )
} }
composable(Route.SETTINGS.path) { composable(Route.SETTINGS.path) {
@ -161,15 +160,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
} }
} }
} }
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent, caller)
lifecycleScope.launch {
intent.data?.let {
viewModel.load(it.toString())
}
}
}
} }
enum class Route( enum class Route(

View file

@ -1,16 +1,11 @@
package com.wbrawner.simplemarkdown package com.wbrawner.simplemarkdown
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.core.LocalOnlyException import com.wbrawner.simplemarkdown.core.LocalOnlyException
import com.wbrawner.simplemarkdown.model.Readability
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
@ -19,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
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 kotlinx.coroutines.withContext
@ -28,17 +24,21 @@ import java.net.URI
data class EditorState( data class EditorState(
val fileName: String = "Untitled.md", val fileName: String = "Untitled.md",
val markdown: TextFieldValue = TextFieldValue(""), val markdown: String = "",
val path: URI? = null, val path: URI? = null,
val toast: ParameterizedText? = null, val toast: ParameterizedText? = null,
val alert: AlertDialogModel? = null, val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null, val saveCallback: (() -> Unit)? = null,
/**
* Used to signal to the view that it should reload due to an external change, like loading
* a new file
*/
val reloadToggle: Int = 0,
val lockSwiping: Boolean = false, val lockSwiping: Boolean = false,
val enableReadability: Boolean = false, private val initialMarkdown: String = "",
val initialMarkdown: String = "",
) { ) {
val dirty: Boolean val dirty: Boolean
get() = markdown.text != initialMarkdown get() = markdown != initialMarkdown
} }
class MarkdownViewModel( class MarkdownViewModel(
@ -50,43 +50,32 @@ class MarkdownViewModel(
private val saveMutex = Mutex() private val saveMutex = Mutex()
init { init {
viewModelScope.launch {
load(null)
}
preferenceHelper.observe<Boolean>(Preference.LOCK_SWIPING) preferenceHelper.observe<Boolean>(Preference.LOCK_SWIPING)
.onEach { .onEach {
updateState { copy(lockSwiping = it) } _state.value = _state.value.copy(lockSwiping = it)
}
.launchIn(viewModelScope)
preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.onEach {
updateState {
copy(
enableReadability = it,
markdown = markdown.copy(annotatedString = markdown.text.annotate(it)),
)
}
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
fun updateMarkdown(markdown: String?) = updateMarkdown(TextFieldValue(markdown.orEmpty())) fun updateMarkdown(markdown: String?) {
_state.value = _state.value.copy(
fun updateMarkdown(markdown: TextFieldValue) { markdown = markdown ?: "",
updateState { )
copy(
markdown = markdown.copy(annotatedString = markdown.text.annotate(enableReadability)),
)
}
} }
fun dismissToast() { fun dismissToast() {
updateState { copy(toast = null) } _state.value = _state.value.copy(toast = null)
} }
fun dismissAlert() { fun dismissAlert() {
updateState { copy(alert = null) } _state.value = _state.value.copy(alert = null)
} }
private fun unsetSaveCallback() { private fun unsetSaveCallback() {
updateState { copy(saveCallback = null) } _state.value = _state.value.copy(saveCallback = null)
} }
suspend fun load(loadPath: String?) { suspend fun load(loadPath: String?) {
@ -109,30 +98,25 @@ class MarkdownViewModel(
val uri = URI.create(actualLoadPath) val uri = URI.create(actualLoadPath)
fileHelper.open(uri) fileHelper.open(uri)
?.let { (name, content) -> ?.let { (name, content) ->
updateState { val currentState = _state.value
copy( _state.value = currentState.copy(
path = uri, path = uri,
fileName = name, fileName = name,
markdown = TextFieldValue(content), markdown = content,
initialMarkdown = content, initialMarkdown = content,
toast = ParameterizedText(R.string.file_loaded, arrayOf(name)) reloadToggle = currentState.reloadToggle.inv(),
) toast = ParameterizedText(R.string.file_loaded, arrayOf(name))
} )
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null") } ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath") Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath")
updateState { _state.value = _state.value.copy(
copy( alert = AlertDialogModel(
alert = AlertDialogModel( text = ParameterizedText(R.string.file_load_error),
text = ParameterizedText(R.string.file_load_error), confirmButton = AlertDialogModel.ButtonModel(ParameterizedText(R.string.ok), onClick = ::dismissAlert)
confirmButton = AlertDialogModel.ButtonModel(
ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
)
) )
} )
} }
} }
} }
@ -144,44 +128,35 @@ class MarkdownViewModel(
?: run { ?: run {
Timber.w("Attempted to save file with empty path") Timber.w("Attempted to save file with empty path")
if (interactive) { if (interactive) {
updateState { _state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
copy(saveCallback = ::unsetSaveCallback)
}
} }
return@withLock false return@withLock false
} }
try { try {
Timber.i("Saving file to $actualSavePath...") Timber.i("Saving file to $actualSavePath...")
val currentState = _state.value val currentState = _state.value
val name = fileHelper.save(actualSavePath, currentState.markdown.text) val name = fileHelper.save(actualSavePath, currentState.markdown)
updateState { _state.value = currentState.copy(
currentState.copy( fileName = name,
fileName = name, path = actualSavePath,
path = actualSavePath, initialMarkdown = currentState.markdown,
initialMarkdown = currentState.markdown.text, toast = if (interactive) ParameterizedText(R.string.file_saved, arrayOf(name)) else null
toast = if (interactive) ParameterizedText( )
R.string.file_saved,
arrayOf(name)
) else null
)
}
Timber.i("Saved file $name to uri $actualSavePath") Timber.i("Saved file $name to uri $actualSavePath")
Timber.i("Persisting autosave uri in shared prefs: $actualSavePath") Timber.i("Persisting autosave uri in shared prefs: $actualSavePath")
preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath
true true
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to save file to $actualSavePath") Timber.e(e, "Failed to save file to $actualSavePath")
updateState { _state.value = _state.value.copy(
copy( alert = AlertDialogModel(
alert = AlertDialogModel( text = ParameterizedText(R.string.file_save_error),
text = ParameterizedText(R.string.file_save_error), confirmButton = AlertDialogModel.ButtonModel(
confirmButton = AlertDialogModel.ButtonModel( text = ParameterizedText(R.string.ok),
text = ParameterizedText(R.string.ok), onClick = ::dismissAlert
onClick = ::dismissAlert
)
) )
) )
} )
false false
} }
} }
@ -213,7 +188,7 @@ class MarkdownViewModel(
// to an internal storage location, thus marking it as not dirty, but no longer able to // 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 // access the file if the accidentally go to create a new file without properly saving
// the current one // the current one
fileHelper.save(file, _state.value.markdown.text) fileHelper.save(file, _state.value.markdown)
preferenceHelper[Preference.AUTOSAVE_URI] = file preferenceHelper[Preference.AUTOSAVE_URI] = file
} }
} }
@ -222,35 +197,33 @@ class MarkdownViewModel(
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 && _state.value.dirty) { if (!force && _state.value.dirty) {
updateState { _state.value = _state.value.copy(alert = AlertDialogModel(
copy(alert = AlertDialogModel( text = ParameterizedText(R.string.prompt_save_changes),
text = ParameterizedText(R.string.prompt_save_changes), confirmButton = AlertDialogModel.ButtonModel(
confirmButton = AlertDialogModel.ButtonModel( text = ParameterizedText(R.string.yes),
text = ParameterizedText(R.string.yes), onClick = {
onClick = { _state.value = _state.value.copy(
_state.value = _state.value.copy( saveCallback = {
saveCallback = { reset(untitledFileName, false)
reset(untitledFileName, false) }
} )
) }
} ),
), dismissButton = AlertDialogModel.ButtonModel(
dismissButton = AlertDialogModel.ButtonModel( text = ParameterizedText(R.string.no),
text = ParameterizedText(R.string.no), onClick = {
onClick = { reset(untitledFileName, true)
reset(untitledFileName, true) }
} )
) ))
))
}
return return
} }
updateState { _state.value =
EditorState( EditorState(
fileName = untitledFileName, fileName = untitledFileName,
reloadToggle = _state.value.reloadToggle.inv(),
lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean
) )
}
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
} }
@ -259,10 +232,6 @@ class MarkdownViewModel(
preferenceHelper[Preference.LOCK_SWIPING] = enabled preferenceHelper[Preference.LOCK_SWIPING] = enabled
} }
private fun updateState(block: EditorState.() -> EditorState) {
_state.value = _state.value.block()
}
companion object { companion object {
fun factory( fun factory(
fileHelper: FileHelper, fileHelper: FileHelper,
@ -305,17 +274,4 @@ data class ParameterizedText(@StringRes val text: Int, val params: Array<Any> =
result = 31 * result + params.contentHashCode() result = 31 * result + params.contentHashCode()
return result return result
} }
}
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
} }

View file

@ -4,6 +4,7 @@ import android.content.Intent
import android.net.Uri 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.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -52,7 +53,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
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
@ -76,10 +76,13 @@ fun MainScreen(
viewModel: MarkdownViewModel, viewModel: MarkdownViewModel,
enableWideLayout: Boolean, enableWideLayout: Boolean,
enableAutosave: Boolean, enableAutosave: Boolean,
enableReadability: Boolean
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.collectAsState(EditorState::fileName, "") val fileName by viewModel.collectAsState(EditorState::fileName, "")
val markdown by viewModel.collectAsState(EditorState::markdown, TextFieldValue("")) val initialMarkdown by viewModel.collectAsState(EditorState::markdown, "")
val reloadToggle by viewModel.collectAsState(EditorState::reloadToggle, 0)
val markdown by viewModel.collectAsState(EditorState::markdown, "")
val dirty by viewModel.collectAsState(EditorState::dirty, false) val dirty by viewModel.collectAsState(EditorState::dirty, false)
val alert by viewModel.collectAsState(EditorState::alert, null) val alert by viewModel.collectAsState(EditorState::alert, null)
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null) val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
@ -95,6 +98,8 @@ fun MainScreen(
MainScreen( MainScreen(
dirty = dirty, dirty = dirty,
fileName = fileName, fileName = fileName,
reloadToggle = reloadToggle,
initialMarkdown = initialMarkdown,
markdown = markdown, markdown = markdown,
setMarkdown = viewModel::updateMarkdown, setMarkdown = viewModel::updateMarkdown,
lockSwiping = lockSwiping, lockSwiping = lockSwiping,
@ -122,6 +127,7 @@ fun MainScreen(
viewModel.reset("Untitled.md") viewModel.reset("Untitled.md")
}, },
enableWideLayout = enableWideLayout, enableWideLayout = enableWideLayout,
enableReadability = enableReadability,
) )
} }
@ -130,8 +136,10 @@ fun MainScreen(
private fun MainScreen( private fun MainScreen(
fileName: String = "Untitled.md", fileName: String = "Untitled.md",
dirty: Boolean = false, dirty: Boolean = false,
markdown: TextFieldValue = TextFieldValue(""), reloadToggle: Int = 0,
setMarkdown: (TextFieldValue) -> Unit = {}, initialMarkdown: String = "",
markdown: String = "",
setMarkdown: (String) -> Unit = {},
lockSwiping: Boolean, lockSwiping: Boolean,
toggleLockSwiping: (Boolean) -> Unit, toggleLockSwiping: (Boolean) -> Unit,
message: String? = null, message: String? = null,
@ -145,6 +153,7 @@ private fun MainScreen(
saveCallback: (() -> Unit)? = null, saveCallback: (() -> Unit)? = null,
reset: () -> Unit = {}, reset: () -> Unit = {},
enableWideLayout: Boolean = false, enableWideLayout: Boolean = false,
enableReadability: Boolean = false
) { ) {
val openFileLauncher = val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
@ -205,7 +214,7 @@ private fun MainScreen(
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND) val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown.text) shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
shareIntent.type = "text/plain" shareIntent.type = "text/plain"
startActivity( startActivity(
context, Intent.createChooser( context, Intent.createChooser(
@ -278,8 +287,10 @@ private fun MainScreen(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(1f), .weight(1f),
reload = reloadToggle,
markdown = markdown, markdown = markdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
enableReadability = enableReadability,
) )
Spacer( Spacer(
modifier = Modifier modifier = Modifier
@ -291,7 +302,7 @@ private fun MainScreen(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(1f), .weight(1f),
markdown = markdown.text markdown = markdown
) )
} }
} else { } else {
@ -301,9 +312,12 @@ private fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
TabbedMarkdownEditor( TabbedMarkdownEditor(
initialMarkdown = initialMarkdown,
markdown = markdown, markdown = markdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
lockSwiping = lockSwiping, lockSwiping = lockSwiping,
enableReadability = enableReadability,
reloadToggle = reloadToggle,
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }
@ -313,11 +327,14 @@ private fun MainScreen(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
private fun TabbedMarkdownEditor( private fun TabbedMarkdownEditor(
markdown: TextFieldValue, initialMarkdown: String,
setMarkdown: (TextFieldValue) -> Unit, markdown: String,
setMarkdown: (String) -> Unit,
lockSwiping: Boolean, lockSwiping: Boolean,
enableReadability: Boolean,
reloadToggle: Int,
scrollBehavior: TopAppBarScrollBehavior scrollBehavior: TopAppBarScrollBehavior
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -332,7 +349,7 @@ private fun TabbedMarkdownEditor(
} }
HorizontalPager( HorizontalPager(
modifier = Modifier.fillMaxSize(1f), state = pagerState, modifier = Modifier.fillMaxSize(1f), state = pagerState,
beyondViewportPageCount = 1, beyondBoundsPageCount = 1,
userScrollEnabled = !lockSwiping userScrollEnabled = !lockSwiping
) { page -> ) { page ->
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -347,15 +364,17 @@ private fun TabbedMarkdownEditor(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
markdown = markdown, markdown = initialMarkdown,
setMarkdown = setMarkdown, setMarkdown = setMarkdown,
enableReadability = enableReadability,
reload = reloadToggle,
) )
} else { } else {
MarkdownText( MarkdownText(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
markdown.text markdown
) )
} }
} }

View file

@ -1,23 +1,33 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.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
@ -25,14 +35,28 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MarkdownTextField( fun MarkdownTextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
markdown: TextFieldValue, markdown: String,
setMarkdown: (TextFieldValue) -> Unit, setMarkdown: (String) -> Unit,
reload: Int = 0,
enableReadability: Boolean = false,
) { ) {
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
val (textFieldValue, setTextFieldValue) = remember(reload) {
mutableStateOf(TextFieldValue(markdown.annotate(enableReadability), selection, composition))
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setSelection(it.selection)
setComposition(it.composition)
setTextFieldValue(it.copy(annotatedString = it.text.annotate(enableReadability)))
setMarkdown(it.text)
}
val colors = TextFieldDefaults.colors( val colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface,
@ -46,37 +70,57 @@ fun MarkdownTextField(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { Column(
BasicTextField( modifier = modifier
value = markdown, .fillMaxSize()
modifier = modifier.imePadding(), .imePadding()
onValueChange = setMarkdown, .verticalScroll(rememberScrollState())
enabled = true, ) {
readOnly = false, CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
textStyle = textStyle, BasicTextField(
cursorBrush = SolidColor(colors.cursorColor), value = textFieldValue,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), modifier = Modifier.fillMaxSize(),
keyboardActions = KeyboardActions.Default, onValueChange = setTextFieldAndViewModelValues,
interactionSource = interactionSource, enabled = true,
singleLine = false, readOnly = false,
maxLines = Int.MAX_VALUE, textStyle = textStyle,
minLines = 1, cursorBrush = SolidColor(colors.cursorColor),
decorationBox = @Composable { innerTextField -> keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
// places leading icon, text field with label and placeholder, trailing icon keyboardActions = KeyboardActions.Default,
TextFieldDefaults.DecorationBox( interactionSource = interactionSource,
value = markdown.text, singleLine = false,
visualTransformation = VisualTransformation.None, maxLines = Int.MAX_VALUE,
innerTextField = innerTextField, minLines = 1,
placeholder = { decorationBox = @Composable { innerTextField ->
Text(stringResource(R.string.markdown_here)) // places leading icon, text field with label and placeholder, trailing icon
}, TextFieldDefaults.DecorationBox(
singleLine = false, value = textFieldValue.text,
enabled = true, visualTransformation = VisualTransformation.None,
interactionSource = interactionSource, innerTextField = innerTextField,
colors = colors, placeholder = {
contentPadding = PaddingValues(8.dp) Text(stringResource(R.string.markdown_here))
) },
}, singleLine = false,
) enabled = true,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(8.dp)
)
}
)
}
} }
} }
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
}

View file

@ -1,11 +1,11 @@
package com.wbrawner.simplemarkdown.utility package com.wbrawner.simplemarkdown.utility
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.res.AssetManager import android.content.res.AssetManager
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.view.View
import android.view.inputmethod.InputMethodManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.Reader import java.io.Reader
@ -22,11 +22,11 @@ suspend fun Uri.getName(context: Context): String {
if ("content" == scheme) { if ("content" == scheme) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
context.contentResolver.query( context.contentResolver.query(
this@getName, this@getName,
null, null,
null, null,
null, null,
null null
)?.use { )?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst() it.moveToFirst()
@ -41,11 +41,3 @@ suspend fun Uri.getName(context: Context): String {
} }
return fileName ?: "Untitled.md" return fileName ?: "Untitled.md"
} }
@Suppress("RecursivePropertyAccessor")
val Context.activity: Activity?
get() = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.activity
else -> null
}

View file

@ -47,7 +47,6 @@ class AndroidPreferenceHelper(context: Context, private val coroutineScope: Coro
} }
} }
@Suppress("UNCHECKED_CAST")
override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T> override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T>
} }

View file

@ -1,2 +0,0 @@
- Fix opening files from external apps
- Update dependencies

View file

@ -0,0 +1,5 @@
- Fix crash on markdown preview
- Persist preference for Lock Swiping
- Enable gestures on nav drawer when open
- Close navigation drawer on back press
- Various dependency updates

View file

@ -23,7 +23,6 @@ class FakePreferenceHelper: PreferenceHelper {
preferences[preference] = value preferences[preference] = value
} }
@Suppress("UNCHECKED_CAST")
override fun <T> observe(preference: Preference): StateFlow<T> = override fun <T> observe(preference: Preference): StateFlow<T> =
preferenceFlow(preference) as StateFlow<T> preferenceFlow(preference) as StateFlow<T>
} }

View file

@ -1,6 +1,5 @@
package com.wbrawner.simplemarkdown package com.wbrawner.simplemarkdown
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.Preference
@ -24,7 +23,6 @@ import timber.log.Timber
import java.io.File import java.io.File
import java.net.URI import java.net.URI
@OptIn(ExperimentalCoroutinesApi::class)
class MarkdownViewModelTest { class MarkdownViewModelTest {
private lateinit var fileHelper: FakeFileHelper private lateinit var fileHelper: FakeFileHelper
private lateinit var preferenceHelper: FakePreferenceHelper private lateinit var preferenceHelper: FakePreferenceHelper
@ -53,9 +51,9 @@ class MarkdownViewModelTest {
@Test @Test
fun testMarkdownUpdate() = runTest { fun testMarkdownUpdate() = runTest {
assertEquals("".asTextFieldValue(), viewModel.state.value.markdown) assertEquals("", viewModel.state.value.markdown)
viewModel.updateMarkdown("Updated content") viewModel.updateMarkdown("Updated content")
assertEquals("Updated content".asTextFieldValue(), viewModel.state.value.markdown) assertEquals("Updated content", viewModel.state.value.markdown)
} }
@Test @Test
@ -69,11 +67,11 @@ class MarkdownViewModelTest {
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 = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty) viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModel.load(null) 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.state.value.fileName) assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown) assertEquals(contents, viewModel.state.value.markdown)
} }
@Test @Test
@ -83,7 +81,7 @@ class MarkdownViewModelTest {
assertEquals(uri, fileHelper.openedUris.firstOrNull()) assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.state.value.fileName) assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown) assertEquals(contents, viewModel.state.value.markdown)
} }
@Test @Test
@ -127,7 +125,7 @@ class MarkdownViewModelTest {
val uri = URI.create("file:///home/user/Saved.md") val uri = URI.create("file:///home/user/Saved.md")
val testMarkdown = "# Test" val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown) viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown) assertEquals(testMarkdown, viewModel.state.value.markdown)
assertTrue(viewModel.save(uri)) assertTrue(viewModel.save(uri))
assertEquals("Saved.md", viewModel.state.value.fileName) assertEquals("Saved.md", viewModel.state.value.fileName)
assertEquals(uri, fileHelper.savedData.last().uri) assertEquals(uri, fileHelper.savedData.last().uri)
@ -140,7 +138,7 @@ class MarkdownViewModelTest {
val uri = URI.create("file:///home/user/Untitled.md") val uri = URI.create("file:///home/user/Untitled.md")
val testMarkdown = "# Test" val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown) viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown) assertEquals(testMarkdown, viewModel.state.value.markdown)
fileHelper.errorOnSave = true fileHelper.errorOnSave = true
assertNull(viewModel.state.value.alert) assertNull(viewModel.state.value.alert)
assertFalse(viewModel.save(uri)) assertFalse(viewModel.save(uri))
@ -160,7 +158,7 @@ class MarkdownViewModelTest {
assertNull(viewModel.state.value.alert) assertNull(viewModel.state.value.alert)
with(viewModel.state.value) { with(viewModel.state.value) {
assertEquals("New.md", fileName) assertEquals("New.md", fileName)
assertEquals("".asTextFieldValue(), markdown) assertEquals("", markdown)
assertNull(path) assertNull(path)
assertNull(saveCallback) assertNull(saveCallback)
assertNull(alert) assertNull(alert)
@ -182,7 +180,7 @@ class MarkdownViewModelTest {
requireNotNull(onClick) requireNotNull(onClick)
onClick.invoke() onClick.invoke()
} }
assertEquals(viewModel.state.value, EditorState()) assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv()))
} }
@Test @Test
@ -201,7 +199,7 @@ class MarkdownViewModelTest {
viewModel.save(uri) viewModel.save(uri)
assertNotNull(viewModel.state.value.saveCallback) assertNotNull(viewModel.state.value.saveCallback)
requireNotNull(viewModel.state.value.saveCallback).invoke() requireNotNull(viewModel.state.value.saveCallback).invoke()
assertEquals(viewModel.state.value, EditorState()) assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv()))
} }
@Test @Test
@ -218,7 +216,7 @@ class MarkdownViewModelTest {
assertNull(viewModel.state.value.alert) assertNull(viewModel.state.value.alert)
with(viewModel.state.value) { with(viewModel.state.value) {
assertEquals("Unsaved.md", fileName) assertEquals("Unsaved.md", fileName)
assertEquals("".asTextFieldValue(), markdown) assertEquals("", markdown)
assertNull(path) assertNull(path)
assertNull(saveCallback) assertNull(saveCallback)
assertNull(alert) assertNull(alert)
@ -304,6 +302,4 @@ class MarkdownViewModelTest {
assertFalse(preferenceHelper.preferences[Preference.LOCK_SWIPING] as Boolean) assertFalse(preferenceHelper.preferences[Preference.LOCK_SWIPING] as Boolean)
assertFalse(viewModel.state.value.lockSwiping) assertFalse(viewModel.state.value.lockSwiping)
} }
private fun String.asTextFieldValue() = TextFieldValue(this)
} }

View file

@ -2,37 +2,25 @@ package com.wbrawner.releasehelper
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFileProperty
import org.gradle.api.internal.provider.Providers
import org.gradle.api.model.ObjectFactory import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import javax.inject.Inject import javax.inject.Inject
private const val CHANGELOG_PATH = "src/play/play/release-notes/en-US/production.txt" private const val CHANGELOG_PATH = "src/play/play/release-notes/en-US/default.txt"
abstract class ChangelogTask @Inject constructor( abstract class ChangelogTask @Inject constructor(objectFactory: ObjectFactory) : DefaultTask() {
objectFactory: ObjectFactory,
providers: ProviderFactory,
) : DefaultTask() {
@get:OutputFile @get:OutputFile
val changelogFile: RegularFileProperty = objectFactory.fileProperty() val changelogFile: RegularFileProperty = objectFactory.fileProperty()
@get:Input
@Suppress("UnstableApiUsage")
val latestTag: String = providers.exec {
commandLine("git" , "describe", "--tags", "--abbrev=0")
}.standardOutput.asText.get()
init { init {
changelogFile.set(project.layout.projectDirectory.file(CHANGELOG_PATH)) changelogFile.set(project.layout.projectDirectory.file(CHANGELOG_PATH))
} }
@TaskAction @TaskAction
fun execute() { fun execute() {
val changelog = "git log --format=\"%B\" ${latestTag.trim()}..".execute() val latestTag = "git describe --tags --abbrev=0".execute()
val changelog = "git log --format=\"%B\" ${latestTag.first().trim()}..".execute()
logger.info("Latest tag: $latestTag") logger.info("Latest tag: $latestTag")
logger.info("Changelog: ${changelog.joinToString("\n")}") logger.info("Changelog: ${changelog.joinToString("\n")}")
changelogFile.get().asFile.writer().use { writer -> changelogFile.get().asFile.writer().use { writer ->
@ -48,4 +36,4 @@ abstract class ChangelogTask @Inject constructor(
.start() .start()
.inputReader() .inputReader()
.readLines() .readLines()
} }

View file

@ -41,18 +41,14 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "1.8"
} }
lint { lint {
disable += listOf( disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
warningsAsErrors = true warningsAsErrors = true
} }
} }

View file

@ -27,18 +27,14 @@ android {
compose = true compose = true
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "1.8"
} }
lint { lint {
disable += listOf( disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
warningsAsErrors = true warningsAsErrors = true
} }
} }

View file

@ -1,40 +1,43 @@
[versions] [versions]
acra = "5.12.0" acra = "5.11.3"
activityKtx = "1.9.3" activityKtx = "1.9.1"
animationCore = "1.7.5" animationCore = "1.6.8"
appcompat = "1.7.0" appcompat = "1.7.0"
billing = "7.1.1" billing = "7.0.0"
browser = "1.8.0" browser = "1.8.0"
commonMarkVersion = "0.24.0" commonMarkVersion = "0.22.0"
composeBom = "2024.11.00" composeBom = "2024.08.00"
core = "1.6.1" core = "1.6.1"
coreKtx = "1.15.0" coreKtx = "1.13.1"
coreSplashscreen = "1.0.1" coreSplashscreen = "1.0.1"
coroutines = "1.9.0" coroutines = "1.8.1"
dependencyAnalysis = "2.5.0" dependencyAnalysis = "1.33.0"
espressoVersion = "3.6.1" espressoVersion = "3.6.1"
fladle = "0.17.5" fladle = "0.17.5"
googleServices = "4.4.2" googleServices = "4.4.2"
firebaseCrashlyticsGradle = "3.0.2" firebaseCrashlyticsGradle = "3.0.2"
androidGradlePlugin = "8.7.2" androidGradlePlugin = "8.5.2"
hamcrestCore = "1.3"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.0.21" kotlin = "2.0.20"
lifecycleViewmodelKtx = "2.8.7" lifecycleViewmodelKtx = "2.8.4"
material = "1.12.0" material = "1.12.0"
material3WindowSizeClassAndroid = "1.3.1" material3WindowSizeClassAndroid = "1.2.1"
materialIconsCore = "1.7.5" materialIconsCore = "1.6.8"
maxSdk = "35" maxSdk = "35"
minSdk = "23" minSdk = "23"
monitor = "1.7.2" monitor = "1.7.2"
navigation = "2.8.4" navigationCommon = "2.7.7"
orchestrator = "1.5.1" navigationRuntimeKtx = "2.7.7"
play = "2.0.2" navigationVersion = "2.7.7"
orchestrator = "1.5.0"
play = "2.0.1"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
robolectric = "4.14.1" robolectric = "4.13"
runner = "1.6.2" runner = "1.6.2"
syllableCounter = "4.1.0" syllableCounter = "4.1.0"
timber = "5.0.1" timber = "5.0.1"
tripletPlay = "3.12.1" tripletPlay = "3.10.1"
[libraries] [libraries]
acra-advanced-scheduler = { module = "ch.acra:acra-advanced-scheduler", version.ref = "acra" } acra-advanced-scheduler = { module = "ch.acra:acra-advanced-scheduler", version.ref = "acra" }
@ -62,9 +65,9 @@ androidx-material-icons-extended = { module = "androidx.compose.material:materia
androidx-material3 = { module = "androidx.compose.material3:material3" } androidx-material3 = { module = "androidx.compose.material3:material3" }
androidx-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" } androidx-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" }
androidx-monitor = { module = "androidx.test:monitor", version.ref = "monitor" } androidx-monitor = { module = "androidx.test:monitor", version.ref = "monitor" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" } androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigationCommon" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationVersion" }
androidx-navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigation" } androidx-navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" }
androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" }
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }
@ -89,6 +92,7 @@ commonmark-ext-yaml-front-matter = { module = "org.commonmark:commonmark-ext-yam
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
foundation = { module = "androidx.compose.foundation:foundation" } foundation = { module = "androidx.compose.foundation:foundation" }
foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
hamcrest-core = { module = "org.hamcrest:hamcrest-core", version.ref = "hamcrestCore" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -1,5 +1,4 @@
storePassword= storePassword=
keyPassword= keyPassword=
keyAlias= keyAlias=
storeFile= storeFile=
publishCredentialsFile=

View file

@ -24,21 +24,17 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "1.8"
} }
lint { lint {
disable += listOf( disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
warningsAsErrors = true warningsAsErrors = true
} }
} }