WIP: Migrate to ViewModel architecture

Signed-off-by: Billy Brawner <billy@wbrawner.com>
This commit is contained in:
Billy Brawner 2019-08-18 18:56:21 -07:00 committed by William Brawner
parent f03a91c1d3
commit 04954b96f7
16 changed files with 427 additions and 489 deletions

View file

@ -101,7 +101,10 @@ dependencies {
def coroutines_version = "1.3.0-RC2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
def lifecycle_version = "2.0.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
implementation 'eu.crydee:syllable-counter:4.0.2'
}

View file

@ -1,28 +0,0 @@
package com.wbrawner.simplemarkdown
import android.content.Context
import com.wbrawner.simplemarkdown.view.activity.MainActivity
import com.wbrawner.simplemarkdown.view.activity.SplashActivity
import com.wbrawner.simplemarkdown.view.fragment.EditFragment
import com.wbrawner.simplemarkdown.view.fragment.PreviewFragment
import dagger.BindsInstance
import dagger.Component
import javax.inject.Singleton
@Singleton
@Component(modules = [AppModule::class])
interface AppComponent {
fun inject(application: MarkdownApplication)
fun inject(activity: MainActivity)
fun inject(activity: SplashActivity)
fun inject(fragment: EditFragment)
fun inject(fragment: PreviewFragment)
@Component.Builder
abstract class Builder {
@BindsInstance
internal abstract fun context(context: Context): Builder
internal abstract fun build(): AppComponent
}
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.simplemarkdown
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenterImpl
import com.wbrawner.simplemarkdown.utility.CrashlyticsErrorHandler
import com.wbrawner.simplemarkdown.utility.ErrorHandler
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@Module
class AppModule {
@Provides
@Singleton
fun provideMarkdownPresenter(errorHandler: ErrorHandler): MarkdownPresenter {
return MarkdownPresenterImpl(errorHandler)
}
@Provides
@Singleton
internal fun provideErrorHandler(): ErrorHandler {
return CrashlyticsErrorHandler()
}
}

View file

@ -1,16 +1,26 @@
package com.wbrawner.simplemarkdown
import android.app.Application
import android.os.StrictMode
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModelFactory
class MarkdownApplication : Application() {
lateinit var component: AppComponent
private set
val viewModelFactory: MarkdownViewModelFactory by lazy {
MarkdownViewModelFactory()
}
override fun onCreate() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyDeath()
.build())
// StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
// .detectAll()
// .penaltyLog()
// .penaltyDeath()
// .build())
}
super.onCreate()
component = DaggerAppComponent.builder()
.context(this)
.build()
}
}

View file

@ -10,25 +10,4 @@ import java.io.Reader
*/
class MarkdownFile(var name: String = "Untitled.md", var content: String = "") {
fun load(name: String, inputStream: InputStream): Boolean {
this.name = name
return try {
this.content = inputStream.reader().use(Reader::readText)
true
} catch (e: Throwable) {
false
}
}
fun save(name: String, outputStream: OutputStream): Boolean {
this.name = name
return try {
outputStream.writer().use {
it.write(this.content)
}
true
} catch (e: Throwable) {
false
}
}
}

View file

@ -1,39 +0,0 @@
package com.wbrawner.simplemarkdown.presentation
import android.content.Context
import android.net.Uri
import java.io.InputStream
import java.io.OutputStream
interface MarkdownPresenter {
var fileName: String
var markdown: String
var editView: MarkdownEditView?
var previewView: MarkdownPreviewView?
suspend fun loadFromUri(context: Context, fileUri: Uri): String?
suspend fun loadMarkdown(
fileName: String,
`in`: InputStream,
replaceCurrentFile: Boolean = true
): String?
fun newFile(newName: String)
suspend fun saveMarkdown(name: String, outputStream: OutputStream): Boolean
fun onMarkdownEdited(markdown: String? = null)
fun generateHTML(markdown: String = ""): String
}
interface MarkdownEditView {
var markdown: String
fun setTitle(title: String)
fun onFileSaved(success: Boolean)
fun onFileLoaded(success: Boolean)
}
interface MarkdownPreviewView {
fun updatePreview(html: String)
}

