Finish compose migration and remove unnecessary assets/code

This commit is contained in:
William Brawner 2023-09-12 21:20:43 -06:00
parent 493444aaab
commit ae5b13dfd0
23 changed files with 458 additions and 982 deletions

View file

@ -24,7 +24,6 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator import androidx.test.espresso.web.webdriver.Locator
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import com.wbrawner.simplemarkdown.view.activity.MainActivity
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before

View file

@ -15,7 +15,7 @@
android:theme="@style/Theme.App.Starting" android:theme="@style/Theme.App.Starting"
tools:ignore="AllowBackup" tools:ignore="AllowBackup"
tools:targetApi="n"> tools:targetApi="n">
<activity android:name=".view.activity.MainActivity" <activity android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name_short"> android:label="@string/app_name_short">
<intent-filter> <intent-filter>

View file

@ -1,11 +1,9 @@
package com.wbrawner.simplemarkdown.view.activity package com.wbrawner.simplemarkdown
import android.content.Context
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
import androidx.activity.viewModels import androidx.activity.viewModels
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
@ -22,7 +20,8 @@ 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
import androidx.compose.material3.Text import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -31,34 +30,28 @@ 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
import androidx.navigation.findNavController
import androidx.preference.PreferenceManager
import com.wbrawner.plausible.android.Plausible import com.wbrawner.plausible.android.Plausible
import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.fileHelper
import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.preferenceHelper
import com.wbrawner.simplemarkdown.ui.MainScreen import com.wbrawner.simplemarkdown.ui.MainScreen
import com.wbrawner.simplemarkdown.ui.MarkdownInfoScreen import com.wbrawner.simplemarkdown.ui.MarkdownInfoScreen
import com.wbrawner.simplemarkdown.ui.SettingsScreen 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.viewmodel.MarkdownViewModel import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
const val KEY_AUTOSAVE = "autosave"
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback { class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
private val viewModel: MarkdownViewModel by viewModels() private val viewModel: MarkdownViewModel by viewModels { MarkdownViewModel.factory(fileHelper, preferenceHelper) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { lifecycleScope.launch {
val darkMode = withContext(Dispatchers.IO) { val darkMode = withContext(Dispatchers.IO) {
val darkModeValue = getStringPref( val darkModeValue = preferenceHelper[Preference.DARK_MODE] as String
R.string.pref_key_dark_mode,
getString(R.string.pref_value_auto)
)
return@withContext when { return@withContext when {
darkModeValue.equals( darkModeValue.equals(
@ -83,18 +76,20 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
AppCompatDelegate.setDefaultNightMode(darkMode) AppCompatDelegate.setDefaultNightMode(darkMode)
} }
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val preferences = mutableMapOf<String, String>() val preferences = mutableMapOf<String, String>()
preferences["Autosave"] = sharedPreferences.getBoolean("autosave", true).toString() preferences["Autosave"] = preferenceHelper[Preference.AUTOSAVE_ENABLED].toString()
val usingCustomCss = !getStringPref(R.string.pref_custom_css, null).isNullOrBlank() val usingCustomCss = !(preferenceHelper[Preference.CUSTOM_CSS] as String?).isNullOrBlank()
preferences["Custom CSS"] = usingCustomCss.toString() preferences["Custom CSS"] = usingCustomCss.toString()
val darkModeSetting = getStringPref(R.string.pref_key_dark_mode, "auto").toString() val darkModeSetting = preferenceHelper[Preference.DARK_MODE].toString()
preferences["Dark Mode"] = darkModeSetting preferences["Dark Mode"] = darkModeSetting
preferences["Error Reports"] = preferences["Error Reports"] = preferenceHelper[Preference.ERROR_REPORTS_ENABLED].toString()
getBooleanPref(R.string.pref_key_error_reports_enabled, true).toString() preferences["Readability"] = preferenceHelper[Preference.READABILITY_ENABLED].toString()
preferences["Readability"] = getBooleanPref(R.string.readability_enabled, false).toString()
Plausible.event("settings", props = preferences, url = "/") Plausible.event("settings", props = preferences, url = "/")
setContent { setContent {
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
.collectAsState()
val readabilityEnabled by preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.collectAsState()
SimpleMarkdownTheme { SimpleMarkdownTheme {
val navController = rememberNavController() val navController = rememberNavController()
NavHost( NavHost(
@ -121,10 +116,15 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
} }
) { ) {
composable(Route.EDITOR.path) { composable(Route.EDITOR.path) {
MainScreen(navController = navController, viewModel = viewModel) MainScreen(
navController = navController,
viewModel = viewModel,
enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled
)
} }
composable(Route.SETTINGS.path) { composable(Route.SETTINGS.path) {
SettingsScreen(navController = navController) SettingsScreen(navController = navController, preferenceHelper)
} }
composable(Route.SUPPORT.path) { composable(Route.SUPPORT.path) {
SupportScreen(navController = navController) SupportScreen(navController = navController)
@ -156,15 +156,3 @@ enum class Route(
ABOUT("/about", "About", Icons.Default.Info), ABOUT("/about", "About", Icons.Default.Info),
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip), PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
} }
fun Context.getBooleanPref(@StringRes key: Int, defaultValue: Boolean) =
PreferenceManager.getDefaultSharedPreferences(this).getBoolean(
getString(key),
defaultValue
)
fun Context.getStringPref(@StringRes key: Int, defaultValue: String?) =
PreferenceManager.getDefaultSharedPreferences(this).getString(
getString(key),
defaultValue
)

View file

@ -3,7 +3,11 @@ package com.wbrawner.simplemarkdown
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import com.wbrawner.plausible.android.Plausible import com.wbrawner.plausible.android.Plausible
import com.wbrawner.simplemarkdown.utility.AndroidFileHelper
import com.wbrawner.simplemarkdown.utility.AndroidPreferenceHelper
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.PersistentTree import com.wbrawner.simplemarkdown.utility.PersistentTree
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import com.wbrawner.simplemarkdown.utility.ReviewHelper import com.wbrawner.simplemarkdown.utility.ReviewHelper
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -11,6 +15,7 @@ import timber.log.Timber
import java.io.File import java.io.File
class MarkdownApplication : Application() { class MarkdownApplication : Application() {
override fun onCreate() { override fun onCreate() {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
@ -40,5 +45,14 @@ class MarkdownApplication : Application() {
"Flavor" to BuildConfig.FLAVOR, "Flavor" to BuildConfig.FLAVOR,
"App Version" to BuildConfig.VERSION_NAME "App Version" to BuildConfig.VERSION_NAME
)) ))
fileHelper = AndroidFileHelper(this)
preferenceHelper = AndroidPreferenceHelper(this)
}
companion object {
lateinit var fileHelper: FileHelper
private set
lateinit var preferenceHelper: PreferenceHelper
private set
} }
} }

View file

@ -0,0 +1,173 @@
package com.wbrawner.simplemarkdown
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.File
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
class MarkdownViewModel(
private val fileHelper: FileHelper,
private val preferenceHelper: PreferenceHelper
) : ViewModel() {
private val _fileName = MutableStateFlow("Untitled.md")
val fileName = _fileName.asStateFlow()
private val _markdown = MutableStateFlow("")
val markdown = _markdown.asStateFlow()
private val path = MutableStateFlow<URI?>(null)
private val isDirty = AtomicBoolean(false)
private val _effects = MutableSharedFlow<Effect>()
val effects = _effects.asSharedFlow()
private val saveMutex = Mutex()
fun updateMarkdown(markdown: String?) = viewModelScope.launch {
this@MarkdownViewModel._markdown.emit(markdown ?: "")
isDirty.set(true)
}
suspend fun load(loadPath: String?) {
if (loadPath.isNullOrBlank()) {
Timber.i("No URI provided to load, attempting to load last autosaved file")
preferenceHelper[Preference.AUTOSAVE_URI]
?.let {
val autosaveUri = it as? String
if (autosaveUri.isNullOrBlank()) {
preferenceHelper[Preference.AUTOSAVE_URI] = null
} else {
Timber.d("Using uri from shared preferences: $it")
load(autosaveUri)
}
}
return
}
try {
val uri = URI.create(loadPath)
fileHelper.open(uri)
?.let { (name, content) ->
path.emit(uri)
_effects.emit(Effect.ClearText)
_fileName.emit(name)
_markdown.emit(content)
isDirty.set(false)
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath"))
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $loadPath")
}
}
suspend fun save(savePath: URI? = path.value, promptSavePath: Boolean = true): Boolean {
return saveMutex.withLock {
if (savePath == null) {
Timber.w("Attempted to save file with empty path")
if (promptSavePath) {
_effects.emit(Effect.OpenSaveDialog {})
}
return false
}
try {
val name = fileHelper.save(savePath, markdown.value)
_fileName.emit(name)
path.emit(savePath)
isDirty.set(false)
Timber.i("Saved file ${fileName.value} to uri $savePath")
Timber.i("Persisting autosave uri in shared prefs: $savePath")
preferenceHelper[Preference.AUTOSAVE_URI] = savePath
true
} catch (e: Exception) {
val message = "Failed to save file to $savePath"
Timber.e(e, message)
_effects.emit(Effect.Error(message))
false
}
}
}
suspend fun autosave() {
if (!isDirty.get()) {
Timber.d("Ignoring autosave as contents haven't changed")
return
}
if (saveMutex.isLocked) {
Timber.i("Ignoring autosave since manual save is already in progress")
return
}
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!save(promptSavePath = false)) {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val file = File(fileHelper.defaultDirectory, fileName.value).toURI()
Timber.i("No cached uri for autosave, saving to $file instead")
save(file)
}
}
suspend fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state")
if (!force && isDirty.get()) {
_effects.emit(Effect.Prompt(
"Would you like to save your changes?",
confirm = {
viewModelScope.launch {
_effects.emit(Effect.OpenSaveDialog {
reset(untitledFileName, false)
})
}
},
cancel = {
viewModelScope.launch {
reset(untitledFileName, true)
}
}
))
return
}
_fileName.emit(untitledFileName)
_markdown.emit("")
path.emit(null)
_effects.emit(Effect.ClearText)
isDirty.set(false)
Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null
}
companion object {
fun factory(fileHelper: FileHelper, preferenceHelper: PreferenceHelper): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
): T {
return MarkdownViewModel(fileHelper, preferenceHelper) as T
}
}
}
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
}
}

View file

@ -1,8 +1,8 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import android.content.Intent import android.content.Intent
import android.text.SpannableString import androidx.activity.compose.rememberLauncherForActivityResult
import android.text.style.BackgroundColorSpan import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -19,6 +19,7 @@ import androidx.compose.material.icons.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
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DismissibleNavigationDrawer
@ -35,9 +36,11 @@ import androidx.compose.material3.Scaffold
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
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -46,7 +49,6 @@ 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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -59,21 +61,95 @@ 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
import androidx.preference.PreferenceManager import com.wbrawner.simplemarkdown.MarkdownViewModel
import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route
import com.wbrawner.simplemarkdown.model.Readability import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.view.activity.Route import kotlinx.coroutines.delay
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import java.net.URI
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) { fun MainScreen(
navController: NavController,
viewModel: MarkdownViewModel,
enableAutosave: Boolean,
enableReadability: Boolean,
) {
var lockSwiping by remember { mutableStateOf(false) } var lockSwiping by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.fileName.collectAsState()
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
coroutineScope.launch {
viewModel.load(it.toString())
}
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let {
coroutineScope.launch {
viewModel.save(URI.create(it.toString()))
}
}
}
var errorMessage by remember { mutableStateOf<String?>(null) }
var promptEffect by remember { mutableStateOf<MarkdownViewModel.Effect.Prompt?>(null) }
var clearText by remember { mutableStateOf(0) }
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is MarkdownViewModel.Effect.OpenSaveDialog -> saveFileLauncher.launch(fileName)
is MarkdownViewModel.Effect.Error -> errorMessage = effect.text
is MarkdownViewModel.Effect.Prompt -> promptEffect = effect
is MarkdownViewModel.Effect.ClearText -> clearText++
}
}
}
LaunchedEffect(enableAutosave) {
if (!enableAutosave) return@LaunchedEffect
while (isActive) {
delay(30_000)
viewModel.autosave()
}
}
errorMessage?.let { message ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = { errorMessage = null }) {
Text("OK")
}
},
text = { Text(message) }
)
}
promptEffect?.let { prompt ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = {
prompt.confirm()
promptEffect = null
}) {
Text("Yes")
}
},
dismissButton = {
TextButton(onClick = {
prompt.cancel()
promptEffect = null
}) {
Text("No")
}
},
text = { Text(prompt.text) }
)
}
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState -> MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
Scaffold(topBar = { Scaffold(topBar = {
val fileName by viewModel.fileName.collectAsState()
val context = LocalContext.current val context = LocalContext.current
MarkdownTopAppBar(title = fileName, MarkdownTopAppBar(title = fileName,
backAsUp = false, backAsUp = false,
@ -82,7 +158,7 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND) val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value) shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
shareIntent.type = "text/plain" shareIntent.type = "text/plain"
startActivity( startActivity(
context, Intent.createChooser( context, Intent.createChooser(
@ -101,16 +177,24 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
onDismissRequest = { menuExpanded = false }) { onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("New") }, onClick = { DropdownMenuItem(text = { Text("New") }, onClick = {
menuExpanded = false menuExpanded = false
coroutineScope.launch {
viewModel.reset("Untitled.md")
}
}) })
DropdownMenuItem(text = { Text("Open") }, onClick = { DropdownMenuItem(text = { Text("Open") }, onClick = {
menuExpanded = false menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
}) })
DropdownMenuItem(text = { Text("Save") }, onClick = { DropdownMenuItem(text = { Text("Save") }, onClick = {
menuExpanded = false menuExpanded = false
coroutineScope.launch {
viewModel.save()
}
}) })
DropdownMenuItem(text = { Text("Save as…") }, DropdownMenuItem(text = { Text("Save as…") },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
saveFileLauncher.launch(fileName)
}) })
DropdownMenuItem(text = { DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -127,13 +211,7 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
} }
}) })
}) { paddingValues -> }) { paddingValues ->
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
val context = LocalContext.current
val enableReadability = remember {
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(PREF_KEY_READABILITY, false)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -151,8 +229,8 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
modifier = Modifier.weight(1f), state = pagerState, modifier = Modifier.weight(1f), state = pagerState,
userScrollEnabled = !lockSwiping userScrollEnabled = !lockSwiping
) { page -> ) { page ->
val markdown by viewModel.markdownUpdates.collectAsState() val markdown by viewModel.markdown.collectAsState()
var textFieldValue by remember { var textFieldValue by remember(clearText) {
val annotatedMarkdown = if (enableReadability) { val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability() markdown.annotateReadability()
} else { } else {

View file

@ -1,10 +1,8 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import android.content.SharedPreferences
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -13,8 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -29,27 +25,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference
const val PREF_KEY_AUTOSAVE = "autosave" import com.wbrawner.simplemarkdown.utility.PreferenceHelper
const val PREF_KEY_DARK_MODE = "darkMode"
const val PREF_KEY_ERROR_REPORTS = "crashlytics.enable"
const val PREF_KEY_ANALYTICS = "analytics.enable"
const val PREF_KEY_READABILITY = "readability.enable"
@Composable @Composable
fun SettingsScreen(navController: NavController) { fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
Scaffold(topBar = { Scaffold(topBar = {
MarkdownTopAppBar(title = "Settings", navController = navController) MarkdownTopAppBar(title = "Settings", navController = navController)
}) { paddingValues -> }) { paddingValues ->
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -60,37 +47,35 @@ fun SettingsScreen(navController: NavController) {
title = "Autosave", title = "Autosave",
enabledDescription = "Files will be saved automatically", enabledDescription = "Files will be saved automatically",
disabledDescription = "Files will not be saved automatically", disabledDescription = "Files will not be saved automatically",
preferenceKey = PREF_KEY_AUTOSAVE, preference = Preference.AUTOSAVE_ENABLED,
sharedPreferences = sharedPreferences preferenceHelper = preferenceHelper
) )
ListPreference( ListPreference(
title = "Dark mode", title = "Dark mode",
options = listOf("Auto", "Dark", "Light"), options = listOf("auto", "dark", "light"),
defaultValue = "Auto", preference = Preference.DARK_MODE,
preferenceKey = PREF_KEY_DARK_MODE, preferenceHelper = preferenceHelper
sharedPreferences = sharedPreferences
) )
BooleanPreference( BooleanPreference(
title = "Send crash reports", title = "Send crash reports",
enabledDescription = "Error reports will be sent", enabledDescription = "Error reports will be sent",
disabledDescription = "Error reports will not be sent", disabledDescription = "Error reports will not be sent",
preferenceKey = PREF_KEY_ERROR_REPORTS, preference = Preference.ERROR_REPORTS_ENABLED,
sharedPreferences = sharedPreferences preferenceHelper = preferenceHelper
) )
BooleanPreference( BooleanPreference(
title = "Send analytics", title = "Send analytics",
enabledDescription = "Analytics events will be sent", enabledDescription = "Analytics events will be sent",
disabledDescription = "Analytics events will not be sent", disabledDescription = "Analytics events will not be sent",
preferenceKey = PREF_KEY_ANALYTICS, preference = Preference.ANALYTICS_ENABLED,
sharedPreferences = sharedPreferences preferenceHelper = preferenceHelper
) )
BooleanPreference( BooleanPreference(
title = "Readability highlighting", title = "Readability highlighting",
enabledDescription = "Readability highlighting is on", enabledDescription = "Readability highlighting is on",
disabledDescription = "Readability highlighting is off", disabledDescription = "Readability highlighting is off",
preferenceKey = PREF_KEY_READABILITY, preference = Preference.READABILITY_ENABLED,
sharedPreferences = sharedPreferences, preferenceHelper = preferenceHelper
defaultValue = false
) )
} }
} }
@ -101,16 +86,11 @@ fun BooleanPreference(
title: String, title: String,
enabledDescription: String, enabledDescription: String,
disabledDescription: String, disabledDescription: String,
preferenceKey: String, preference: Preference,
sharedPreferences: SharedPreferences, preferenceHelper: PreferenceHelper
defaultValue: Boolean = true
) { ) {
var enabled by remember { var enabled by remember {
mutableStateOf( mutableStateOf(preferenceHelper[preference] as Boolean)
sharedPreferences.getBoolean(
preferenceKey, defaultValue
)
)
} }
BooleanPreference(title = title, BooleanPreference(title = title,
enabledDescription = enabledDescription, enabledDescription = enabledDescription,
@ -118,9 +98,7 @@ fun BooleanPreference(
enabled = enabled, enabled = enabled,
setEnabled = { setEnabled = {
enabled = it enabled = it
sharedPreferences.edit { preferenceHelper[preference] = it
putBoolean(preferenceKey, it)
}
}) })
} }
@ -156,27 +134,19 @@ fun BooleanPreference(
fun ListPreference( fun ListPreference(
title: String, title: String,
options: List<String>, options: List<String>,
defaultValue: String, preference: Preference,
preferenceKey: String, preferenceHelper: PreferenceHelper
sharedPreferences: SharedPreferences
) { ) {
var selected by remember { var selected by remember {
mutableStateOf( mutableStateOf(preferenceHelper[preference] as String)
sharedPreferences.getString(
preferenceKey, defaultValue
) ?: defaultValue
)
} }
ListPreference(title = title, options = options, selected = selected, setSelected = { ListPreference(title = title, options = options, selected = selected, setSelected = {
selected = it selected = it
sharedPreferences.edit { preferenceHelper[preference] = it
putString(preferenceKey, it)
}
}) })
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListPreference( fun ListPreference(
title: String, options: List<String>, selected: String, setSelected: (String) -> Unit title: String, options: List<String>, selected: String, setSelected: (String) -> Unit

View file

@ -0,0 +1,59 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.Reader
import java.net.URI
interface FileHelper {
val defaultDirectory: File
/**
* Opens a file at the given path
* @param path The path of the file to open
* @return A [Pair] of the file name to the file's contents
*/
suspend fun open(source: URI): Pair<String, String>?
/**
* Saves the given content to the given path
* @param path
* @param content
* @return The name of the saved file
*/
suspend fun save(destination: URI, content: String): String
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File = context.filesDir
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()
context.contentResolver.openFileDescriptor(uri, "r")
?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
.use(Reader::readText)
}
}
override suspend fun save(destination: URI, content: String): String = withContext(Dispatchers.IO) {
val uri = destination.toString().toUri()
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(content)
}
?: run {
Timber.w("Open output stream returned null for uri: $uri")
throw IOException("Failed to save to $destination")
}
return@withContext uri.getName(context)
}
}

View file

@ -0,0 +1,67 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.net.Uri
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
interface PreferenceHelper {
operator fun get(preference: Preference): Any?
operator fun set(preference: Preference, value: Any?)
fun <T> observe(preference: Preference): StateFlow<T>
}
class AndroidPreferenceHelper(context: Context, val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val states = mapOf(
Preference.ANALYTICS_ENABLED to MutableStateFlow(get(Preference.ANALYTICS_ENABLED)),
Preference.AUTOSAVE_ENABLED to MutableStateFlow(get(Preference.AUTOSAVE_ENABLED)),
Preference.AUTOSAVE_URI to MutableStateFlow(get(Preference.AUTOSAVE_URI)),
Preference.CUSTOM_CSS to MutableStateFlow(get(Preference.CUSTOM_CSS)),
Preference.DARK_MODE to MutableStateFlow(get(Preference.DARK_MODE)),
Preference.ERROR_REPORTS_ENABLED to MutableStateFlow(get(Preference.ERROR_REPORTS_ENABLED)),
Preference.READABILITY_ENABLED to MutableStateFlow(get(Preference.READABILITY_ENABLED)),
)
override fun get(preference: Preference): Any? = sharedPreferences.all[preference.key]?: preference.default
override fun set(preference: Preference, value: Any?) {
sharedPreferences.edit {
when (value) {
is Boolean -> putBoolean(preference.key, value)
is Float -> putFloat(preference.key, value)
is Int -> putInt(preference.key, value)
is Long -> putLong(preference.key, value)
is String -> putString(preference.key, value)
null -> remove(preference.key)
}
}
coroutineScope.launch {
states[preference]!!.emit(value)
}
}
override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T>
}
enum class Preference(val key: String, val default: Any?) {
ANALYTICS_ENABLED("analytics.enable", true),
AUTOSAVE_ENABLED("autosave", true),
AUTOSAVE_URI("autosave.uri", null),
CUSTOM_CSS("pref.custom_css", null),
DARK_MODE("darkMode", "Auto"),
ERROR_REPORTS_ENABLED("crashlytics.enable", true),
READABILITY_ENABLED("readability.enable", false)
}

View file

@ -1,179 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.annotation.SuppressLint
//import android.graphics.Color
//import android.os.Bundle
//import android.text.Editable
//import android.text.SpannableString
//import android.text.TextWatcher
//import android.text.style.BackgroundColorSpan
//import android.view.LayoutInflater
//import android.view.MotionEvent
//import android.view.View
//import android.view.ViewGroup
//import android.widget.EditText
//import android.widget.TextView
//import androidx.core.widget.NestedScrollView
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.Observer
//import androidx.lifecycle.lifecycleScope
//import androidx.preference.PreferenceManager
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.model.Readability
//import com.wbrawner.simplemarkdown.utility.hideKeyboard
//import com.wbrawner.simplemarkdown.utility.showKeyboard
//import com.wbrawner.simplemarkdown.view.ViewPagerPage
//import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
//import kotlinx.coroutines.*
//import timber.log.Timber
//import kotlin.math.abs
//
//class EditFragment : Fragment(), ViewPagerPage {
// private var markdownEditor: EditText? = null
// private var markdownEditorScroller: NestedScrollView? = null
// private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
// private var readabilityWatcher: TextWatcher? = null
// private val markdownWatcher = object : TextWatcher {
// private var searchFor = ""
//
// override fun afterTextChanged(s: Editable?) {
// val searchText = s.toString().trim()
// if (searchText == searchFor)
// return
//
// searchFor = searchText
//
// lifecycleScope.launch {
// delay(50)
// if (searchText != searchFor)
// return@launch
// viewModel.updateMarkdown(searchText)
// }
// }
//
// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// }
//
// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// }
// }
//
// @SuppressLint("ClickableViewAccessibility")
// override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
// savedInstanceState: Bundle?): View? =
// inflater.inflate(R.layout.fragment_edit, container, false)
//
// @SuppressLint("ClickableViewAccessibility")
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// super.onViewCreated(view, savedInstanceState)
// markdownEditor = view.findViewById(R.id.markdown_edit)
// markdownEditorScroller = view.findViewById(R.id.markdown_edit_container)
// markdownEditor?.addTextChangedListener(markdownWatcher)
//
// var touchDown = 0L
// var oldX = 0f
// var oldY = 0f
// markdownEditorScroller!!.setOnTouchListener { _, event ->
// // The ScrollView's onClickListener doesn't seem to be called, so I've had to
// // implement a sort of custom click listener that checks that the tap was both quick
// // and didn't drag.
// when (event?.action) {
// MotionEvent.ACTION_DOWN -> {
// touchDown = System.currentTimeMillis()
// oldX = event.rawX
// oldY = event.rawY
// }
// MotionEvent.ACTION_UP -> {
// if (System.currentTimeMillis() - touchDown < 150
// && abs(event.rawX - oldX) < 25
// && abs(event.rawY - oldY) < 25)
// markdownEditor?.showKeyboard()
// }
// }
// false
// }
// markdownEditor?.setText(viewModel.markdownUpdates.value)
// viewModel.editorActions.observe(viewLifecycleOwner, Observer {
// if (it.consumed.getAndSet(true)) return@Observer
// if (it is MarkdownViewModel.EditorAction.Load) {
// markdownEditor?.apply {
// removeTextChangedListener(markdownWatcher)
// setText(it.markdown)
// addTextChangedListener(markdownWatcher)
// }
// }
// })
// lifecycleScope.launch {
// val enableReadability = withContext(Dispatchers.IO) {
// context?.let {
// PreferenceManager.getDefaultSharedPreferences(it)
// .getBoolean(getString(R.string.readability_enabled), false)
// } ?: false
// }
// if (enableReadability) {
// if (readabilityWatcher == null) {
// readabilityWatcher = ReadabilityTextWatcher()
// }
// markdownEditor?.addTextChangedListener(readabilityWatcher)
// } else {
// readabilityWatcher?.let {
// markdownEditor?.removeTextChangedListener(it)
// }
// readabilityWatcher = null
// }
// }
// }
//
// override fun onSelected() {
// markdownEditor?.showKeyboard()
// }
//
// override fun onDeselected() {
// markdownEditor?.hideKeyboard()
// }
//
// inner class ReadabilityTextWatcher : TextWatcher {
// private var previousValue = ""
// private var searchFor = ""
//
// override fun afterTextChanged(s: Editable?) {
// val searchText = s.toString().trim()
// if (searchText == searchFor)
// return
//
// searchFor = searchText
//
// lifecycleScope.launch {
// delay(250)
// if (searchText != searchFor)
// return@launch
// val start = System.currentTimeMillis()
// if (searchFor.isEmpty()) return@launch
// if (previousValue == searchFor) return@launch
// val readability = Readability(searchFor)
// val span = SpannableString(searchFor)
// for (sentence in readability.sentences()) {
// var color = Color.TRANSPARENT
// if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42)
// if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66)
// Timber.d("Sentence start: ${sentence.start()} end: ${
// sentence
// .end()
// }")
// span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
// }
// markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
// previousValue = searchFor
// val timeTakenMs = System.currentTimeMillis() - start
// Timber.d("Handled markdown in $timeTakenMs ms")
// }
// }
//
// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// }
//
// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// }
// }
//}

View file

@ -1,335 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.app.Activity
//import android.content.Context
//import android.content.Intent
//import android.content.pm.PackageManager
//import android.content.res.Configuration
//import android.os.Build
//import android.os.Bundle
//import android.view.*
//import android.webkit.MimeTypeMap
//import android.widget.Toast
//import androidx.appcompat.app.AlertDialog
//import androidx.appcompat.app.AppCompatActivity
//import androidx.core.app.ActivityCompat
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.lifecycleScope
//import androidx.navigation.fragment.findNavController
//import androidx.navigation.ui.AppBarConfiguration
//import androidx.navigation.ui.setupWithNavController
//import androidx.preference.PreferenceManager
//import com.wbrawner.plausible.android.Plausible
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.databinding.FragmentMainBinding
//import com.wbrawner.simplemarkdown.utility.ErrorHandler
//import com.wbrawner.simplemarkdown.utility.errorHandlerImpl
//import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
//import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.launch
//import kotlinx.coroutines.withContext
//import timber.log.Timber
//
//class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback {
//
// private val viewModel: MarkdownViewModel by viewModels()
// private var appBarConfiguration: AppBarConfiguration? = null
// private val errorHandler: ErrorHandler by errorHandlerImpl()
// private var _binding: FragmentMainBinding? = null
// private val binding: FragmentMainBinding
// get() = _binding!!
//
// override fun onAttach(context: Context) {
// super.onAttach(context)
// if (context !is Activity) return
// lifecycleScope.launch {
// viewModel.load(context, context.intent?.data)
// context.intent?.data = null
// }
// }
//
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// setHasOptionsMenu(true)
// }
//
// override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// inflater.inflate(R.menu.menu_edit, menu)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// menu.findItem(R.id.action_save_as)
// ?.setAlphabeticShortcut('S', KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)
// }
// }
//
// override fun onCreateView(
// inflater: LayoutInflater,
// container: ViewGroup?,
// savedInstanceState: Bundle?
// ): View {
// _binding = FragmentMainBinding.inflate(inflater, container, false)
// return binding.root
// }
//
// override fun onDestroyView() {
// super.onDestroyView()
// _binding = null
// }
//
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// with(findNavController()) {
// appBarConfiguration = AppBarConfiguration(graph, binding.drawerLayout)
// binding.toolbar.setupWithNavController(this, appBarConfiguration!!)
// (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
// binding.navigationView.setupWithNavController(this)
// }
// val adapter = EditPagerAdapter(childFragmentManager, view.context)
// binding.pager.adapter = adapter
// binding.pager.addOnPageChangeListener(adapter)
// binding.pager.pageMargin = 1
// binding.pager.setPageMarginDrawable(R.color.colorAccent)
// binding.tabLayout.setupWithViewPager(binding.pager)
// if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
// binding.tabLayout.visibility = View.GONE
// }
// @Suppress("CAST_NEVER_SUCCEEDS")
// viewModel.fileName.observe(viewLifecycleOwner) {
// binding.toolbar.title = it
// }
// }
//
// override fun onOptionsItemSelected(item: MenuItem): Boolean {
// return when (item.itemId) {
// android.R.id.home -> {
// binding.drawerLayout.open()
// true
// }
//
// R.id.action_save -> {
// Timber.d("Save clicked")
// lifecycleScope.launch {
// if (!viewModel.save(requireContext())) {
// requestFileOp(REQUEST_SAVE_FILE)
// } else {
// Toast.makeText(
// requireContext(),
// getString(R.string.file_saved, viewModel.fileName.value),
// Toast.LENGTH_SHORT
// ).show()
// }
// }
// true
// }
//
// R.id.action_save_as -> {
// Timber.d("Save as clicked")
// requestFileOp(REQUEST_SAVE_FILE)
// true
// }
//
// R.id.action_share -> {
// Timber.d("Share clicked")
// val shareIntent = Intent(Intent.ACTION_SEND)
// shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value)
// shareIntent.type = "text/plain"
// startActivity(
// Intent.createChooser(
// shareIntent,
// getString(R.string.share_file)
// )
// )
// true
// }
//
// R.id.action_load -> {
// Timber.d("Load clicked")
// requestFileOp(REQUEST_OPEN_FILE)
// true
// }
//
// R.id.action_new -> {
// Timber.d("New clicked")
// promptSaveOrDiscardChanges()
// true
// }
//
// R.id.action_lock_swipe -> {
// Timber.d("Lock swiping clicked")
// item.isChecked = !item.isChecked
// binding.pager.setSwipeLocked(item.isChecked)
// true
// }
//
// else -> super.onOptionsItemSelected(item)
// }
// }
//
// override fun onStart() {
// super.onStart()
// Plausible.pageView("")
// lifecycleScope.launch {
// withContext(Dispatchers.IO) {
// val enableErrorReports =
// PreferenceManager.getDefaultSharedPreferences(requireContext())
// .getBoolean(getString(R.string.pref_key_error_reports_enabled), true)
// Timber.d("MainFragment started. Error reports enabled? $enableErrorReports")
// errorHandler.enable(enableErrorReports)
// }
// }
// }
//
// override fun onStop() {
// super.onStop()
// val context = context?.applicationContext ?: return
// lifecycleScope.launch {
// viewModel.autosave(context, PreferenceManager.getDefaultSharedPreferences(context))
// }
// }
//
// override fun onConfigurationChanged(newConfig: Configuration) {
// super.onConfigurationChanged(newConfig)
// if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
// Timber.d("Orientation changed to landscape, hiding tabs")
// binding.tabLayout.visibility = View.GONE
// } else {
// Timber.d("Orientation changed to portrait, showing tabs")
// binding.tabLayout.visibility = View.VISIBLE
// }
// }
//
// override fun onRequestPermissionsResult(
// requestCode: Int,
// permissions: Array<String>,
// grantResults: IntArray
// ) {
// when (requestCode) {
// REQUEST_SAVE_FILE, REQUEST_OPEN_FILE -> {
// // If request is cancelled, the result arrays are empty.
// if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// // Permission granted, open file save dialog
// Timber.d("Storage permissions granted")
// requestFileOp(requestCode)
// } else {
// // Permission denied, do nothing
// Timber.d("Storage permissions denied, unable to save or load files")
// context?.let {
// Toast.makeText(it, R.string.no_permissions, Toast.LENGTH_SHORT)
// .show()
// }
// }
// }
// }
// }
//
// override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// when (requestCode) {
// REQUEST_OPEN_FILE -> {
// if (resultCode != Activity.RESULT_OK || data?.data == null) {
// Timber.w(
// "Unable to open file. Result ok? %b Intent uri: %s",
// resultCode == Activity.RESULT_OK,
// data?.data?.toString()
// )
// return
// }
//
// lifecycleScope.launch {
// context?.let {
// if (!viewModel.load(it, data.data)) {
// Toast.makeText(it, R.string.file_load_error, Toast.LENGTH_SHORT).show()
// }
// }
// }
// }
//
// REQUEST_SAVE_FILE -> {
// if (resultCode != Activity.RESULT_OK || data?.data == null) {
// Timber.w(
// "Unable to save file. Result ok? %b Intent uri: %s",
// resultCode == Activity.RESULT_OK,
// data?.data?.toString()
// )
// return
// }
//
// lifecycleScope.launch {
// context?.let {
// viewModel.save(it, data.data)
// }
// }
// }
// }
// super.onActivityResult(requestCode, resultCode, data)
// }
//
// private fun promptSaveOrDiscardChanges() {
// if (!viewModel.shouldPromptSave()) {
// viewModel.reset(
// "Untitled.md",
// PreferenceManager.getDefaultSharedPreferences(requireContext())
// )
// return
// }
// val context = context ?: run {
// Timber.w("Context is null, unable to show prompt for save or discard")
// return
// }
// AlertDialog.Builder(context)
// .setTitle(R.string.save_changes)
// .setMessage(R.string.prompt_save_changes)
// .setNegativeButton(R.string.action_discard) { _, _ ->
// Timber.d("Discarding changes")
// viewModel.reset(
// "Untitled.md",
// PreferenceManager.getDefaultSharedPreferences(requireContext())
// )
// }
// .setPositiveButton(R.string.action_save) { _, _ ->
// Timber.d("Saving changes")
// requestFileOp(REQUEST_SAVE_FILE)
// }
// .create()
// .show()
// }
//
// private fun requestFileOp(requestType: Int) {
// val intent = when (requestType) {
// REQUEST_SAVE_FILE -> {
// Timber.d("Requesting save op")
// Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
// type = "text/markdown"
// putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
// }
// }
//
// REQUEST_OPEN_FILE -> {
// Timber.d("Requesting open op")
// Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// type = "*/*"
// if (MimeTypeMap.getSingleton().hasMimeType("md")) {
// // If the device doesn't recognize markdown files then we're not going to be
// // able to open them at all, so there's no sense in filtering them out.
// putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/plain", "text/markdown"))
// }
// }
// }
//
// else -> {
// Timber.w("Ignoring unknown file op request: $requestType")
// null
// }
// } ?: return
// intent.addCategory(Intent.CATEGORY_OPENABLE)
// startActivityForResult(
// intent,
// requestType
// )
// }
//
// companion object {
// // Request codes
// const val REQUEST_OPEN_FILE = 1
// const val REQUEST_SAVE_FILE = 2
// }
//}

View file

@ -1,96 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.content.Context
//import android.content.res.Configuration.UI_MODE_NIGHT_MASK
//import android.content.res.Configuration.UI_MODE_NIGHT_YES
//import android.os.Bundle
//import android.view.LayoutInflater
//import android.view.View
//import android.view.ViewGroup
//import android.webkit.WebView
//import androidx.appcompat.app.AppCompatDelegate
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.lifecycleScope
//import androidx.preference.PreferenceManager
//import com.wbrawner.simplemarkdown.BuildConfig
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.utility.toHtml
//import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
//import kotlinx.coroutines.*
//
//class PreviewFragment : Fragment() {
// private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
// private var markdownPreview: WebView? = null
// private var style: String = ""
//
// override fun onCreateView(
// inflater: LayoutInflater,
// container: ViewGroup?,
// savedInstanceState: Bundle?
// ): View? = inflater.inflate(R.layout.fragment_preview, container, false)
//
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// markdownPreview = view.findViewById(R.id.markdown_view)
// WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
// lifecycleScope.launch {
// val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
// AppCompatDelegate.MODE_NIGHT_YES
// || requireContext().resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
// val defaultCssId = if (isNightMode) {
// R.string.pref_custom_css_default_dark
// } else {
// R.string.pref_custom_css_default
// }
// val css = withContext(Dispatchers.IO) {
// val context = context ?: return@withContext null
// @Suppress("ConstantConditionIf")
// if (!BuildConfig.ENABLE_CUSTOM_CSS) {
// context.getString(defaultCssId)
// } else {
// PreferenceManager.getDefaultSharedPreferences(context)
// .getString(
// getString(R.string.pref_custom_css),
// getString(defaultCssId)
// )
// }
// }
// style = String.format(FORMAT_CSS, css ?: "")
// }
// }
//
// override fun onAttach(context: Context) {
// super.onAttach(context)
// updateWebContent(viewModel.markdownUpdates.value ?: "")
//// viewModel.markdownUpdates.observe(this, {
//// updateWebContent(it)
//// })
// }
//
// private fun updateWebContent(markdown: String) {
// markdownPreview?.post {
// lifecycleScope.launch {
// markdownPreview?.loadDataWithBaseURL(null,
// style + markdown.toHtml(),
// "text/html",
// "UTF-8", null
// )
// }
// }
// }
//
// override fun onDestroyView() {
// markdownPreview?.let {
// (it.parent as ViewGroup).removeView(it)
// it.destroy()
// markdownPreview = null
// }
// super.onDestroyView()
// }
//
// companion object {
// var FORMAT_CSS = "<style>" +
// "%s" +
// "</style>"
// }
//}

View file

@ -1,178 +0,0 @@
package com.wbrawner.simplemarkdown.viewmodel
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.utility.getName
import com.wbrawner.simplemarkdown.view.activity.KEY_AUTOSAVE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.Reader
import java.util.concurrent.atomic.AtomicBoolean
const val PREF_KEY_AUTOSAVE_URI = "autosave.uri"
class MarkdownViewModel(val timber: Timber.Tree = Timber.asTree()) : ViewModel() {
val fileName = MutableStateFlow("Untitled.md")
val markdownUpdates = MutableStateFlow("")
val editorActions = MutableLiveData<EditorAction>()
val uri = MutableLiveData<Uri?>()
private val isDirty = AtomicBoolean(false)
private val saveMutex = Mutex()
init {
markdownUpdates
}
fun updateMarkdown(markdown: String?) = viewModelScope.launch {
markdownUpdates.emit(markdown ?: "")
isDirty.set(true)
}
suspend fun load(
context: Context,
uri: Uri?,
sharedPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
): Boolean {
if (uri == null) {
timber.i("No URI provided to load, attempting to load last autosaved file")
sharedPrefs.getString(PREF_KEY_AUTOSAVE_URI, null)
?.let {
Timber.d("Using uri from shared preferences: $it")
return load(context, Uri.parse(it), sharedPrefs)
} ?: return false
}
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
val fileInput = FileInputStream(it.fileDescriptor)
val fileName = uri.getName(context)
val content = fileInput.reader().use(Reader::readText)
if (content.isBlank()) {
// If we don't get anything back, then we can assume that reading the file failed
timber.i("Ignoring load for empty file $fileName from $fileInput")
return@withContext false
}
editorActions.postValue(EditorAction.Load(content))
markdownUpdates.emit(content)
this@MarkdownViewModel.fileName.emit(fileName)
this@MarkdownViewModel.uri.postValue(uri)
timber.i("Loaded file $fileName from $fileInput")
timber.v("File contents:\n$content")
isDirty.set(false)
timber.i("Persisting autosave uri in shared prefs: $uri")
sharedPrefs.edit()
.putString(PREF_KEY_AUTOSAVE_URI, uri.toString())
.apply()
true
} ?: run {
timber.w("Open file descriptor returned null for uri: $uri")
false
}
} catch (e: Exception) {
timber.e(e, "Failed to open file descriptor for uri: $uri")
false
}
}
}
suspend fun save(
context: Context,
givenUri: Uri? = null,
sharedPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
): Boolean = saveMutex.withLock {
val uri = givenUri?.let {
timber.i("Saving file with given uri: $it")
it
} ?: this.uri.value?.let {
timber.i("Saving file with cached uri: $it")
it
} ?: run {
timber.w("Save called with no uri")
return@save false
}
return withContext(Dispatchers.IO) {
try {
val fileName = uri.getName(context)
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(markdownUpdates.value ?: "")
}
?: run {
timber.w("Open output stream returned null for uri: $uri")
return@withContext false
}
this@MarkdownViewModel.fileName.emit(fileName)
this@MarkdownViewModel.uri.postValue(uri)
isDirty.set(false)
timber.i("Saved file $fileName to uri $uri")
timber.i("Persisting autosave uri in shared prefs: $uri")
sharedPrefs.edit()
.putString(PREF_KEY_AUTOSAVE_URI, uri.toString())
.apply()
true
} catch (e: Exception) {
timber.e(e, "Failed to save file at uri: $uri")
false
}
}
}
suspend fun autosave(context: Context, sharedPrefs: SharedPreferences) {
if (saveMutex.isLocked) {
timber.i("Ignoring autosave since manual save is already in progress")
return
}
val isAutoSaveEnabled = sharedPrefs.getBoolean(KEY_AUTOSAVE, true)
timber.d("Autosave called. isEnabled? $isAutoSaveEnabled")
if (!isDirty.get() || !isAutoSaveEnabled) {
timber.i("Ignoring call to autosave. Contents haven't changed or autosave not enabled")
return
}
if (save(context)) {
timber.i("Autosave with cached uri succeeded: ${uri.value}")
} else {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val fileUri = Uri.fromFile(File(context.filesDir, fileName.value ?: "Untitled.md"))
timber.i("No cached uri for autosave, saving to $fileUri instead")
save(context, fileUri)
}
}
fun reset(untitledFileName: String, sharedPrefs: SharedPreferences) = viewModelScope.launch{
timber.i("Resetting view model to default state")
fileName.tryEmit(untitledFileName)
uri.postValue(null)
markdownUpdates.emit("")
editorActions.postValue(EditorAction.Load(""))
isDirty.set(false)
timber.i("Removing autosave uri from shared prefs")
sharedPrefs.edit {
remove(PREF_KEY_AUTOSAVE_URI)
}
}
fun shouldPromptSave() = isDirty.get()
sealed class EditorAction {
val consumed = AtomicBoolean(false)
data class Load(val markdown: String) : EditorAction()
}
}

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:top="0dp"
android:bottom="0dp"
android:left="0dp"
android:right="0dp"
android:drawable="@color/colorPrimary"/>
<item
android:drawable="@drawable/splash_fg"
android:gravity="center"/>
</layer-list>

View file

@ -9,7 +9,7 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.play.core.review.ReviewManager import com.google.android.play.core.review.ReviewManager
import com.google.android.play.core.review.ReviewManagerFactory import com.google.android.play.core.review.ReviewManagerFactory
import com.wbrawner.simplemarkdown.view.activity.MainActivity import com.wbrawner.simplemarkdown.MainActivity
import timber.log.Timber import timber.log.Timber
private const val KEY_TIME_IN_APP = "timeInApp" private const val KEY_TIME_IN_APP = "timeInApp"