View file

@ -1,127 +0,0 @@
package com.wbrawner.simplemarkdown.presentation
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import com.commonsware.cwac.anddown.AndDown
import com.wbrawner.simplemarkdown.model.MarkdownFile
import com.wbrawner.simplemarkdown.utility.ErrorHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MarkdownPresenterImpl @Inject constructor(private val errorHandler: ErrorHandler) : MarkdownPresenter {
@Volatile
private var file: MarkdownFile = MarkdownFile()
@Volatile
override var editView: MarkdownEditView? = null
set(value) {
field = value
onMarkdownEdited(null)
}
@Volatile
override var previewView: MarkdownPreviewView? = null
override var fileName: String
get() = file.name
set(name) {
file.name = name
}
override var markdown: String
get() = file.content
set(markdown) {
file.content = markdown
}
override suspend fun loadMarkdown(
fileName: String,
`in`: InputStream,
replaceCurrentFile: Boolean
): String? {
val tmpFile = MarkdownFile()
withContext(Dispatchers.IO) {
if (!tmpFile.load(fileName, `in`)) {
throw RuntimeException("Failed to load markdown")
}
}
if (replaceCurrentFile) {
this.file = tmpFile
editView?.let {
it.onFileLoaded(true)
it.setTitle(fileName)
it.markdown = file.content
onMarkdownEdited(file.content)
}
}
return generateHTML(tmpFile.content)
}
override fun newFile(newName: String) {
editView?.let {
file.content = it.markdown
it.setTitle(newName)
it.markdown = ""
}
file = MarkdownFile(newName, "")
}
override suspend fun saveMarkdown(name: String, outputStream: OutputStream): Boolean {
val result = withContext(Dispatchers.IO) {
file.save(name, outputStream)
}
editView?.let {
it.setTitle(file.name)
it.onFileSaved(result)
}
return result
}
override fun onMarkdownEdited(markdown: String?) {
this.markdown = markdown ?: file.content
previewView?.updatePreview(generateHTML(this.markdown))
}
override fun generateHTML(markdown: String): String {
return AndDown().markdownToHtml(markdown, HOEDOWN_FLAGS, 0)
}
override suspend fun loadFromUri(context: Context, fileUri: Uri): String? {
return try {
var fileName: String? = null
if ("content" == fileUri.scheme) {
context.contentResolver
.query(
fileUri,
null,
null,
null,
null
)
?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst()
fileName = it.getString(nameIndex)
}
} else if ("file" == fileUri.scheme) {
fileName = fileUri.lastPathSegment
}
val inputStream = context.contentResolver.openInputStream(fileUri) ?: return null
loadMarkdown(fileName ?: "Untitled.md", inputStream, true)
} catch (e: Exception) {
errorHandler.reportException(e)
editView?.onFileLoaded(false)
null
}
}
companion object {
const val HOEDOWN_FLAGS = AndDown.HOEDOWN_EXT_STRIKETHROUGH or AndDown.HOEDOWN_EXT_TABLES or
AndDown.HOEDOWN_EXT_UNDERLINE or AndDown.HOEDOWN_EXT_SUPERSCRIPT or
AndDown.HOEDOWN_EXT_FENCED_CODE
}
}

View file

@ -1,8 +1,15 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.content.res.AssetManager
import android.net.Uri
import android.provider.OpenableColumns
import android.view.View
import android.view.inputmethod.InputMethodManager
import com.commonsware.cwac.anddown.AndDown
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.Reader
fun View.showKeyboard() =
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
@ -10,4 +17,46 @@ fun View.showKeyboard() =
fun View.hideKeyboard() =
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(windowToken, 0)
.hideSoftInputFromWindow(windowToken, 0)
suspend fun AssetManager.readAssetToString(asset: String): String? {
return withContext(Dispatchers.IO) {
open(asset).reader().use(Reader::readText)
}
}
const val HOEDOWN_FLAGS = AndDown.HOEDOWN_EXT_STRIKETHROUGH or AndDown.HOEDOWN_EXT_TABLES or
AndDown.HOEDOWN_EXT_UNDERLINE or AndDown.HOEDOWN_EXT_SUPERSCRIPT or
AndDown.HOEDOWN_EXT_FENCED_CODE
suspend fun String.toHtml(): String {
return withContext(Dispatchers.IO) {
AndDown().markdownToHtml(this@toHtml, HOEDOWN_FLAGS, 0)
}
}
suspend fun Uri.getName(context: Context): String {
var fileName: String? = null
try {
if ("content" == scheme) {
withContext(Dispatchers.IO) {
context.contentResolver.query(
this@getName,
null,
null,
null,
null
)?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst()
fileName = it.getString(nameIndex)
}
}
} else if ("file" == scheme) {
fileName = lastPathSegment
}
} catch (ignored: Exception) {
ignored.printStackTrace()
}
return fileName ?: "Untitled.md"
}

View file

@ -5,10 +5,10 @@ import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.preference.PreferenceManager
import android.provider.OpenableColumns
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -18,11 +18,16 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
import com.wbrawner.simplemarkdown.utility.ErrorHandler
import com.wbrawner.simplemarkdown.utility.getName
import com.wbrawner.simplemarkdown.utility.readAssetToString
import com.wbrawner.simplemarkdown.utility.toHtml
import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
import java.io.File
@ -32,12 +37,11 @@ import kotlin.coroutines.CoroutineContext
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope {
@Inject
lateinit var presenter: MarkdownPresenter
@Inject
lateinit var errorHandler: ErrorHandler
private var shouldAutoSave = true
override val coroutineContext: CoroutineContext = Dispatchers.Main
private lateinit var viewModel: MarkdownViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -50,7 +54,10 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
(application as MarkdownApplication).component.inject(this)
viewModel = ViewModelProviders.of(
this,
(application as MarkdownApplication).viewModelFactory
).get(MarkdownViewModel::class.java)
val adapter = EditPagerAdapter(supportFragmentManager, this@MainActivity)
pager.adapter = adapter
pager.addOnPageChangeListener(adapter)
@ -60,16 +67,38 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
tabLayout!!.visibility = View.GONE
}
@Suppress("CAST_NEVER_SUCCEEDS")
viewModel.fileName.observe(this, Observer<String> {
title = it
})
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
val isAutoSaveEnabled = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(KEY_AUTOSAVE, true)
if (shouldAutoSave && presenter.markdown.isNotEmpty() && isAutoSaveEnabled) {
launch {
withContext(Dispatchers.IO) {
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this@MainActivity)
val isAutoSaveEnabled = sharedPrefs.getBoolean(KEY_AUTOSAVE, true)
if (!shouldAutoSave || !isAutoSaveEnabled) {
return@withContext
}
launch {
presenter.saveMarkdown("autosave.md", File(filesDir, "autosave.md").outputStream())
val uri = if (viewModel.save(this@MainActivity)) {
viewModel.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(filesDir, viewModel.fileName.value))
if (viewModel.save(this@MainActivity, fileUri)) {
fileUri
} else {
null
}
}?: return@withContext
sharedPrefs.edit()
.putString(getString(R.string.pref_key_autosave_uri), uri.toString())
.apply()
}
}
}
@ -89,10 +118,22 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_save -> requestFileOp(REQUEST_SAVE_FILE)
R.id.action_save -> {
launch {
if (!viewModel.save(this@MainActivity)) {
requestFileOp(REQUEST_SAVE_FILE)
} else {
Toast.makeText(
this@MainActivity,
getString(R.string.file_saved, viewModel.fileName.value),
Toast.LENGTH_SHORT
).show()
}
}
}
R.id.action_share -> {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, presenter.markdown)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value)
shareIntent.type = "text/plain"
startActivity(Intent.createChooser(
shareIntent,
@ -137,9 +178,9 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
infoIntent.putExtra("title", title)
launch {
try {
val inputStream = assets?.open(fileName)
val html = assets?.readAssetToString(fileName)
?.toHtml()
?: throw RuntimeException("Unable to open stream to $fileName")
val html = presenter.loadMarkdown(fileName, inputStream, false)
infoIntent.putExtra("html", html)
startActivity(infoIntent)
} catch (e: Exception) {
@ -183,17 +224,11 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
return
}
val fileName = contentResolver.query(data.data!!, null, null, null, null)
?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
} ?: "Untitled.md"
contentResolver.openFileDescriptor(data.data!!, "r")?.let {
val fileInput = FileInputStream(it.fileDescriptor)
launch {
presenter.loadMarkdown(fileName, fileInput)
launch {
val fileLoaded = viewModel.load(this@MainActivity, data.data)
if (!fileLoaded) {
Toast.makeText(this@MainActivity, R.string.file_load_error, Toast.LENGTH_SHORT)
.show()
}
}
}
@ -202,20 +237,8 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
return
}
val fileName = contentResolver.query(data.data!!, null, null, null, null)
?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
} ?: "Untitled.md"
launch {
val outputStream = contentResolver.openOutputStream(data.data!!)
?: throw RuntimeException("Unable to open output stream to save file")
presenter.saveMarkdown(
fileName,
outputStream
)
viewModel.save(this@MainActivity, data.data)
}
}
REQUEST_DARK_MODE -> recreate()
@ -224,11 +247,15 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
}
private fun promptSaveOrDiscardChanges() {
if (viewModel.originalMarkdown.value == viewModel.markdownUpdates.value) {
viewModel.reset("Untitled.md")
return
}
AlertDialog.Builder(this)
.setTitle(R.string.save_changes)
.setMessage(R.string.prompt_save_changes)
.setNegativeButton(R.string.action_discard) { _, _ ->
presenter.newFile("Untitled.md")
viewModel.reset("Untitled.md")
}
.setPositiveButton(R.string.action_save) { _, _ ->
requestFileOp(REQUEST_SAVE_FILE)
@ -252,7 +279,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
REQUEST_SAVE_FILE -> {
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "text/markdown"
putExtra(Intent.EXTRA_TITLE, presenter.fileName)
putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
}
}
REQUEST_OPEN_FILE -> {
@ -274,14 +301,11 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
)
}
override fun onResume() {
super.onResume()
title = presenter.fileName
shouldAutoSave = true
}
override fun onDestroy() {
super.onDestroy()
coroutineContext[Job]?.let {

View file

@ -1,85 +1,73 @@
package com.wbrawner.simplemarkdown.view.activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.preference.PreferenceManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
import com.wbrawner.simplemarkdown.utility.ErrorHandler
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import java.io.File
import java.io.FileNotFoundException
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var presenter: MarkdownPresenter
@Inject
lateinit var errorHandler: ErrorHandler
lateinit var viewModel: MarkdownViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
(application as MarkdownApplication).component.inject(this)
if (sharedPreferences.getBoolean(getString(R.string.error_reports_enabled), true)) {
errorHandler.init(this)
}
viewModel = ViewModelProviders.of(
this,
(application as MarkdownApplication).viewModelFactory
).get(MarkdownViewModel::class.java)
val darkModeValue = sharedPreferences.getString(
getString(R.string.pref_key_dark_mode),
getString(R.string.pref_value_auto)
)
var darkMode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
if (darkModeValue != null && !darkModeValue.isEmpty()) {
if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_NO
} else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_YES
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
if (intent?.data != null) {
launch {
presenter.loadFromUri(applicationContext, intent.data!!)
}
} else {
presenter.fileName = "Untitled.md"
val autosave = File(filesDir, "autosave.md")
if (autosave.exists()) {
try {
launch {
presenter.loadMarkdown(
"Untitled.md",
autosave.inputStream(),
true
launch {
val darkMode = withContext(Dispatchers.IO) {
val darkModeValue = PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(
getString(R.string.pref_key_dark_mode),
getString(R.string.pref_value_auto)
)
autosave.delete()
}
} catch (ignored: FileNotFoundException) {
return
}
}
}
val startIntent = Intent(this, MainActivity::class.java)
startActivity(startIntent)
finish()
return@withContext when {
darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_NO
darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_YES
else -> {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
withContext(Dispatchers.IO) {
var uri = intent?.data
if (uri == null) {
uri = PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(
getString(R.string.pref_key_autosave_uri),
null
)?.let {
Uri.parse(it)
}
}
viewModel.load(this@SplashActivity, uri)
}
val startIntent = Intent(this@SplashActivity, MainActivity::class.java)
startActivity(startIntent)
finish()
}
}
override fun onDestroy() {

View file

@ -16,31 +16,24 @@ import android.view.ViewGroup
import android.widget.EditText
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.presentation.MarkdownEditView
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
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 javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.math.abs
class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope {
@Inject
lateinit var presenter: MarkdownPresenter
class EditFragment : Fragment(), ViewPagerPage, CoroutineScope {
private var markdownEditor: EditText? = null
private var markdownEditorScroller: ScrollView? = null
override var markdown: String
get() = markdownEditor?.text?.toString() ?: ""
set(value) {
markdownEditor?.setText(value)
}
private lateinit var viewModel: MarkdownViewModel
override val coroutineContext: CoroutineContext = Dispatchers.Main
@SuppressLint("ClickableViewAccessibility")
@ -67,7 +60,7 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
delay(50)
if (searchText != searchFor)
return@launch
presenter.onMarkdownEdited(searchText)
viewModel.updateMarkdown(searchText)
}
}
@ -78,8 +71,15 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
}
})
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val enableReadability = sharedPrefs.getBoolean(getString(R.string.readability_enabled), false)
var enableReadability = false
launch {
enableReadability = withContext(Dispatchers.IO) {
context?.let {
PreferenceManager.getDefaultSharedPreferences(it)
.getBoolean(getString(R.string.readability_enabled), false)
}?: false
}
}
if (enableReadability) {
markdownEditor?.addTextChangedListener(object : TextWatcher {
private var previousValue = ""
@ -97,10 +97,10 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
if (searchText != searchFor)
return@launch
val start = System.currentTimeMillis()
if (markdown.isEmpty()) return@launch
if (previousValue == markdown) return@launch
val readability = Readability(markdown)
val span = SpannableString(markdown)
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)
@ -108,7 +108,7 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
}
markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
previousValue = markdown
previousValue = searchFor
val timeTakenMs = System.currentTimeMillis() - start
Log.d("SimpleMarkdown", "Handled markdown in " + timeTakenMs + "ms")
}
@ -148,21 +148,13 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
@Suppress("CAST_NEVER_SUCCEEDS")
(activity?.application as? MarkdownApplication)?.component?.inject(this)
presenter.editView = this@EditFragment
}
override fun onResume() {
super.onResume()
presenter.editView = this
markdown = presenter.markdown
}
override fun onPause() {
super.onPause()
presenter.editView = null
markdownEditor?.hideKeyboard()
viewModel = ViewModelProviders.of(
this,
(requireActivity().application as MarkdownApplication).viewModelFactory
).get(MarkdownViewModel::class.java)
viewModel.originalMarkdown.observe(this, Observer<String> {
markdownEditor?.setText(it)
})
}
override fun onDestroy() {
@ -179,26 +171,4 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope
override fun onDeselected() {
markdownEditor?.hideKeyboard()
}
override fun setTitle(title: String) {
val activity = activity
if (activity != null) {
activity.title = title
}
}
override fun onFileSaved(success: Boolean) {
val message: String = if (success) {
getString(R.string.file_saved, presenter.fileName)
} else {
getString(R.string.file_save_error)
}
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
override fun onFileLoaded(success: Boolean) {
// TODO: Investigate why this fires off so often
// int message = success ? R.string.file_loaded : R.string.file_load_error;
// Toast.makeText(getActivity(), message, Toast.LENGTH_SHORT).show();
}
}// Required empty public constructor
}

View file

@ -1,6 +1,5 @@
package com.wbrawner.simplemarkdown.view.fragment
import android.content.SharedPreferences
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
@ -11,19 +10,20 @@ import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.MarkdownApplication
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter
import com.wbrawner.simplemarkdown.presentation.MarkdownPreviewView
import javax.inject.Inject
import com.wbrawner.simplemarkdown.utility.toHtml
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
class PreviewFragment : Fragment(), MarkdownPreviewView {
@Inject
lateinit var presenter: MarkdownPresenter
class PreviewFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
lateinit var viewModel: MarkdownViewModel
private var markdownPreview: WebView? = null
private var sharedPreferences: SharedPreferences? = null
override fun onCreateView(
inflater: LayoutInflater,
@ -32,46 +32,48 @@ class PreviewFragment : Fragment(), MarkdownPreviewView {
): View? = inflater.inflate(R.layout.fragment_preview, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(view.context)
markdownPreview = view.findViewById(R.id.markdown_view)
(activity?.application as? MarkdownApplication)?.component?.inject(this)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
}
override fun updatePreview(html: String) {
markdownPreview?.post {
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES
|| context!!.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
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(
this,
(requireActivity().application as MarkdownApplication).viewModelFactory
).get(MarkdownViewModel::class.java)
viewModel.markdownUpdates.observe(this, Observer<String> {
markdownPreview?.post {
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES
|| context!!.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
}
launch {
val css = withContext(Dispatchers.IO) {
@Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
requireActivity().getString(defaultCssId)
} else {
PreferenceManager.getDefaultSharedPreferences(requireActivity())
.getString(
getString(R.string.pref_custom_css),
getString(defaultCssId)
)?: ""
}
}
val style = String.format(FORMAT_CSS, css)
markdownPreview?.loadDataWithBaseURL(null,
style + it.toHtml(),
"text/html",
"UTF-8", null
)
}
}
@Suppress("ConstantConditionIf")
val css: String? = if (!BuildConfig.ENABLE_CUSTOM_CSS) {
context?.getString(defaultCssId)
} else {
sharedPreferences!!.getString(
getString(R.string.pref_custom_css),
getString(defaultCssId)
)
}
val style = String.format(FORMAT_CSS, css)
markdownPreview?.loadDataWithBaseURL(null,
style + html,
"text/html",
"UTF-8", null
)
}
}
override fun onResume() {
super.onResume()
presenter.previewView = this
presenter.onMarkdownEdited()
})
}
override fun onDestroyView() {
@ -84,8 +86,10 @@ class PreviewFragment : Fragment(), MarkdownPreviewView {
}
override fun onDestroy() {
coroutineContext[Job]?.let {
cancel()
}
super.onDestroy()
presenter.previewView = null
}
companion object {

View file

@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.view.fragment
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.os.StrictMode
import android.preference.ListPreference
import android.preference.Preference
import android.preference.PreferenceFragment
@ -13,19 +14,39 @@ import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Exception
import kotlin.coroutines.CoroutineContext
class SettingsFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.pref_general)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
setListPreferenceSummary(
sharedPreferences,
findPreference(getString(R.string.pref_key_dark_mode))
)
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
preferenceScreen.removePreference(findPreference(getString(R.string.pref_custom_css)))
class SettingsFragment
: PreferenceFragment(),
SharedPreferences.OnSharedPreferenceChangeListener,
CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
launch {
withContext(Dispatchers.IO) {
try {
// This can be thrown when recreating the activity for theme changes
addPreferencesFromResource(R.xml.pref_general)
} catch (ignored: Exception) {
return@withContext
}
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
sharedPreferences.registerOnSharedPreferenceChangeListener(this@SettingsFragment)
(findPreference(getString(R.string.pref_key_dark_mode)) as? ListPreference)?.let {
setListPreferenceSummary(sharedPreferences, it)
}
@Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
preferenceScreen.removePreference(findPreference(getString(R.string.pref_custom_css)))
}
}
}
}
@ -39,34 +60,35 @@ class SettingsFragment : PreferenceFragment(), SharedPreferences.OnSharedPrefere
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (!isAdded) return
val preference = findPreference(key)
if (preference is ListPreference) {
setListPreferenceSummary(sharedPreferences, preference)
val preference = findPreference(key) as? ListPreference ?: return
setListPreferenceSummary(sharedPreferences, preference)
if (preference.key != getString(R.string.pref_key_dark_mode)) {
return
}
if (preference.key == getString(R.string.pref_key_dark_mode)) {
var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
val darkModeValue = sharedPreferences.getString(preference.key, null)
if (darkModeValue != null && !darkModeValue.isEmpty()) {
if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_NO
} else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_YES
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
activity?.recreate()
var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
val darkModeValue = sharedPreferences.getString(preference.key, null)
if (darkModeValue != null && darkModeValue.isNotEmpty()) {
if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_NO
} else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) {
darkMode = AppCompatDelegate.MODE_NIGHT_YES
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
activity?.recreate()
}
private fun setListPreferenceSummary(sharedPreferences: SharedPreferences, preference: Preference) {
val listPreference = preference as ListPreference
val storedValue = sharedPreferences.getString(preference.getKey(), null) ?: return
val index = listPreference.findIndexOfValue(storedValue)
private fun setListPreferenceSummary(sharedPreferences: SharedPreferences, preference: ListPreference) {
val storedValue = sharedPreferences.getString(
preference.key,
null
) ?: return
val index = preference.findIndexOfValue(storedValue)
if (index < 0) return
preference.setSummary(listPreference.entries[index].toString())
preference.summary = preference.entries[index].toString()
}
}

View file

@ -0,0 +1,106 @@
package com.wbrawner.simplemarkdown.viewmodel
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.wbrawner.simplemarkdown.utility.getName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
import java.io.Reader
import kotlin.coroutines.CoroutineContext
class MarkdownViewModel : ViewModel() {
private val coroutineContext: CoroutineContext = Dispatchers.IO
val fileName = MutableLiveData<String>().apply {
postValue("Untitled.md")
}
val markdownUpdates = MutableLiveData<String>()
val originalMarkdown = MutableLiveData<String>()
val uri = MutableLiveData<Uri>()
fun updateMarkdown(markdown: String?) {
this.markdownUpdates.postValue(markdown ?: "")
}
suspend fun load(context: Context, uri: Uri?): Boolean {
if (uri == null) return false
return withContext(Dispatchers.IO) {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
val fileInput = FileInputStream(it.fileDescriptor)
val fileName = uri.getName(context)
return@withContext if (load(fileInput)) {
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
true
} else {
false
}
} ?: false
}
}
suspend fun load(inputStream: InputStream): Boolean {
return try {
withContext(coroutineContext) {
val content = inputStream.reader().use(Reader::readText)
originalMarkdown.postValue(content)
markdownUpdates.postValue(content)
}
true
} catch (e: Throwable) {
e.printStackTrace()
false
}
}
suspend fun save(context: Context, givenUri: Uri? = this.uri.value): Boolean {
val uri = givenUri ?: this.uri.value ?: return false
return withContext(Dispatchers.IO) {
val fileName = uri.getName(context)
val outputStream = context.contentResolver.openOutputStream(uri)
?: return@withContext false
if (save(outputStream)) {
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
true
} else {
false
}
}
}
suspend fun save(outputStream: OutputStream): Boolean {
return try {
withContext(coroutineContext) {
outputStream.writer().use {
it.write(markdownUpdates.value)
}
}
true
} catch (e: Throwable) {
false
}
}
fun reset(untitledFileName: String) {
fileName.postValue(untitledFileName)
originalMarkdown.postValue("")
markdownUpdates.postValue("")
}
}
class MarkdownViewModelFactory : ViewModelProvider.Factory {
private val markdownViewModel: MarkdownViewModel by lazy {
MarkdownViewModel()
}
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return markdownViewModel as T
}
}

View file

@ -60,6 +60,7 @@
<string name="pref_key_dark_mode_light" translatable="false">light</string>
<string name="pref_key_dark_mode_dark" translatable="false">dark</string>
<string name="pref_key_dark_mode_auto" translatable="false">auto</string>
<string name="pref_key_autosave_uri" translatable="false">autosave.uri</string>
<string name="save_changes">Save Changes</string>
<string name="prompt_save_changes">Would you like to save your changes?</string>
<string name="action_discard">Discard</string>

View file

@ -14,7 +14,7 @@
<ListPreference
android:entries="@array/pref_entries_dark_mode"
android:entryValues="@array/pref_values_dark_mode"
android:defaultValue="@string/pref_value_auto"
android:defaultValue="@string/pref_key_dark_mode_auto"
android:key="@string/pref_key_dark_mode"
android:title="@string/title_dark_mode" />
<SwitchPreference