Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
dfd771adfe |
|
@ -1,70 +0,0 @@
|
|||
name: Build & Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: https://git.wbrawner.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v4
|
||||
unit_tests:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- validate
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: https://git.wbrawner.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Setup Android SDK
|
||||
uses: https://git.wbrawner.com/android-actions/setup-android@v3
|
||||
- name: Setup Gradle
|
||||
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
|
||||
- name: Run unit tests
|
||||
run: ./gradlew check
|
||||
- name: Publish JUnit Results
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: Unit Test Results
|
||||
path: "*/build/reports/*"
|
||||
if-no-files-found: error
|
||||
ui_tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run UI Tests
|
||||
needs:
|
||||
- unit_tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: https://git.wbrawner.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Setup Android SDK
|
||||
uses: https://git.wbrawner.com/android-actions/setup-android@v3
|
||||
- name: Setup Gradle
|
||||
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
|
||||
- name: Build APKs
|
||||
run: ./gradlew assemblePlayDebug assemblePlayDebugAndroidTest
|
||||
- name: Grant execute permission for flank_auth.sh
|
||||
run: chmod +x flank_auth.sh
|
||||
- name: Add auth for flank
|
||||
env:
|
||||
GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
|
||||
run: |
|
||||
./flank_auth.sh
|
||||
- name: Run UI tests
|
||||
run: ./gradlew runFlank
|
77
.github/workflows/android.yml
vendored
|
@ -1,77 +0,0 @@
|
|||
name: Android CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
- name: Enable auto-merge
|
||||
if: ${{ github.event_name == 'pull_request' && github.actor == 'dependabot' }}
|
||||
run: gh pr merge --auto --rebase "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
unit_test:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- validate
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Run unit tests
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
arguments: testPlayDebugUnitTest
|
||||
- name: Publish JUnit Results
|
||||
uses: dorny/test-reporter@v1
|
||||
if: always()
|
||||
with:
|
||||
name: Unit Test Results
|
||||
path: "*/build/test-results/*/*.xml"
|
||||
reporter: java-junit
|
||||
fail-on-error: true
|
||||
ui_tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run UI Tests
|
||||
needs:
|
||||
- validate
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Build with Gradle
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
arguments: assemblePlayDebug assemblePlayDebugAndroidTest
|
||||
- name: Grant execute permission for flank_auth.sh
|
||||
run: chmod +x flank_auth.sh
|
||||
- name: Add auth for flank
|
||||
env:
|
||||
GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
|
||||
run: |
|
||||
./flank_auth.sh
|
||||
- name: Run UI tests
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
arguments: runFlank
|
1
.gitignore
vendored
|
@ -12,3 +12,4 @@
|
|||
*.log
|
||||
keystore.properties
|
||||
*.jks
|
||||
sentry.properties
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
image: openjdk:24-jdk
|
||||
image: openjdk:8-jdk
|
||||
|
||||
variables:
|
||||
ANDROID_COMPILE_SDK: "28"
|
||||
|
|
38
README.md
|
@ -1,36 +1,44 @@
|
|||
# Simple Markdown
|
||||
# [Simple Markdown](https://wbrawner.com/portfolio/simple-markdown/)
|
||||
|
||||
[![pipeline status](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml/badge.svg)](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml)
|
||||
[![pipeline status](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/pipeline.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
|
||||
[![coverage report](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/coverage.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
|
||||
|
||||
<p>
|
||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" style="width: 24%" />
|
||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" style="width: 24%" />
|
||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" style="width: 24%" />
|
||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" style="width: 24%" />
|
||||
</p>
|
||||
Simple Markdown is simply a Markdown editor :) I wrote it to offer up an open source alternative to
|
||||
the other Markdown editors available on the Play Store. I also wanted to get some practice in
|
||||
creating Android apps and have a little something to put into my portfolio.
|
||||
|
||||
Simple Markdown is an open source Markdown editor.
|
||||
## Roadmap
|
||||
|
||||
<a href='https://play.google.com/store/apps/details?id=com.wbrawner.simplemarkdown&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height="80"/></a>
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.wbrawner.simplemarkdown.free/)
|
||||
* [x] Auto-save
|
||||
* [x] Night mode
|
||||
* [x] Save to cloud (Dropbox, Google Drive, OneDrive)
|
||||
* [x] Custom CSS for Markdown preview
|
||||
* [ ] Better insert for tables/images/links
|
||||
* [ ] Quick-insert toolbar for common Markdown syntax characters
|
||||
* [ ] Auto-scroll preview to match edit view in landscape mode
|
||||
* [ ] Disable live preview in landscape mode
|
||||
* [ ] Disable preview tab for better performance in large files
|
||||
|
||||
## Building
|
||||
|
||||
Using Android Studio is the preferred way to build the project. To build from the command line, you can run
|
||||
|
||||
./gradlew assembleFreeDebug
|
||||
./gradlew assembleDebug
|
||||
|
||||
### Crashlytics
|
||||
|
||||
SimpleMarkdown makes use of Firebase Crashlytics for error reporting. You'll need to follow the
|
||||
[Get started with Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/get-started?platform=android) guide in order to build the project.
|
||||
|
||||
## Contributing
|
||||
|
||||
I'd love any contributions, particularly in improving the existing code. Please just fork the
|
||||
repository, make your changes, squash your commits, and submit a pull request :)
|
||||
|
||||
## License
|
||||
|
||||
```
|
||||
Copyright 2017-2022 William Brawner
|
||||
Copyright 2017-2019 William Brawner
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
1
app/.gitignore
vendored
|
@ -1,4 +1,3 @@
|
|||
/build
|
||||
*.apk
|
||||
*.aab
|
||||
/release
|
||||
|
|
166
app/build.gradle
Normal file
|
@ -0,0 +1,166 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'jacoco'
|
||||
apply plugin: 'io.sentry.android.gradle'
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
try {
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
} catch (FileNotFoundException ignored) {
|
||||
logger.warn("Unable to load keystore properties. Automatic signing won't be available")
|
||||
keystoreProperties['keyAlias'] = ""
|
||||
keystoreProperties['keyPassword'] = ""
|
||||
keystoreProperties['storeFile'] = File.createTempFile("temp", ".tmp").absolutePath
|
||||
keystoreProperties['storePassword'] = ""
|
||||
}
|
||||
|
||||
android {
|
||||
configurations.all {
|
||||
resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.1'
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/LICENSE-LGPL-2.1.txt'
|
||||
exclude 'META-INF/LICENSE-LGPL-3.txt'
|
||||
exclude 'META-INF/LICENSE-W3C-TEST'
|
||||
exclude 'META-INF/LICENSE'
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
}
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion '28.0.3'
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId "com.wbrawner.simplemarkdown"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 29
|
||||
versionCode 27
|
||||
versionName "0.8.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
sentryDsn: "https://399270639b2e4b10a028a2be9192d1d3@sentry.brawner.dev/2"
|
||||
]
|
||||
buildConfigField "boolean", "ENABLE_CUSTOM_CSS", "false"
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
testCoverageEnabled true
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
buildConfigField "boolean", "ENABLE_CUSTOM_CSS", "false"
|
||||
}
|
||||
}
|
||||
flavorDimensions "freedom"
|
||||
productFlavors {
|
||||
play {}
|
||||
free {
|
||||
applicationIdSuffix ".free"
|
||||
versionNameSuffix "-free"
|
||||
}
|
||||
}
|
||||
dexOptions {
|
||||
jumboMode true
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.2'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'org.robolectric:robolectric:4.2.1'
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
def espresso_version = '3.2.0'
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-web:$espresso_version"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version"
|
||||
def android_test = '1.2.0'
|
||||
androidTestImplementation "androidx.test:runner:$android_test"
|
||||
androidTestImplementation "androidx.test:rules:$android_test"
|
||||
androidTestUtil "androidx.test:orchestrator:$android_test"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation "androidx.fragment:fragment-ktx:1.2.4"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'com.google.android.material:material:1.1.0'
|
||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
||||
implementation 'com.commonsware.cwac:anddown:0.3.0'
|
||||
playImplementation 'com.android.billingclient:billing:3.0.0'
|
||||
playImplementation 'com.google.firebase:firebase-core:17.4.3'
|
||||
implementation "androidx.core:core-ktx:1.3.0"
|
||||
implementation 'androidx.browser:browser:1.2.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'io.sentry:sentry-android:2.1.6'
|
||||
def coroutines_version = "1.3.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||
def lifecycle_version = "2.2.0"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
|
||||
kapt "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation 'eu.crydee:syllable-counter:4.0.2'
|
||||
}
|
||||
|
||||
android.productFlavors.each { flavor ->
|
||||
if (getGradle().getStartParameter().getTaskRequests().toString().toLowerCase().contains(flavor.name)
|
||||
&& flavor.name == 'play') {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = '0.8.0'
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
jacoco.includeNoLocationClasses = true
|
||||
}
|
||||
|
||||
task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
|
||||
reports {
|
||||
xml.enabled = true
|
||||
html.enabled = true
|
||||
}
|
||||
|
||||
def fileFilter = [ '**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*' ]
|
||||
def javaDebugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/compileDebugJavaWithJavac/classes", excludes: fileFilter)
|
||||
def kotlinDebugTree = fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", excludes: fileFilter)
|
||||
def mainSrc = "$project.projectDir/src/main/java"
|
||||
|
||||
sourceDirectories.setFrom(files([mainSrc]))
|
||||
classDirectories.setFrom(files([javaDebugTree, kotlinDebugTree]))
|
||||
executionData.setFrom(fileTree(dir: project.buildDir, includes: [
|
||||
'jacoco/testDebugUnitTest.exec',
|
||||
'outputs/code-coverage/connected/*coverage.ec'
|
||||
]))
|
||||
}
|
|
@ -1,201 +0,0 @@
|
|||
import java.io.FileInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.fladle)
|
||||
alias(libs.plugins.triplet.play)
|
||||
id("com.wbrawner.releasehelper")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
try {
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
} catch (ignored: FileNotFoundException) {
|
||||
logger.warn("Unable to load keystore properties. Automatic signing won't be available")
|
||||
keystoreProperties["keyAlias"] = ""
|
||||
keystoreProperties["keyPassword"] = ""
|
||||
keystoreProperties["storeFile"] = File.createTempFile("temp", ".tmp").absolutePath
|
||||
keystoreProperties["storePassword"] = ""
|
||||
keystoreProperties["publishCredentialsFile"] = ""
|
||||
}
|
||||
|
||||
android {
|
||||
packaging {
|
||||
resources {
|
||||
excludes += listOf(
|
||||
"META-INF/LICENSE-LGPL-2.1.txt",
|
||||
"META-INF/LICENSE-LGPL-3.txt",
|
||||
"META-INF/LICENSE-W3C-TEST",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/DEPENDENCIES"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileSdk = libs.versions.maxSdk.get().toInt()
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId = "com.wbrawner.simplemarkdown"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.maxSdk.get().toInt()
|
||||
versionCode = 45
|
||||
versionName = "2024.10.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "true")
|
||||
}
|
||||
signingConfigs {
|
||||
create("playRelease") {
|
||||
keyAlias = keystoreProperties["keyAlias"].toString()
|
||||
keyPassword = keystoreProperties["keyPassword"].toString()
|
||||
storeFile = file(keystoreProperties["storeFile"].toString())
|
||||
storePassword = keystoreProperties["storePassword"].toString()
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "false")
|
||||
}
|
||||
}
|
||||
flavorDimensions.add("platform")
|
||||
productFlavors {
|
||||
create("free") {
|
||||
applicationIdSuffix = ".free"
|
||||
versionNameSuffix = "-free"
|
||||
}
|
||||
create("play") {
|
||||
signingConfig = signingConfigs["playRelease"]
|
||||
}
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
namespace = "com.wbrawner.simplemarkdown"
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
playConfigs {
|
||||
register("play") {
|
||||
enabled.set(true)
|
||||
commit.set(true)
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable += listOf(
|
||||
"AndroidGradlePluginVersion",
|
||||
"GradleDependency",
|
||||
"ObsoleteLintCustomCheck"
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
play {
|
||||
commit.set(false)
|
||||
enabled.set(false)
|
||||
track.set("production")
|
||||
defaultToAppBundles.set(true)
|
||||
(keystoreProperties["publishCredentialsFile"] as? String)?.ifBlank { null }?.let {
|
||||
serviceAccountCredentials.set(file(it))
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
"freeImplementation"(project(":free"))
|
||||
"playImplementation"(project(":non-free"))
|
||||
implementation(libs.androidx.material3.windowsizeclass)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
testImplementation(libs.junit)
|
||||
testRuntimeOnly(libs.robolectric)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.espresso.web)
|
||||
androidTestImplementation(libs.androidx.espresso.intents)
|
||||
androidTestRuntimeOnly(libs.androidx.runner)
|
||||
androidTestUtil(libs.androidx.orchestrator)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.gfm.tables)
|
||||
implementation(libs.commonmark.ext.gfm.strikethrough)
|
||||
implementation(libs.commonmark.ext.autolink)
|
||||
implementation(libs.commonmark.ext.task.list.items)
|
||||
implementation(libs.commonmark.ext.yaml.front.matter)
|
||||
implementation(libs.commonmark.ext.image.attributes)
|
||||
implementation(libs.commonmark.ext.heading.anchor)
|
||||
val composeBom = enforcedPlatform(libs.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
implementation(libs.androidx.runtime)
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.foundation.layout)
|
||||
implementation(libs.androidx.ui.tooling)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
runtimeOnly(libs.kotlinx.coroutines.android)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.syllable.counter)
|
||||
androidTestImplementation(libs.androidx.ui.test)
|
||||
androidTestImplementation(libs.androidx.core)
|
||||
androidTestImplementation(libs.androidx.monitor)
|
||||
androidTestImplementation(libs.junit)
|
||||
implementation(libs.androidx.activity.ktx)
|
||||
implementation(libs.androidx.activity.ktx)
|
||||
implementation(libs.androidx.animation.core)
|
||||
implementation(libs.androidx.animation)
|
||||
implementation(libs.androidx.material.icons.core)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.text)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.ui.unit)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.navigation.common)
|
||||
implementation(libs.androidx.navigation.runtime.ktx)
|
||||
implementation(libs.androidx.preference.ktx)
|
||||
implementation(libs.acra.core)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(project(":core"))
|
||||
}
|
||||
|
||||
fladle {
|
||||
variant.set("playDebug")
|
||||
useOrchestrator.set(true)
|
||||
environmentVariables.put("clearPackageData", "true")
|
||||
testTimeout.set("7m")
|
||||
devices.add(
|
||||
mapOf("model" to "Pixel2.arm", "version" to "33")
|
||||
)
|
||||
projectId.set("simplemarkdown")
|
||||
}
|
||||
|
||||
tasks.register<Exec>("pullLogFiles") {
|
||||
commandLine = listOf(
|
||||
"adb", "pull",
|
||||
"/storage/emulated/0/Android/data/com.wbrawner.simplemarkdown/files/logs"
|
||||
)
|
||||
}
|
78
app/google-services.json
Normal file
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "318641233555",
|
||||
"firebase_url": "https://simplemarkdown.firebaseio.com",
|
||||
"project_id": "simplemarkdown",
|
||||
"storage_bucket": "simplemarkdown.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:318641233555:android:09d865cad87e3b5f",
|
||||
"android_client_info": {
|
||||
"package_name": "com.wbrawner.simplemarkdown"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "318641233555-83n2k1mqhokf0b7lhccqiva9pspgripq.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.wbrawner.simplemarkdown",
|
||||
"certificate_hash": "710e37c230689bc259fc6e2e032e2f56956f8d33"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"admob_app_id": "ca-app-pub-3319579963502409~4576405307"
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:318641233555:android:5dfb62206717437e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.wbrawner.simplemarkdown.samsung"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBDMcXg-10NsXLDKJRtj5WnXoHrwg3m9Os"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "318641233555-a5rm2cqqf66jc9j5nmg3ttepdt2iaeno.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
15
app/proguard-rules.pro
vendored
|
@ -2,7 +2,7 @@
|
|||
# By default, the flags in this file are appended to flags specified
|
||||
# in /home/billy/Android/Sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.kts.
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
@ -27,15 +27,4 @@
|
|||
### Crashlytics ###
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep public class * extends java.lang.Exception
|
||||
### Crashlytics ###
|
||||
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
### End Crashlytics ###
|
|
@ -0,0 +1,182 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.InstrumentationRegistry
|
||||
import androidx.test.InstrumentationRegistry.getInstrumentation
|
||||
import androidx.test.core.app.ApplicationProvider.getApplicationContext
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
|
||||
import androidx.test.espresso.action.ViewActions.*
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents.intending
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.rule.IntentsTestRule
|
||||
import androidx.test.espresso.matcher.ViewMatchers.*
|
||||
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
|
||||
import androidx.test.espresso.web.sugar.Web.onWebView
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
|
||||
import androidx.test.espresso.web.webdriver.Locator
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiScrollable
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import com.wbrawner.simplemarkdown.view.activity.MainActivity
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.Reader
|
||||
|
||||
class MarkdownTests {
|
||||
|
||||
@get:Rule
|
||||
var activityRule = IntentsTestRule(MainActivity::class.java, false, false)
|
||||
|
||||
lateinit var file: File
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
|
||||
file.parentFile?.mkdirs()
|
||||
file.delete()
|
||||
activityRule.launchActivity(null)
|
||||
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun openAppTest() {
|
||||
val mDevice = UiDevice.getInstance(getInstrumentation())
|
||||
mDevice.pressHome()
|
||||
// Bring up the default launcher by searching for a UI component
|
||||
// that matches the content description for the launcher button.
|
||||
val allAppsButton = mDevice
|
||||
.findObject(UiSelector().description("Apps"))
|
||||
|
||||
// Perform a click on the button to load the launcher.
|
||||
allAppsButton.clickAndWaitForNewWindow()
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getTargetContext()
|
||||
assertEquals("com.wbrawner.simplemarkdown", appContext.packageName)
|
||||
val appView = UiScrollable(UiSelector().scrollable(true))
|
||||
val simpleMarkdownSelector = UiSelector()
|
||||
.text(getApplicationContext<Context>().getString(R.string.app_name_short))
|
||||
appView.scrollIntoView(simpleMarkdownSelector)
|
||||
mDevice.findObject(simpleMarkdownSelector).clickAndWaitForNewWindow()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editAndPreviewMarkdownTest() {
|
||||
onView(withId(R.id.markdown_edit)).perform(typeText("# Header test"))
|
||||
onView(withText(R.string.action_preview)).perform(click())
|
||||
onWebView(withId(R.id.markdown_view)).forceJavascriptEnabled()
|
||||
.withElement(findElement(Locator.TAG_NAME, "h1"))
|
||||
.check(webMatches(getText(), containsString("Header test")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newMarkdownTest() {
|
||||
onView(withId(R.id.markdown_edit))
|
||||
.perform(typeText("# UI Testing\n\nThe quick brown fox jumped over the lazy dog."))
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_new)).perform(click())
|
||||
onView(withText(R.string.action_discard)).perform(click())
|
||||
onView(withId(R.id.markdown_edit)).check(matches(withText("")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithFileUriTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
onView(withId(R.id.markdown_edit)).perform(typeText(markdownText))
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_save_as)).perform(click())
|
||||
Thread.sleep(500)
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithContentUriTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
onView(withId(R.id.markdown_edit)).perform(typeText(markdownText))
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(getApplicationContext(), "com.wbrawner.simplemarkdown.fileprovider", file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_save_as)).perform(click())
|
||||
Thread.sleep(500)
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithFileUriTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_open)).perform(click())
|
||||
Thread.sleep(500)
|
||||
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithContentUriTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(getApplicationContext(), "com.wbrawner.simplemarkdown.fileprovider", file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_open)).perform(click())
|
||||
Thread.sleep(500)
|
||||
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun openEditAndSaveMarkdownTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_open)).perform(click())
|
||||
Thread.sleep(500)
|
||||
onView(withId(R.id.markdown_edit)).check(matches(withText(markdownText)))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
val additionalText = "# More info\n\nThis is some additional text"
|
||||
onView(withId(R.id.markdown_edit)).perform(
|
||||
clearText(),
|
||||
typeText(additionalText)
|
||||
)
|
||||
openActionBarOverflowOrOptionsMenu(getApplicationContext())
|
||||
onView(withText(R.string.action_save)).perform(click())
|
||||
Thread.sleep(500)
|
||||
onView(withText(getApplicationContext<Context>().getString(R.string.file_saved, "temp.md")))
|
||||
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
|
||||
onView(withText("temp.md")).check(matches(withParent(withId(R.id.toolbar))))
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import com.wbrawner.simplemarkdown.robot.onMainScreen
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class HelpTest {
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@Test
|
||||
fun openHelpPageTest() {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
checkMarkdownEquals("")
|
||||
openDrawer()
|
||||
} onNavigationDrawer {
|
||||
openHelpPage()
|
||||
} onHelpScreen {
|
||||
checkTitleEquals("Help")
|
||||
verifyH1("Headings/Titles")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider.getApplicationContext
|
||||
import androidx.test.espresso.intent.Intents.intending
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.rule.IntentsRule
|
||||
import androidx.test.espresso.matcher.ViewMatchers.*
|
||||
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
|
||||
import com.wbrawner.simplemarkdown.robot.onMainScreen
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.Reader
|
||||
|
||||
class MarkdownTests {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val intentsRule = IntentsRule()
|
||||
|
||||
private lateinit var file: File
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
|
||||
assertTrue(requireNotNull(file.parentFile).mkdirs())
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun openAppTest() {
|
||||
val context = getInstrumentation().targetContext
|
||||
context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
.apply { context.startActivity(this) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editAndPreviewMarkdownTest() {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
typeMarkdown("# Header test")
|
||||
checkMarkdownEquals("# Header test")
|
||||
openPreview()
|
||||
} onPreview {
|
||||
verifyH1("Header test")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openThenNewMarkdownTest() {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
openMenu()
|
||||
clickOpenMenuItem()
|
||||
checkMarkdownEquals(markdownText)
|
||||
openMenu()
|
||||
clickNewMenuItem()
|
||||
verifyDialogIsNotShown()
|
||||
checkMarkdownEquals("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editThenNewMarkdownTest() {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
onMainScreen(composeRule) {
|
||||
typeMarkdown(markdownText)
|
||||
openMenu()
|
||||
clickNewMenuItem()
|
||||
verifyDialogIsShown("Would you like to save your changes?")
|
||||
discardChanges()
|
||||
checkMarkdownEquals("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithFileUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
typeMarkdown(markdownText)
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
openMenu()
|
||||
clickSaveMenuItem()
|
||||
awaitIdle()
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveMarkdownWithContentUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
typeMarkdown(markdownText)
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(
|
||||
getApplicationContext(),
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
|
||||
openMenu()
|
||||
clickSaveMenuItem()
|
||||
awaitIdle()
|
||||
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithFileUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
openMenu()
|
||||
clickOpenMenuItem()
|
||||
awaitIdle()
|
||||
checkMarkdownEquals(markdownText)
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMarkdownWithContentUriTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = FileProvider.getUriForFile(
|
||||
getApplicationContext(),
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
openMenu()
|
||||
clickOpenMenuItem()
|
||||
awaitIdle()
|
||||
checkMarkdownEquals(markdownText)
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun launchWithContentUriTest() = runTest {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
getApplicationContext(),
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
ActivityScenario.launch<MainActivity>(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
fileUri,
|
||||
getInstrumentation().targetContext,
|
||||
MainActivity::class.java
|
||||
)
|
||||
)
|
||||
onMainScreen(composeRule) {
|
||||
awaitIdle()
|
||||
checkMarkdownEquals(markdownText)
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openEditAndSaveMarkdownTest() = runTest {
|
||||
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
|
||||
file.outputStream().writer().use { it.write(markdownText) }
|
||||
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
|
||||
data = Uri.fromFile(file)
|
||||
})
|
||||
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
openMenu()
|
||||
clickOpenMenuItem()
|
||||
awaitIdle()
|
||||
verifyTextIsShown("Successfully loaded temp.md")
|
||||
checkMarkdownEquals(markdownText)
|
||||
checkTitleEquals("temp.md")
|
||||
val additionalText = "# More info\n\nThis is some additional text"
|
||||
typeMarkdown(additionalText)
|
||||
openMenu()
|
||||
clickSaveMenuItem()
|
||||
awaitIdle()
|
||||
verifyTextIsShown("Successfully saved temp.md")
|
||||
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
|
||||
checkTitleEquals("temp.md")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editAndViewHelpMarkdownTest() = runTest {
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
onMainScreen(composeRule) {
|
||||
checkTitleEquals("Untitled.md")
|
||||
typeMarkdown("# Header test")
|
||||
checkMarkdownEquals("# Header test")
|
||||
openDrawer()
|
||||
} onNavigationDrawer {
|
||||
openHelpPage()
|
||||
} onHelpScreen {
|
||||
checkTitleEquals("Help")
|
||||
verifyH1("Headings/Titles")
|
||||
pressBack()
|
||||
} onMainScreen {
|
||||
checkMarkdownEquals("# Header test")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
|
||||
private const val ASSERTION_TIMEOUT = 5_000L
|
||||
|
||||
fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil {
|
||||
assertIsDisplayed()
|
||||
}
|
||||
|
||||
fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil {
|
||||
assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
fun <T> SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> T): T {
|
||||
val start = System.currentTimeMillis()
|
||||
lateinit var assertionError: AssertionError
|
||||
while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) {
|
||||
try {
|
||||
return assertion()
|
||||
} catch (e: AssertionError) {
|
||||
assertionError = e
|
||||
Thread.sleep(10)
|
||||
}
|
||||
}
|
||||
throw assertionError
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.robot
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.hasAnyDescendant
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import com.wbrawner.simplemarkdown.waitUntil
|
||||
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
|
||||
import com.wbrawner.simplemarkdown.waitUntilIsNotDisplayed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit) =
|
||||
MainScreenRobot(composeRule).apply(block)
|
||||
|
||||
@Suppress("UnusedReceiverParameter") // Used to avoid import ambiguity for tests
|
||||
suspend fun CoroutineScope.onMainScreen(
|
||||
composeRule: ComposeTestRule,
|
||||
block: suspend MainScreenRobot.() -> Unit
|
||||
): MainScreenRobot {
|
||||
val mainScreenRobot = MainScreenRobot(composeRule)
|
||||
block.invoke(mainScreenRobot)
|
||||
return mainScreenRobot
|
||||
}
|
||||
|
||||
class MainScreenRobot(private val composeRule: ComposeTestRule) :
|
||||
TopAppBarRobot by ComposeTopAppBarRobot(composeRule) {
|
||||
|
||||
fun typeMarkdown(markdown: String) = composeRule.onNode(hasSetTextAction())
|
||||
.performTextReplacement(markdown)
|
||||
|
||||
fun checkMarkdownEquals(markdown: String) {
|
||||
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
|
||||
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
|
||||
}
|
||||
composeRule.onNode(hasSetTextAction()).waitUntil {
|
||||
assert(markdownMatcher)
|
||||
}
|
||||
}
|
||||
|
||||
fun openPreview() = composeRule.onNodeWithText("Preview").performClick()
|
||||
|
||||
fun openMenu() = composeRule.onNodeWithContentDescription("Editor Actions").performClick()
|
||||
|
||||
fun clickOpenMenuItem() = composeRule.onNodeWithText("Open").performClick()
|
||||
|
||||
fun clickNewMenuItem() = composeRule.onNodeWithText("New").performClick()
|
||||
|
||||
fun clickSaveMenuItem() = composeRule.onNodeWithText("Save").performClick()
|
||||
|
||||
fun verifyDialogIsShown(text: String) =
|
||||
composeRule.onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed()
|
||||
|
||||
fun verifyDialogIsNotShown() = composeRule.onNode(isDialog()).waitUntilIsNotDisplayed()
|
||||
|
||||
fun discardChanges() = composeRule.onNodeWithText("No").performClick()
|
||||
|
||||
fun verifyTextIsShown(text: String) = composeRule.onNodeWithText(text).waitUntilIsDisplayed()
|
||||
|
||||
fun openDrawer() = composeRule.onNode(hasClickAction() and hasContentDescription("Main Menu"))
|
||||
.waitUntilIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
suspend fun awaitIdle() = composeRule.awaitIdle()
|
||||
|
||||
infix fun onPreview(block: WebViewRobot.() -> Unit) = EspressoWebViewRobot().apply(block)
|
||||
|
||||
infix fun onNavigationDrawer(block: NavigationDrawerRobot.() -> Unit): NavigationDrawerRobot =
|
||||
NavigationDrawerRobot(composeRule).apply(block = block)
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.robot
|
||||
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
|
||||
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
|
||||
WebViewRobot by EspressoWebViewRobot() {
|
||||
fun pressBack() = composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
|
||||
infix fun onMainScreen(block: MainScreenRobot.() -> Unit) =
|
||||
MainScreenRobot(composeTestRule).apply(block)
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.robot
|
||||
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
|
||||
|
||||
class NavigationDrawerRobot(private val composeTestRule: ComposeTestRule) {
|
||||
fun openHelpPage() = composeTestRule.onNode(hasClickAction() and hasText("Help"))
|
||||
.waitUntilIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
infix fun onHelpScreen(block: MarkdownInfoScreenRobot.() -> Unit) =
|
||||
MarkdownInfoScreenRobot(composeTestRule).apply(block)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.robot
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.hasAnySibling
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
|
||||
|
||||
interface TopAppBarRobot {
|
||||
fun checkTitleEquals(title: String): SemanticsNodeInteraction
|
||||
}
|
||||
|
||||
class ComposeTopAppBarRobot(private val composeTestRule: ComposeTestRule) : TopAppBarRobot {
|
||||
override fun checkTitleEquals(title: String) =
|
||||
composeTestRule.onNode(
|
||||
hasAnySibling(
|
||||
hasContentDescription("Main Menu") or hasContentDescription(
|
||||
"Back"
|
||||
)
|
||||
).and(hasText(title))
|
||||
)
|
||||
.waitUntilIsDisplayed()
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.robot
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
|
||||
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
|
||||
import androidx.test.espresso.web.sugar.Web.onWebView
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
|
||||
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
|
||||
import androidx.test.espresso.web.webdriver.Locator
|
||||
import org.hamcrest.CoreMatchers.containsString
|
||||
|
||||
interface WebViewRobot {
|
||||
fun verifyH1(text: String)
|
||||
}
|
||||
|
||||
class EspressoWebViewRobot : WebViewRobot {
|
||||
private fun findWebView() = onWebView(isAssignableFrom(WebView::class.java))
|
||||
.forceJavascriptEnabled()
|
||||
|
||||
override fun verifyH1(text: String) {
|
||||
findWebView().withElement(findElement(Locator.TAG_NAME, "h1"))
|
||||
.check(webMatches(getText(), containsString(text)))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
class SupportLinkProvider(@Suppress("unused") private val activity: Activity) {
|
||||
val supportLinks = MutableLiveData<List<MaterialButton>>()
|
||||
}
|
|
@ -1,23 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.wbrawner.simplemarkdown">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
<application
|
||||
android:name=".MarkdownApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:resizeableActivity="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="AllowBackup"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
tools:targetApi="n">
|
||||
<activity
|
||||
android:name=".view.activity.SplashActivity"
|
||||
android:theme="@style/AppTheme.Splash"
|
||||
android:label="@string/app_name_short">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -39,7 +42,7 @@
|
|||
<data android:host="*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".view.activity.MainActivity" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
@ -49,6 +52,13 @@
|
|||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
android:value="${sentryDsn}" />
|
||||
<meta-data
|
||||
android:name="io.sentry.auto-init"
|
||||
android:value="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -90,11 +90,11 @@ data 1|data 2|data 3
|
|||
data 1|data 2|data 3
|
||||
```
|
||||
|
||||
| Left Content | Center Content | Right Content |
|
||||
|:-------------|:--------------:|--------------:|
|
||||
| data 1 | data 2 | data 3 |
|
||||
| data 1 | data 2 | data 3 |
|
||||
| data 1 | data 2 | data 3 |
|
||||
Left Content|Center Content|Right Content
|
||||
:--------|:--------:|--------:
|
||||
data 1|data 2|data 3
|
||||
data 1|data 2|data 3
|
||||
data 1|data 2|data 3
|
||||
|
||||
### Images
|
||||
|
||||
|
@ -116,7 +116,7 @@ In addition to the monospace inline element, code blocks can be created by inden
|
|||
|
||||
Or by wrapping the code in three backticks (\`\`\`):
|
||||
|
||||
```javascript
|
||||
```
|
||||
function helloWorld() {
|
||||
console.log("Hello, world!")
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
The internet access permission is requested primarily for retrieving images from the internet in
|
||||
case you embed them in your markdown, but it also allows me to send automated error and crash
|
||||
reports to myself whenever the app runs into an issue. These error reports are opt-out, and are
|
||||
powered by [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/), which is a
|
||||
free error reporting solution provided by Google. These error reports are used exclusively for
|
||||
fixing problems that occur while you're using the app, along with some analytics info like how
|
||||
long you use the app for, how often, and which features of the app you use. This helps me to
|
||||
determine how to spend my very limited time on building out new features. I'll have to defer to
|
||||
[Google's Privacy Policy](https://policies.google.com/privacy) to explain how they handle the
|
||||
data. As for me, I don't knowingly or willingly sell or share your data.
|
||||
First and foremost, Simple Markdown DOES NOT collect any personally identifiable information. The
|
||||
internet access permission is requested primarily for retrieving images from the internet in
|
||||
case you embed them in your markdown, but it also allows me to send automated error and crash
|
||||
reports to myself whenever the app runs into an issue. These automated reports are powered by my own
|
||||
self-hosted version of [Sentry] (https://sentry.io/), which is a free and open source error
|
||||
reporting solution. These error reports are used exclusively for fixing problems that occur while
|
||||
you're using the app. For more information on the kinds of data that may be sent in these automated
|
||||
error reports, please see the [relevant documentation](https://docs.sentry.io/platforms/android/#context)
|
||||
on Sentry's website. If you would like to opt-out of these error reports, please visit the in-app
|
||||
settings page to disable the toggle for error reports.
|
||||
|
|
Before Width: | Height: | Size: 14 KiB |
|
@ -1,187 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import android.app.ComponentCaller
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.EaseIn
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
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.MarkdownInfoScreen
|
||||
import com.wbrawner.simplemarkdown.ui.SettingsScreen
|
||||
import com.wbrawner.simplemarkdown.ui.SupportScreen
|
||||
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
|
||||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.ACRA
|
||||
|
||||
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
|
||||
private val viewModel: MarkdownViewModel by viewModels {
|
||||
MarkdownViewModel.factory(
|
||||
fileHelper,
|
||||
preferenceHelper
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
|
||||
.collectAsState()
|
||||
val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE)
|
||||
.collectAsState()
|
||||
LaunchedEffect(darkModePreference) {
|
||||
val darkMode = when {
|
||||
darkModePreference.equals(
|
||||
getString(R.string.pref_value_light),
|
||||
ignoreCase = true
|
||||
) -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
|
||||
darkModePreference.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_BATTERY
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(darkMode)
|
||||
}
|
||||
val errorReporterPreference by preferenceHelper.observe<Boolean>(Preference.ERROR_REPORTS_ENABLED)
|
||||
.collectAsState()
|
||||
LaunchedEffect(errorReporterPreference) {
|
||||
ACRA.errorReporter.setEnabled(errorReporterPreference)
|
||||
}
|
||||
val intentData = remember(intent) { intent?.data }
|
||||
LaunchedEffect(intentData) {
|
||||
viewModel.load(intentData?.toString())
|
||||
}
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
SimpleMarkdownTheme {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.EDITOR.path,
|
||||
enterTransition = {
|
||||
fadeIn(
|
||||
animationSpec = tween(
|
||||
300, easing = LinearEasing
|
||||
)
|
||||
) + slideIntoContainer(
|
||||
animationSpec = tween(300, easing = EaseIn),
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start
|
||||
)
|
||||
},
|
||||
popEnterTransition = { fadeIn() },
|
||||
popExitTransition = {
|
||||
scaleOut(targetScale = 0.9f) + slideOutOfContainer(
|
||||
animationSpec = tween(300, easing = EaseIn),
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.End
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable(Route.EDITOR.path) {
|
||||
MainScreen(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
|
||||
enableAutosave = autosaveEnabled,
|
||||
)
|
||||
}
|
||||
composable(Route.SETTINGS.path) {
|
||||
SettingsScreen(navController = navController, preferenceHelper)
|
||||
}
|
||||
composable(Route.SUPPORT.path) {
|
||||
SupportScreen(navController = navController)
|
||||
}
|
||||
composable(Route.HELP.path) {
|
||||
MarkdownInfoScreen(
|
||||
title = stringResource(Route.HELP.title),
|
||||
file = "Cheatsheet.md",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Route.ABOUT.path) {
|
||||
MarkdownInfoScreen(
|
||||
title = stringResource(Route.ABOUT.title),
|
||||
file = "Libraries.md",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Route.PRIVACY.path) {
|
||||
MarkdownInfoScreen(
|
||||
title = stringResource(Route.PRIVACY.title),
|
||||
file = "Privacy Policy.md",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
super.onNewIntent(intent, caller)
|
||||
lifecycleScope.launch {
|
||||
intent.data?.let {
|
||||
viewModel.load(it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Route(
|
||||
val path: String,
|
||||
@StringRes
|
||||
val title: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
EDITOR("/", R.string.title_editor, Icons.Default.Edit),
|
||||
SETTINGS("/settings", R.string.title_settings, Icons.Default.Settings),
|
||||
SUPPORT("/support", R.string.support_title, Icons.Default.Favorite),
|
||||
HELP("/help", R.string.title_help, Icons.AutoMirrored.Filled.Help),
|
||||
ABOUT("/about", R.string.title_about, Icons.Default.Info),
|
||||
PRIVACY("/privacy", R.string.action_privacy, Icons.Default.PrivacyTip),
|
||||
}
|
|
@ -2,24 +2,19 @@ package com.wbrawner.simplemarkdown
|
|||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import com.wbrawner.simplemarkdown.core.ErrorReporterTree
|
||||
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.PreferenceHelper
|
||||
import com.wbrawner.simplemarkdown.utility.ReviewHelper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.wbrawner.simplemarkdown.utility.ErrorHandler
|
||||
import com.wbrawner.simplemarkdown.utility.SentryErrorHandler
|
||||
|
||||
class MarkdownApplication : Application() {
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
||||
val errorHandler: ErrorHandler by lazy {
|
||||
SentryErrorHandler()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
.getBoolean(getString(R.string.error_reports_enabled), true)
|
||||
errorHandler.init(this, enableErrorReports)
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
|
@ -29,28 +24,7 @@ class MarkdownApplication : Application() {
|
|||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
Timber.plant(Timber.DebugTree())
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
Timber.plant(PersistentTree.create(coroutineScope, File(getExternalFilesDir(null), "logs")))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unable to create PersistentTree")
|
||||
}
|
||||
}
|
||||
}
|
||||
coroutineScope.launch {
|
||||
Timber.plant(ErrorReporterTree.create(this@MarkdownApplication))
|
||||
}
|
||||
super.onCreate()
|
||||
ReviewHelper.init(this)
|
||||
fileHelper = AndroidFileHelper(this)
|
||||
preferenceHelper = AndroidPreferenceHelper(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var fileHelper: FileHelper
|
||||
private set
|
||||
lateinit var preferenceHelper: PreferenceHelper
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,321 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.wbrawner.simplemarkdown.core.LocalOnlyException
|
||||
import com.wbrawner.simplemarkdown.model.Readability
|
||||
import com.wbrawner.simplemarkdown.utility.FileHelper
|
||||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
data class EditorState(
|
||||
val fileName: String = "Untitled.md",
|
||||
val markdown: TextFieldValue = TextFieldValue(""),
|
||||
val path: URI? = null,
|
||||
val toast: ParameterizedText? = null,
|
||||
val alert: AlertDialogModel? = null,
|
||||
val saveCallback: (() -> Unit)? = null,
|
||||
val lockSwiping: Boolean = false,
|
||||
val enableReadability: Boolean = false,
|
||||
val initialMarkdown: String = "",
|
||||
) {
|
||||
val dirty: Boolean
|
||||
get() = markdown.text != initialMarkdown
|
||||
}
|
||||
|
||||
class MarkdownViewModel(
|
||||
private val fileHelper: FileHelper,
|
||||
private val preferenceHelper: PreferenceHelper
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(EditorState())
|
||||
val state = _state.asStateFlow()
|
||||
private val saveMutex = Mutex()
|
||||
|
||||
init {
|
||||
preferenceHelper.observe<Boolean>(Preference.LOCK_SWIPING)
|
||||
.onEach {
|
||||
updateState { copy(lockSwiping = it) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
|
||||
.onEach {
|
||||
updateState {
|
||||
copy(
|
||||
enableReadability = it,
|
||||
markdown = markdown.copy(annotatedString = markdown.text.annotate(it)),
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun updateMarkdown(markdown: String?) = updateMarkdown(TextFieldValue(markdown.orEmpty()))
|
||||
|
||||
fun updateMarkdown(markdown: TextFieldValue) {
|
||||
updateState {
|
||||
copy(
|
||||
markdown = markdown.copy(annotatedString = markdown.text.annotate(enableReadability)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissToast() {
|
||||
updateState { copy(toast = null) }
|
||||
}
|
||||
|
||||
fun dismissAlert() {
|
||||
updateState { copy(alert = null) }
|
||||
}
|
||||
|
||||
private fun unsetSaveCallback() {
|
||||
updateState { copy(saveCallback = null) }
|
||||
}
|
||||
|
||||
suspend fun load(loadPath: String?) {
|
||||
saveMutex.withLock {
|
||||
val actualLoadPath = loadPath
|
||||
?.ifBlank { null }
|
||||
?: preferenceHelper[Preference.AUTOSAVE_URI]
|
||||
?.let {
|
||||
val autosaveUri = it as? String
|
||||
if (autosaveUri.isNullOrBlank()) {
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||
null
|
||||
} else {
|
||||
Timber.d("Using uri from shared preferences: $it")
|
||||
autosaveUri
|
||||
}
|
||||
} ?: return
|
||||
Timber.d("Loading file at $actualLoadPath")
|
||||
try {
|
||||
val uri = URI.create(actualLoadPath)
|
||||
fileHelper.open(uri)
|
||||
?.let { (name, content) ->
|
||||
updateState {
|
||||
copy(
|
||||
path = uri,
|
||||
fileName = name,
|
||||
markdown = TextFieldValue(content),
|
||||
initialMarkdown = content,
|
||||
toast = ParameterizedText(R.string.file_loaded, arrayOf(name))
|
||||
)
|
||||
}
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
|
||||
} ?: throw IllegalStateException("Opened file was null")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath")
|
||||
updateState {
|
||||
copy(
|
||||
alert = AlertDialogModel(
|
||||
text = ParameterizedText(R.string.file_load_error),
|
||||
confirmButton = AlertDialogModel.ButtonModel(
|
||||
ParameterizedText(R.string.ok),
|
||||
onClick = ::dismissAlert
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
|
||||
saveMutex.withLock {
|
||||
val actualSavePath = savePath
|
||||
?: _state.value.path
|
||||
?: run {
|
||||
Timber.w("Attempted to save file with empty path")
|
||||
if (interactive) {
|
||||
updateState {
|
||||
copy(saveCallback = ::unsetSaveCallback)
|
||||
}
|
||||
}
|
||||
return@withLock false
|
||||
}
|
||||
try {
|
||||
Timber.i("Saving file to $actualSavePath...")
|
||||
val currentState = _state.value
|
||||
val name = fileHelper.save(actualSavePath, currentState.markdown.text)
|
||||
updateState {
|
||||
currentState.copy(
|
||||
fileName = name,
|
||||
path = actualSavePath,
|
||||
initialMarkdown = currentState.markdown.text,
|
||||
toast = if (interactive) ParameterizedText(
|
||||
R.string.file_saved,
|
||||
arrayOf(name)
|
||||
) else null
|
||||
)
|
||||
}
|
||||
Timber.i("Saved file $name to uri $actualSavePath")
|
||||
Timber.i("Persisting autosave uri in shared prefs: $actualSavePath")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to save file to $actualSavePath")
|
||||
updateState {
|
||||
copy(
|
||||
alert = AlertDialogModel(
|
||||
text = ParameterizedText(R.string.file_save_error),
|
||||
confirmButton = AlertDialogModel.ButtonModel(
|
||||
text = ParameterizedText(R.string.ok),
|
||||
onClick = ::dismissAlert
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun autosave() {
|
||||
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
|
||||
if (!isAutoSaveEnabled) {
|
||||
Timber.i("Ignoring autosave as autosave not enabled")
|
||||
return
|
||||
}
|
||||
if (!_state.value.dirty) {
|
||||
Timber.d("Ignoring autosave as contents haven't changed")
|
||||
return
|
||||
}
|
||||
if (saveMutex.isLocked) {
|
||||
Timber.i("Ignoring autosave since manual save is already in progress")
|
||||
return
|
||||
}
|
||||
Timber.d("Performing autosave")
|
||||
if (!save(interactive = false)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// The user has left the app, with autosave enabled, and we don't already have a
|
||||
// Uri for them or for some reason we were unable to save to the original Uri. In
|
||||
// this case, we need to just save to internal file storage so that we can recover
|
||||
val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
|
||||
Timber.i("No cached uri for autosave, saving to $file instead")
|
||||
// Here we call the fileHelper directly so that the file is still registered as dirty.
|
||||
// This prevents the user from ending up in a scenario where they've autosaved the file
|
||||
// to an internal storage location, thus marking it as not dirty, but no longer able to
|
||||
// access the file if the accidentally go to create a new file without properly saving
|
||||
// the current one
|
||||
fileHelper.save(file, _state.value.markdown.text)
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset(untitledFileName: String, force: Boolean = false) {
|
||||
Timber.i("Resetting view model to default state")
|
||||
if (!force && _state.value.dirty) {
|
||||
updateState {
|
||||
copy(alert = AlertDialogModel(
|
||||
text = ParameterizedText(R.string.prompt_save_changes),
|
||||
confirmButton = AlertDialogModel.ButtonModel(
|
||||
text = ParameterizedText(R.string.yes),
|
||||
onClick = {
|
||||
_state.value = _state.value.copy(
|
||||
saveCallback = {
|
||||
reset(untitledFileName, false)
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
dismissButton = AlertDialogModel.ButtonModel(
|
||||
text = ParameterizedText(R.string.no),
|
||||
onClick = {
|
||||
reset(untitledFileName, true)
|
||||
}
|
||||
)
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
updateState {
|
||||
EditorState(
|
||||
fileName = untitledFileName,
|
||||
lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean
|
||||
)
|
||||
}
|
||||
Timber.i("Removing autosave uri from shared prefs")
|
||||
preferenceHelper[Preference.AUTOSAVE_URI] = null
|
||||
}
|
||||
|
||||
fun setLockSwiping(enabled: Boolean) {
|
||||
preferenceHelper[Preference.LOCK_SWIPING] = enabled
|
||||
}
|
||||
|
||||
private fun updateState(block: EditorState.() -> EditorState) {
|
||||
_state.value = _state.value.block()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AlertDialogModel(
|
||||
val text: ParameterizedText,
|
||||
val confirmButton: ButtonModel,
|
||||
val dismissButton: ButtonModel? = null
|
||||
) {
|
||||
data class ButtonModel(val text: ParameterizedText, val onClick: () -> Unit)
|
||||
}
|
||||
|
||||
data class ParameterizedText(@StringRes val text: Int, val params: Array<Any> = arrayOf()) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParameterizedText
|
||||
|
||||
if (text != other.text) return false
|
||||
if (!params.contentEquals(other.params)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = text
|
||||
result = 31 * result + params.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
|
||||
if (!enableReadability) return AnnotatedString(this)
|
||||
val readability = Readability(this)
|
||||
val annotated = AnnotatedString.Builder(this)
|
||||
for (sentence in readability.sentences()) {
|
||||
var color = Color.Transparent
|
||||
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
|
||||
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
|
||||
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
|
||||
}
|
||||
return annotated.toAnnotatedString()
|
||||
}
|
|
@ -8,7 +8,7 @@ class Readability(private val content: String) {
|
|||
val list = ArrayList<Sentence>()
|
||||
var startOfSentance = 0
|
||||
var lineBuilder = StringBuilder()
|
||||
for (i in content.indices) {
|
||||
for (i in 0 until content.length) {
|
||||
val c = content[i] + ""
|
||||
if (DELIMS.contains(c)) {
|
||||
list.add(Sentence(content, startOfSentance, i))
|
||||
|
|
|
@ -1,369 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.simplemarkdown.AlertDialogModel
|
||||
import com.wbrawner.simplemarkdown.EditorState
|
||||
import com.wbrawner.simplemarkdown.MarkdownViewModel
|
||||
import com.wbrawner.simplemarkdown.ParameterizedText
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.Route
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navController: NavController,
|
||||
viewModel: MarkdownViewModel,
|
||||
enableWideLayout: Boolean,
|
||||
enableAutosave: Boolean,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val fileName by viewModel.collectAsState(EditorState::fileName, "")
|
||||
val markdown by viewModel.collectAsState(EditorState::markdown, TextFieldValue(""))
|
||||
val dirty by viewModel.collectAsState(EditorState::dirty, false)
|
||||
val alert by viewModel.collectAsState(EditorState::alert, null)
|
||||
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
|
||||
val lockSwiping by viewModel.collectAsState(EditorState::lockSwiping, false)
|
||||
LaunchedEffect(enableAutosave) {
|
||||
if (!enableAutosave) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
viewModel.autosave()
|
||||
}
|
||||
}
|
||||
val toast by viewModel.collectAsState(EditorState::toast, null)
|
||||
MainScreen(
|
||||
dirty = dirty,
|
||||
fileName = fileName,
|
||||
markdown = markdown,
|
||||
setMarkdown = viewModel::updateMarkdown,
|
||||
lockSwiping = lockSwiping,
|
||||
toggleLockSwiping = viewModel::setLockSwiping,
|
||||
message = toast?.stringRes(),
|
||||
dismissMessage = viewModel::dismissToast,
|
||||
alert = alert,
|
||||
dismissAlert = viewModel::dismissAlert,
|
||||
navigate = {
|
||||
navController.navigate(it.path)
|
||||
},
|
||||
navigateBack = { navController.popBackStack() },
|
||||
loadFile = {
|
||||
coroutineScope.launch {
|
||||
viewModel.load(it.toString())
|
||||
}
|
||||
},
|
||||
saveFile = {
|
||||
coroutineScope.launch {
|
||||
viewModel.save(it)
|
||||
}
|
||||
},
|
||||
saveCallback = saveCallback,
|
||||
reset = {
|
||||
viewModel.reset("Untitled.md")
|
||||
},
|
||||
enableWideLayout = enableWideLayout,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
fileName: String = "Untitled.md",
|
||||
dirty: Boolean = false,
|
||||
markdown: TextFieldValue = TextFieldValue(""),
|
||||
setMarkdown: (TextFieldValue) -> Unit = {},
|
||||
lockSwiping: Boolean,
|
||||
toggleLockSwiping: (Boolean) -> Unit,
|
||||
message: String? = null,
|
||||
dismissMessage: () -> Unit = {},
|
||||
alert: AlertDialogModel? = null,
|
||||
dismissAlert: () -> Unit = {},
|
||||
navigate: (Route) -> Unit = {},
|
||||
navigateBack: () -> Unit = {},
|
||||
loadFile: (Uri?) -> Unit = {},
|
||||
saveFile: (URI?) -> Unit = {},
|
||||
saveCallback: (() -> Unit)? = null,
|
||||
reset: () -> Unit = {},
|
||||
enableWideLayout: Boolean = false,
|
||||
) {
|
||||
val openFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
loadFile(it)
|
||||
}
|
||||
val saveFileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
|
||||
it?.let { uri -> saveFile(URI.create(uri.toString())) }
|
||||
}
|
||||
saveCallback?.let { callback ->
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
|
||||
it?.let { uri -> saveFile(URI.create(uri.toString())) }
|
||||
callback()
|
||||
}
|
||||
LaunchedEffect(callback) {
|
||||
launcher.launch(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
val snackBarState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
snackBarState.showSnackbar(it)
|
||||
dismissMessage()
|
||||
}
|
||||
}
|
||||
|
||||
alert?.let {
|
||||
AlertDialog(
|
||||
onDismissRequest = dismissAlert,
|
||||
confirmButton = {
|
||||
TextButton(onClick = it.confirmButton.onClick) {
|
||||
Text(stringResource(it.confirmButton.text.text))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
it.dismissButton?.let { dismissButton ->
|
||||
TextButton(onClick = dismissButton.onClick) {
|
||||
Text(dismissButton.text.stringRes())
|
||||
}
|
||||
}
|
||||
},
|
||||
text = { Text(it.text.stringRes()) }
|
||||
)
|
||||
}
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
MarkdownNavigationDrawer(navigate) { drawerState ->
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val context = LocalContext.current
|
||||
MarkdownTopAppBar(
|
||||
title = if (dirty) "$fileName*" else fileName,
|
||||
backAsUp = false,
|
||||
goBack = navigateBack,
|
||||
drawerState = drawerState,
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown.text)
|
||||
shareIntent.type = "text/plain"
|
||||
startActivity(
|
||||
context, Intent.createChooser(
|
||||
shareIntent, context.getString(R.string.share_file)
|
||||
), null
|
||||
)
|
||||
}) {
|
||||
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(R.string.action_share))
|
||||
}
|
||||
Box {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(imageVector = Icons.Default.MoreVert, stringResource(R.string.action_editor_actions))
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.action_new)) }, onClick = {
|
||||
menuExpanded = false
|
||||
reset()
|
||||
})
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.action_open)) }, onClick = {
|
||||
menuExpanded = false
|
||||
openFileLauncher.launch(arrayOf("text/*"))
|
||||
})
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.action_save)) }, onClick = {
|
||||
menuExpanded = false
|
||||
saveFile(null)
|
||||
})
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.action_save_as )) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
saveFileLauncher.launch(fileName)
|
||||
})
|
||||
if (!enableWideLayout) {
|
||||
DropdownMenuItem(text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.action_lock_swipe))
|
||||
Checkbox(
|
||||
checked = lockSwiping,
|
||||
onCheckedChange = toggleLockSwiping
|
||||
)
|
||||
}
|
||||
}, onClick = {
|
||||
toggleLockSwiping(!lockSwiping)
|
||||
menuExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
modifier = Modifier.imePadding(),
|
||||
hostState = snackBarState
|
||||
) {
|
||||
Snackbar(it)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (enableWideLayout) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
MarkdownTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
markdown = markdown.text
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
TabbedMarkdownEditor(
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
lockSwiping = lockSwiping,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun TabbedMarkdownEditor(
|
||||
markdown: TextFieldValue,
|
||||
setMarkdown: (TextFieldValue) -> Unit,
|
||||
lockSwiping: Boolean,
|
||||
scrollBehavior: TopAppBarScrollBehavior
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { 2 }
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
Tab(text = { Text(stringResource(R.string.action_edit)) },
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } })
|
||||
Tab(text = { Text(stringResource(R.string.action_preview)) },
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } })
|
||||
}
|
||||
HorizontalPager(
|
||||
modifier = Modifier.fillMaxSize(1f), state = pagerState,
|
||||
beyondViewportPageCount = 1,
|
||||
userScrollEnabled = !lockSwiping
|
||||
) { page ->
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(page) {
|
||||
when (page) {
|
||||
0 -> keyboardController?.show()
|
||||
else -> keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
if (page == 0) {
|
||||
MarkdownTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
markdown = markdown,
|
||||
setMarkdown = setMarkdown,
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
markdown.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <P> MarkdownViewModel.collectAsState(prop: KProperty1<EditorState, P>, initial: P): State<P> =
|
||||
remember(prop) { state.map { prop.get(it) }.distinctUntilChanged() }.collectAsState(initial)
|
||||
|
||||
@Composable
|
||||
fun ParameterizedText.stringRes() = stringResource(text, *params)
|
|
@ -1,43 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.simplemarkdown.utility.readAssetToString
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MarkdownInfoScreen(
|
||||
title: String,
|
||||
file: String,
|
||||
navController: NavController,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MarkdownTopAppBar(
|
||||
title = title,
|
||||
goBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val context = LocalContext.current
|
||||
val (markdown, setMarkdown) = remember { mutableStateOf("") }
|
||||
LaunchedEffect(file) {
|
||||
setMarkdown(context.assets.readAssetToString(file))
|
||||
}
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
markdown = markdown
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.DismissibleDrawerSheet
|
||||
import androidx.compose.material3.DismissibleNavigationDrawer
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.Route
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MarkdownNavigationDrawer(
|
||||
navigate: (Route) -> Unit, content: @Composable (drawerState: DrawerState) -> Unit
|
||||
) {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
BackHandler(enabled = drawerState.isOpen) {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
DismissibleNavigationDrawer(
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
DismissibleDrawerSheet {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(96.dp),
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
Route.entries.forEach { route ->
|
||||
if (route == Route.EDITOR) {
|
||||
return@forEach
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
icon = {
|
||||
Icon(imageVector = route.icon, contentDescription = null)
|
||||
},
|
||||
label = { Text(stringResource(route.title)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
navigate(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
content(drawerState)
|
||||
}
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Color.TRANSPARENT
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.wbrawner.simplemarkdown.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.commonmark.ext.autolink.AutolinkExtension
|
||||
import org.commonmark.ext.front.matter.YamlFrontMatterExtension
|
||||
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension
|
||||
import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
|
||||
import org.commonmark.ext.image.attributes.ImageAttributesExtension
|
||||
import org.commonmark.ext.task.list.items.TaskListItemsExtension
|
||||
import org.commonmark.parser.Parser
|
||||
import org.commonmark.renderer.html.HtmlRenderer
|
||||
|
||||
private val markdownExtensions = listOf(
|
||||
AutolinkExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TablesExtension.create(),
|
||||
HeadingAnchorExtension.create(),
|
||||
YamlFrontMatterExtension.create(),
|
||||
ImageAttributesExtension.create(),
|
||||
TaskListItemsExtension.create(),
|
||||
)
|
||||
|
||||
private val markdownParser = Parser.builder()
|
||||
.extensions(markdownExtensions)
|
||||
.build()
|
||||
|
||||
private val renderer = HtmlRenderer.builder()
|
||||
.extensions(markdownExtensions)
|
||||
.build()
|
||||
|
||||
@Composable
|
||||
fun MarkdownText(modifier: Modifier = Modifier, markdown: String) {
|
||||
val (html, setHtml) = remember { mutableStateOf("") }
|
||||
LaunchedEffect(markdown) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val parsedHtml = renderer.render(
|
||||
markdownParser.parse(markdown)
|
||||
)
|
||||
setHtml(parsedHtml)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
HtmlText(html = html)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
@Composable
|
||||
fun HtmlText(html: String, modifier: Modifier = Modifier) {
|
||||
val materialColors = MaterialTheme.colorScheme
|
||||
val style = remember(isSystemInDarkTheme()) {
|
||||
"""body {
|
||||
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
|
||||
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|
||||
|}
|
||||
|a {
|
||||
| color: #${materialColors.secondary.toArgb().toHexString().substring(2)};
|
||||
|}
|
||||
|pre {
|
||||
| background: #${materialColors.surfaceVariant.toArgb().toHexString().substring(2)};
|
||||
| color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)};
|
||||
|}""".trimMargin().wrapTag("style")
|
||||
}
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
FrameLayout(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
addView(
|
||||
WebView(context).apply {
|
||||
tag = WEBVIEW_TAG
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setBackgroundColor(TRANSPARENT)
|
||||
isNestedScrollingEnabled = false
|
||||
settings.javaScriptEnabled = true
|
||||
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { frameLayout ->
|
||||
frameLayout.findViewWithTag<WebView>(WEBVIEW_TAG)
|
||||
.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private const val WEBVIEW_TAG = "com.wbrawner.simplemarkdown.MarkdownText#WebView"
|
||||
|
||||
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"
|
|
@ -1,82 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MarkdownTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
markdown: TextFieldValue,
|
||||
setMarkdown: (TextFieldValue) -> Unit,
|
||||
) {
|
||||
val colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val textStyle = TextStyle.Default.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||
BasicTextField(
|
||||
value = markdown,
|
||||
modifier = modifier.imePadding(),
|
||||
onValueChange = setMarkdown,
|
||||
enabled = true,
|
||||
readOnly = false,
|
||||
textStyle = textStyle,
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
keyboardActions = KeyboardActions.Default,
|
||||
interactionSource = interactionSource,
|
||||
singleLine = false,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
minLines = 1,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
// places leading icon, text field with label and placeholder, trailing icon
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = markdown.text,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.markdown_here))
|
||||
},
|
||||
singleLine = false,
|
||||
enabled = true,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MarkdownTopAppBar(
|
||||
title: String,
|
||||
goBack: () -> Unit,
|
||||
backAsUp: Boolean = true,
|
||||
drawerState: DrawerState? = null,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
colors = topAppBarColors(scrolledContainerColor = MaterialTheme.colorScheme.surface),
|
||||
navigationIcon = {
|
||||
val (icon, contentDescription, onClick) = remember {
|
||||
if (backAsUp) {
|
||||
Triple(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.action_back), goBack)
|
||||
} else {
|
||||
Triple(
|
||||
Icons.Default.Menu, context.getString(R.string.action_menu)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
if (drawerState?.isOpen == true) {
|
||||
drawerState.close()
|
||||
} else {
|
||||
drawerState?.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onClick() }) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription)
|
||||
}
|
||||
},
|
||||
actions = actions ?: {},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
|
@ -1,258 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.simplemarkdown.BuildConfig
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
|
||||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
|
||||
Scaffold(topBar = {
|
||||
MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
|
||||
}) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
BooleanPreference(
|
||||
title = stringResource(R.string.pref_title_autosave),
|
||||
enabledDescription = stringResource(R.string.pref_autosave_on),
|
||||
disabledDescription = stringResource(R.string.pref_autosave_off),
|
||||
preference = Preference.AUTOSAVE_ENABLED,
|
||||
preferenceHelper = preferenceHelper
|
||||
)
|
||||
ListPreference(
|
||||
title = stringResource(R.string.title_dark_mode),
|
||||
options = stringArrayResource(R.array.pref_values_dark_mode),
|
||||
preference = Preference.DARK_MODE,
|
||||
preferenceHelper = preferenceHelper
|
||||
)
|
||||
BooleanPreference(
|
||||
title = stringResource(R.string.pref_title_error_reports),
|
||||
enabledDescription = stringResource(R.string.pref_error_reports_on),
|
||||
disabledDescription = stringResource(R.string.pref_error_reports_off),
|
||||
preference = Preference.ERROR_REPORTS_ENABLED,
|
||||
preferenceHelper = preferenceHelper
|
||||
)
|
||||
BooleanPreference(
|
||||
title = stringResource(R.string.pref_title_analytics),
|
||||
enabledDescription = stringResource(R.string.pref_analytics_on),
|
||||
disabledDescription = stringResource(R.string.pref_analytics_off),
|
||||
preference = Preference.ANALYTICS_ENABLED,
|
||||
preferenceHelper = preferenceHelper
|
||||
)
|
||||
BooleanPreference(
|
||||
title = stringResource(R.string.pref_title_readability),
|
||||
enabledDescription = stringResource(R.string.pref_readability_on),
|
||||
disabledDescription = stringResource(R.string.pref_readability_off),
|
||||
preference = Preference.READABILITY_ENABLED,
|
||||
preferenceHelper = preferenceHelper
|
||||
)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
error("Forced crash")
|
||||
}
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(text = stringResource(R.string.action_force_crash), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.description_force_crash),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BooleanPreference(
|
||||
title: String,
|
||||
enabledDescription: String,
|
||||
disabledDescription: String,
|
||||
preference: Preference,
|
||||
preferenceHelper: PreferenceHelper
|
||||
) {
|
||||
var enabled by remember {
|
||||
mutableStateOf(preferenceHelper[preference] as Boolean)
|
||||
}
|
||||
BooleanPreference(title = title,
|
||||
enabledDescription = enabledDescription,
|
||||
disabledDescription = disabledDescription,
|
||||
enabled = enabled,
|
||||
setEnabled = {
|
||||
enabled = it
|
||||
preferenceHelper[preference] = it
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BooleanPreference(
|
||||
title: String,
|
||||
enabledDescription: String,
|
||||
disabledDescription: String,
|
||||
enabled: Boolean,
|
||||
setEnabled: (Boolean) -> Unit
|
||||
) {
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
setEnabled(!enabled)
|
||||
}
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = if (enabled) enabledDescription else disabledDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = enabled, onCheckedChange = setEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListPreference(
|
||||
title: String,
|
||||
options: Array<String>,
|
||||
preference: Preference,
|
||||
preferenceHelper: PreferenceHelper
|
||||
) {
|
||||
var selected by remember {
|
||||
mutableStateOf(preferenceHelper[preference] as String)
|
||||
}
|
||||
|
||||
ListPreference(title = title, options = options, selected = selected, setSelected = {
|
||||
selected = it
|
||||
preferenceHelper[preference] = it
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListPreference(
|
||||
title: String, options: Array<String>, selected: String, setSelected: (String) -> Unit
|
||||
) {
|
||||
var dialogShowing by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
dialogShowing = true
|
||||
}
|
||||
.padding(16.dp), verticalArrangement = Arrangement.Center) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = selected,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (dialogShowing) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
onDismissRequest = { dialogShowing = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { dialogShowing = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
options.forEach { option ->
|
||||
val onClick = {
|
||||
setSelected(option)
|
||||
dialogShowing = false
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = option == selected, onClick = onClick)
|
||||
Text(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BooleanPreference_Preview() {
|
||||
val (enabled, setEnabled) = remember { mutableStateOf(true) }
|
||||
SimpleMarkdownTheme {
|
||||
Surface {
|
||||
BooleanPreference(
|
||||
"Autosave",
|
||||
"Files will be saved automatically",
|
||||
"Files will not be saved automatically",
|
||||
enabled,
|
||||
setEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun ListPreference_Preview() {
|
||||
val (selected, setSelected) = remember { mutableStateOf("Auto") }
|
||||
SimpleMarkdownTheme {
|
||||
Surface {
|
||||
ListPreference(
|
||||
"Dark mode", arrayOf("Light", "Dark", "Auto"), selected, setSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_ON
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.utility.SupportLinks
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SupportScreen(navController: NavController) {
|
||||
Scaffold(topBar = {
|
||||
MarkdownTopAppBar(title = stringResource(R.string.support_title), goBack = { navController.popBackStack() })
|
||||
}) { paddingValues ->
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(100.dp),
|
||||
imageVector = Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(stringResource(R.string.support_info), textAlign = TextAlign.Center)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
CustomTabsIntent.Builder()
|
||||
.setShareState(SHARE_STATE_ON)
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse("https://github.com/wbrawner/SimpleMarkdown"))
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(context.getColor(R.color.colorBackgroundGitHub)),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_view_github))
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
val playStoreIntent = Intent(Intent.ACTION_VIEW)
|
||||
.apply {
|
||||
data = Uri.parse("market://details?id=${context.packageName}")
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NO_HISTORY or
|
||||
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
|
||||
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||
)
|
||||
}
|
||||
try {
|
||||
startActivity(context, playStoreIntent, null)
|
||||
} catch (ignored: ActivityNotFoundException) {
|
||||
playStoreIntent.data =
|
||||
Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}")
|
||||
startActivity(context, playStoreIntent, null)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(context.getColor(R.color.colorBackgroundPlayStore)),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_rate))
|
||||
}
|
||||
SupportLinks()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val md_theme_light_primary = Color(0xFFBA1A20)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF410003)
|
||||
val md_theme_light_secondary = Color(0xFF775653)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF2C1513)
|
||||
val md_theme_light_tertiary = Color(0xFF725B2E)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFFEDEA6)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF261900)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onBackground = Color(0xFF201A19)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onSurface = Color(0xFF201A19)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFF5DDDB)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF534342)
|
||||
val md_theme_light_outline = Color(0xFF857371)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFFBEEEC)
|
||||
val md_theme_light_inverseSurface = Color(0xFF362F2E)
|
||||
val md_theme_light_inversePrimary = Color(0xFFFFB3AC)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFFBA1A20)
|
||||
val md_theme_light_outlineVariant = Color(0xFFD8C2BF)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFFFFB3AC)
|
||||
val md_theme_dark_onPrimary = Color(0xFF680008)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF930010)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_secondary = Color(0xFFE7BDB8)
|
||||
val md_theme_dark_onSecondary = Color(0xFF442927)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF5D3F3C)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_tertiary = Color(0xFFE1C38C)
|
||||
val md_theme_dark_onTertiary = Color(0xFF3F2D04)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF584419)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFFEDEA6)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF201A19)
|
||||
val md_theme_dark_onBackground = Color(0xFFEDE0DE)
|
||||
val md_theme_dark_surface = Color(0xFF201A19)
|
||||
val md_theme_dark_onSurface = Color(0xFFEDE0DE)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF534342)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BF)
|
||||
val md_theme_dark_outline = Color(0xFFA08C8A)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF201A19)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFEDE0DE)
|
||||
val md_theme_dark_inversePrimary = Color(0xFFBA1A20)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFFFFB3AC)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF534342)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFFD32F2F)
|
|
@ -1,90 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleMarkdownTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (!useDarkTheme) {
|
||||
LightColors
|
||||
} else {
|
||||
DarkColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content
|
||||
)
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.wbrawner.simplemarkdown.BuildConfig
|
||||
import io.sentry.android.core.SentryAndroid
|
||||
import io.sentry.core.Sentry
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
interface ErrorHandler {
|
||||
fun init(context: Context, enable: Boolean)
|
||||
fun enable(enable: Boolean)
|
||||
fun reportException(t: Throwable, message: String? = null)
|
||||
}
|
||||
|
||||
class SentryErrorHandler : ErrorHandler {
|
||||
private lateinit var enabled: AtomicBoolean
|
||||
|
||||
override fun init(context: Context, enable: Boolean) {
|
||||
enabled = AtomicBoolean(enable)
|
||||
SentryAndroid.init(context) { options ->
|
||||
options.setBeforeSend { event, _ ->
|
||||
if (enabled.get()) {
|
||||
event
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable(enable: Boolean) {
|
||||
enabled.set(enable)
|
||||
}
|
||||
|
||||
override fun reportException(t: Throwable, message: String?) {
|
||||
@Suppress("ConstantConditionIf")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e("SentryErrorHandler", "Caught exception: $message", t)
|
||||
}
|
||||
Sentry.captureException(t)
|
||||
}
|
||||
}
|
|
@ -1,21 +1,42 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
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
|
||||
|
||||
suspend fun AssetManager.readAssetToString(asset: String): String {
|
||||
fun View.showKeyboard() {
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
|
||||
.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0)
|
||||
requestFocus()
|
||||
}
|
||||
|
||||
fun View.hideKeyboard() =
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
|
||||
.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 {
|
||||
|
@ -41,11 +62,3 @@ suspend fun Uri.getName(context: Context): String {
|
|||
}
|
||||
return fileName ?: "Untitled.md"
|
||||
}
|
||||
|
||||
@Suppress("RecursivePropertyAccessor")
|
||||
val Context.activity: Activity?
|
||||
get() = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.activity
|
||||
else -> null
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
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 by lazy {
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
|
||||
?: context.filesDir
|
||||
}
|
||||
|
||||
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val uri = source.toString().toUri()
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// We weren't granted the persistent read/write permission for this file.
|
||||
// TODO: Return whether or not we got the persistent permission in order to determine
|
||||
// whether or not we should show this file in the recent files section
|
||||
}
|
||||
context.contentResolver.openFileDescriptor(uri, "r")
|
||||
?.use {
|
||||
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
|
||||
.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)
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.util.Log
|
||||
import com.wbrawner.simplemarkdown.utility.PersistentTree.Companion.create
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.PrintStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* A [Timber.Tree] implementation that persists all logs to disk for retrieval later. Create
|
||||
* instances via [create] instead of calling the constructor directly.
|
||||
*/
|
||||
class PersistentTree private constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val logFile: File
|
||||
) : Timber.Tree() {
|
||||
private val dateFormat = object : ThreadLocal<SimpleDateFormat>() {
|
||||
override fun initialValue(): SimpleDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
}
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
val timestamp = dateFormat.get()!!.format(System.currentTimeMillis())
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val priorityLetter = when (priority) {
|
||||
Log.ASSERT -> "A"
|
||||
Log.DEBUG -> "D"
|
||||
Log.ERROR -> "E"
|
||||
Log.INFO -> "I"
|
||||
Log.VERBOSE -> "V"
|
||||
Log.WARN -> "W"
|
||||
else -> "U"
|
||||
}
|
||||
FileOutputStream(logFile, true).use { stream ->
|
||||
stream.bufferedWriter().use {
|
||||
it.appendLine("$timestamp $priorityLetter/${tag ?: "SimpleMarkdown"}: $message")
|
||||
}
|
||||
t?.let {
|
||||
PrintStream(stream).use { pStream ->
|
||||
it.printStackTrace(pStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
log(Log.INFO, "Persistent logging initialized, writing contents to ${logFile.absolutePath}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance of a [PersistentTree].
|
||||
* @param logDir A [File] pointing to a directory where the log files should be stored. Will be
|
||||
* created if it doesn't exist.
|
||||
* @throws IllegalArgumentException if [logDir] is a file instead of a directory
|
||||
* @throws IOException if the directory does not exist or cannot be
|
||||
* created/written to
|
||||
*/
|
||||
@Throws(IllegalArgumentException::class, IOException::class)
|
||||
suspend fun create(coroutineScope: CoroutineScope, logDir: File): PersistentTree = withContext(Dispatchers.IO) {
|
||||
if (!logDir.mkdirs() && !logDir.isDirectory)
|
||||
throw IllegalArgumentException("Unable to create log directory at ${logDir.absolutePath}")
|
||||
val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
|
||||
val logFile = File(logDir, "persistent-log-$timestamp.log")
|
||||
if (!logFile.createNewFile())
|
||||
throw IOException("Unable to create logFile at ${logFile.absolutePath}")
|
||||
PersistentTree(coroutineScope, logFile)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
package com.wbrawner.simplemarkdown.utility
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
|
||||
private val sharedPreferences by lazy {
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
}
|
||||
private val states by lazy {
|
||||
val allPrefs: Map<String, Any?> = sharedPreferences.all
|
||||
Preference.entries.associateWith { preference ->
|
||||
MutableStateFlow(allPrefs[preference.key] ?: preference.default)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(preference: Preference): Any? = states[preference]?.value
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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("acra.enable", true),
|
||||
LOCK_SWIPING("lockSwiping", false),
|
||||
READABILITY_ENABLED("readability.enable", false)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.wbrawner.simplemarkdown.view
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
|
||||
class DisableableViewPager : ViewPager {
|
||||
private var isSwipeLocked = false
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
return !isSwipeLocked && super.onInterceptTouchEvent(ev)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
return !isSwipeLocked && super.onTouchEvent(ev)
|
||||
}
|
||||
|
||||
fun setSwipeLocked(locked: Boolean) {
|
||||
this.isSwipeLocked = locked
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.wbrawner.simplemarkdown.view
|
||||
|
||||
interface ViewPagerPage {
|
||||
fun onSelected()
|
||||
fun onDeselected()
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.wbrawner.simplemarkdown.view.activity
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.navigation.findNavController
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope {
|
||||
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
private val viewModel: MarkdownViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
intent?.data?.let {
|
||||
launch {
|
||||
viewModel.load(this@MainActivity, it)
|
||||
intent?.data = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
findNavController(R.id.content).navigateUp()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineContext[Job]?.let {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package com.wbrawner.simplemarkdown.view.activity
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class SplashActivity : AppCompatActivity(), CoroutineScope {
|
||||
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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)
|
||||
)
|
||||
|
||||
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_BATTERY
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
AppCompatDelegate.setDefaultNightMode(darkMode)
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
intent?.data
|
||||
?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
|
||||
.getString(
|
||||
getString(R.string.pref_key_autosave_uri),
|
||||
null
|
||||
)?.let {
|
||||
Uri.parse(it)
|
||||
}
|
||||
}
|
||||
|
||||
val startIntent = Intent(this@SplashActivity, MainActivity::class.java)
|
||||
.apply {
|
||||
data = uri
|
||||
}
|
||||
startActivity(startIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineContext[Job]?.let {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package com.wbrawner.simplemarkdown.view.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.view.fragment.EditFragment
|
||||
import com.wbrawner.simplemarkdown.view.fragment.PreviewFragment
|
||||
|
||||
class EditPagerAdapter(fm: FragmentManager, private val context: Context)
|
||||
: FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT), ViewPager.OnPageChangeListener {
|
||||
|
||||
private val editFragment = EditFragment()
|
||||
private val previewFragment = PreviewFragment()
|
||||
|
||||
override fun getItem(position: Int): Fragment {
|
||||
return when (position) {
|
||||
FRAGMENT_EDIT -> editFragment
|
||||
FRAGMENT_PREVIEW -> previewFragment
|
||||
else -> throw IllegalStateException("Attempting to get fragment for invalid page number")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount(): Int {
|
||||
return NUM_PAGES
|
||||
}
|
||||
|
||||
override fun getPageTitle(position: Int): CharSequence? {
|
||||
var stringId = 0
|
||||
when (position) {
|
||||
FRAGMENT_EDIT -> stringId = R.string.action_edit
|
||||
FRAGMENT_PREVIEW -> stringId = R.string.action_preview
|
||||
}
|
||||
return context.getString(stringId)
|
||||
}
|
||||
|
||||
override fun getPageWidth(position: Int): Float {
|
||||
return if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
0.5f
|
||||
} else {
|
||||
super.getPageWidth(position)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
}
|
||||
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
when (position) {
|
||||
FRAGMENT_EDIT -> {
|
||||
editFragment.onSelected()
|
||||
}
|
||||
FRAGMENT_PREVIEW -> {
|
||||
editFragment.onDeselected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FRAGMENT_EDIT = 0
|
||||
const val FRAGMENT_PREVIEW = 1
|
||||
const val NUM_PAGES = 2
|
||||
}
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
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.util.Log
|
||||
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.activityViewModels
|
||||
import androidx.lifecycle.Observer
|
||||
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.EditorCommand
|
||||
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.math.abs
|
||||
|
||||
class EditFragment : Fragment(), ViewPagerPage, CoroutineScope {
|
||||
private var markdownEditor: EditText? = null
|
||||
private var markdownEditorScroller: NestedScrollView? = null
|
||||
private val viewModel: MarkdownViewModel by activityViewModels()
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
private var readabilityWatcher: TextWatcher? = null
|
||||
|
||||
@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(object : TextWatcher {
|
||||
private var searchFor = ""
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val searchText = s.toString().trim()
|
||||
if (searchText == searchFor)
|
||||
return
|
||||
|
||||
searchFor = searchText
|
||||
|
||||
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) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
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.markdown.value)
|
||||
viewModel.editorCommands.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is EditorCommand.Undo -> markdownEditor?.text?.
|
||||
}
|
||||
markdownEditor?.setText(it)
|
||||
})
|
||||
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 onDestroyView() {
|
||||
coroutineContext[Job]?.let {
|
||||
cancel()
|
||||
}
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
Log.d("SimpleMarkdown", "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
|
||||
Log.d("SimpleMarkdown", "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) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,301 @@
|
|||
package com.wbrawner.simplemarkdown.view.fragment
|
||||
|
||||
import android.Manifest
|
||||
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.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.onNavDestinationSelected
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.wbrawner.simplemarkdown.MarkdownApplication
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
|
||||
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
|
||||
import kotlinx.android.synthetic.main.fragment_main.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope {
|
||||
|
||||
private var shouldAutoSave = true
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
private val viewModel: MarkdownViewModel by activityViewModels()
|
||||
private var appBarConfiguration: AppBarConfiguration? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_main, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
with(findNavController()) {
|
||||
appBarConfiguration = AppBarConfiguration(graph, drawerLayout)
|
||||
toolbar.setupWithNavController(this, appBarConfiguration!!)
|
||||
toolbar.inflateMenu(R.menu.menu_edit)
|
||||
toolbar.setOnMenuItemClickListener { item ->
|
||||
return@setOnMenuItemClickListener when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
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 -> {
|
||||
requestFileOp(REQUEST_SAVE_FILE)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
|
||||
shareIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(
|
||||
shareIntent,
|
||||
getString(R.string.share_file)
|
||||
))
|
||||
true
|
||||
}
|
||||
R.id.action_load -> {
|
||||
requestFileOp(REQUEST_OPEN_FILE)
|
||||
true
|
||||
}
|
||||
R.id.action_new -> {
|
||||
promptSaveOrDiscardChanges()
|
||||
true
|
||||
}
|
||||
R.id.action_lock_swipe -> {
|
||||
item.isChecked = !item.isChecked
|
||||
pager!!.setSwipeLocked(item.isChecked)
|
||||
true
|
||||
}
|
||||
else -> item.onNavDestinationSelected(findNavController())
|
||||
}
|
||||
}
|
||||
navigationView.setupWithNavController(this)
|
||||
}
|
||||
val adapter = EditPagerAdapter(childFragmentManager, view.context)
|
||||
pager.adapter = adapter
|
||||
pager.addOnPageChangeListener(adapter)
|
||||
pager.pageMargin = 1
|
||||
pager.setPageMarginDrawable(R.color.colorAccent)
|
||||
tabLayout.setupWithViewPager(pager)
|
||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
tabLayout!!.visibility = View.GONE
|
||||
}
|
||||
@Suppress("CAST_NEVER_SUCCEEDS")
|
||||
viewModel.fileName.observe(viewLifecycleOwner, Observer {
|
||||
toolbar?.title = it
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.getBoolean(getString(R.string.error_reports_enabled), true)
|
||||
(requireActivity().application as MarkdownApplication).errorHandler.enable(enableErrorReports)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
launch {
|
||||
val context = context?.applicationContext ?: return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val isAutoSaveEnabled = sharedPrefs.getBoolean(KEY_AUTOSAVE, true)
|
||||
if (!shouldAutoSave || !isAutoSaveEnabled) {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val uri = if (viewModel.save(context)) {
|
||||
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(context.filesDir, viewModel.fileName.value!!))
|
||||
if (viewModel.save(context, fileUri)) {
|
||||
fileUri
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: return@withContext
|
||||
sharedPrefs.edit()
|
||||
.putString(getString(R.string.pref_key_autosave_uri), uri.toString())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
|
||||
tabLayout!!.visibility = View.GONE
|
||||
else
|
||||
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
|
||||
requestFileOp(requestCode)
|
||||
} else {
|
||||
// Permission denied, do nothing
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
||||
launch {
|
||||
val fileLoaded = context?.let {
|
||||
viewModel.load(it, data.data)
|
||||
}
|
||||
if (fileLoaded == false) {
|
||||
context?.let {
|
||||
Toast.makeText(it, R.string.file_load_error, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
REQUEST_SAVE_FILE -> {
|
||||
if (resultCode != Activity.RESULT_OK || data?.data == null) {
|
||||
return
|
||||
}
|
||||
|
||||
launch {
|
||||
context?.let {
|
||||
viewModel.save(it, data.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun promptSaveOrDiscardChanges() {
|
||||
if (viewModel.originalMarkdown.value == viewModel.markdown.value) {
|
||||
viewModel.reset("Untitled.md")
|
||||
return
|
||||
}
|
||||
val context = context ?: return
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(R.string.save_changes)
|
||||
.setMessage(R.string.prompt_save_changes)
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.reset("Untitled.md")
|
||||
}
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
requestFileOp(REQUEST_SAVE_FILE)
|
||||
}
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun requestFileOp(requestType: Int) {
|
||||
val context = context ?: return
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED && Build.VERSION.SDK_INT > 22) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
requestType
|
||||
)
|
||||
return
|
||||
}
|
||||
// If the user is going to save the file, we don't want to auto-save it for them
|
||||
shouldAutoSave = false
|
||||
val intent = when (requestType) {
|
||||
REQUEST_SAVE_FILE -> {
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
type = "text/markdown"
|
||||
putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
|
||||
}
|
||||
}
|
||||
REQUEST_OPEN_FILE -> {
|
||||
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 -> null
|
||||
} ?: return
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
startActivityForResult(
|
||||
intent,
|
||||
requestType
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
shouldAutoSave = true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineContext[Job]?.let {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Request codes
|
||||
const val REQUEST_OPEN_FILE = 1
|
||||
const val REQUEST_SAVE_FILE = 2
|
||||
const val REQUEST_DARK_MODE = 4
|
||||
const val KEY_AUTOSAVE = "autosave"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package com.wbrawner.simplemarkdown.view.fragment
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.wbrawner.simplemarkdown.MarkdownApplication
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.utility.readAssetToString
|
||||
import com.wbrawner.simplemarkdown.utility.toHtml
|
||||
import kotlinx.android.synthetic.main.fragment_markdown_info.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class MarkdownInfoFragment : Fragment(), CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_markdown_info, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val fileName = arguments?.getString(EXTRA_FILE)
|
||||
if (fileName.isNullOrBlank()) {
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
toolbar.setupWithNavController(findNavController())
|
||||
|
||||
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
|| resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
val defaultCssId = if (isNightMode) {
|
||||
R.string.pref_custom_css_default_dark
|
||||
} else {
|
||||
R.string.pref_custom_css_default
|
||||
}
|
||||
val css: String? = getString(defaultCssId)
|
||||
launch {
|
||||
try {
|
||||
val html = view.context.assets?.readAssetToString(fileName)
|
||||
?.toHtml()
|
||||
?: throw RuntimeException("Unable to open stream to $fileName")
|
||||
infoWebview.loadDataWithBaseURL(null,
|
||||
String.format(FORMAT_CSS, css) + html,
|
||||
"text/html",
|
||||
"UTF-8", null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
(requireActivity().application as MarkdownApplication).errorHandler.reportException(e)
|
||||
Toast.makeText(view.context, R.string.file_load_error, Toast.LENGTH_SHORT).show()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
coroutineContext[Job]?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
findNavController().navigateUp()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FORMAT_CSS = "<style>" +
|
||||
"%s" +
|
||||
"</style>"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_FILE = "file"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
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.activityViewModels
|
||||
import androidx.lifecycle.Observer
|
||||
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.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class PreviewFragment : Fragment(), CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
private val viewModel: MarkdownViewModel by activityViewModels()
|
||||
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)
|
||||
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.markdown.value ?: "")
|
||||
viewModel.markdown.observe(this, Observer {
|
||||
updateWebContent(it)
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateWebContent(markdown: String) {
|
||||
markdownPreview?.post {
|
||||
launch {
|
||||
markdownPreview?.loadDataWithBaseURL(null,
|
||||
style + markdown.toHtml(),
|
||||
"text/html",
|
||||
"UTF-8", null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
coroutineContext[Job]?.let {
|
||||
cancel()
|
||||
}
|
||||
markdownPreview?.let {
|
||||
(it.parent as ViewGroup).removeView(it)
|
||||
it.destroy()
|
||||
markdownPreview = null
|
||||
}
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var FORMAT_CSS = "<style>" +
|
||||
"%s" +
|
||||
"</style>"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.wbrawner.simplemarkdown.view.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import kotlinx.android.synthetic.main.fragment_settings.*
|
||||
|
||||
class SettingsContainerFragment : Fragment() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
toolbar.setupWithNavController(findNavController())
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = findNavController().navigateUp()
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package com.wbrawner.simplemarkdown.view.fragment
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.wbrawner.simplemarkdown.BuildConfig
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class SettingsFragment
|
||||
: PreferenceFragmentCompat(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
CoroutineScope {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_general)
|
||||
}
|
||||
|
||||
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
launch(context = Dispatchers.IO) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (!isAdded) return
|
||||
val preference = findPreference(key) as? ListPreference ?: return
|
||||
setListPreferenceSummary(sharedPreferences, preference)
|
||||
if (preference.key != getString(R.string.pref_key_dark_mode)) {
|
||||
return
|
||||
}
|
||||
var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
|
||||
} 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: ListPreference) {
|
||||
val storedValue = sharedPreferences.getString(
|
||||
preference.key,
|
||||
null
|
||||
) ?: return
|
||||
val index = preference.findIndexOfValue(storedValue)
|
||||
if (index < 0) return
|
||||
preference.summary = preference.entries[index].toString()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package com.wbrawner.simplemarkdown.view.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.wbrawner.simplemarkdown.R
|
||||
import com.wbrawner.simplemarkdown.utility.SupportLinkProvider
|
||||
import kotlinx.android.synthetic.main.fragment_support.*
|
||||
|
||||
class SupportFragment : Fragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_support, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
toolbar.setupWithNavController(findNavController())
|
||||
githubButton.setOnClickListener {
|
||||
CustomTabsIntent.Builder()
|
||||
.addDefaultShareMenuItem()
|
||||
.build()
|
||||
.launchUrl(view.context, Uri.parse("https://github" +
|
||||
".com/wbrawner/SimpleMarkdown"))
|
||||
}
|
||||
rateButton.setOnClickListener {
|
||||
val playStoreIntent = Intent(Intent.ACTION_VIEW)
|
||||
.apply {
|
||||
data = Uri.parse("market://details?id=${view.context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or
|
||||
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
|
||||
Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
}
|
||||
try {
|
||||
startActivity(playStoreIntent)
|
||||
} catch (ignored: ActivityNotFoundException) {
|
||||
playStoreIntent.data = Uri.parse("https://play.google.com/store/apps/details?id=${view.context.packageName}")
|
||||
startActivity(playStoreIntent)
|
||||
}
|
||||
}
|
||||
SupportLinkProvider(requireActivity()).supportLinks.observe(viewLifecycleOwner, Observer { links ->
|
||||
links.forEach {
|
||||
supportButtons.addView(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
// if (item.itemId == android.R.id.home) {
|
||||
// findNavController().navigateUp()
|
||||
// return true
|
||||
// }
|
||||
// return super.onOptionsItemSelected(item)
|
||||
// }
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package com.wbrawner.simplemarkdown.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.wbrawner.simplemarkdown.utility.getName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileInputStream
|
||||
import java.io.Reader
|
||||
|
||||
class MarkdownViewModel : ViewModel() {
|
||||
val fileName = MutableLiveData<String>("Untitled.md")
|
||||
val editorCommands = MutableLiveData<EditorCommand>()
|
||||
val markdown = MutableLiveData<String>()
|
||||
val uri = MutableLiveData<Uri>()
|
||||
|
||||
fun updateMarkdown(markdown: String?) {
|
||||
this.markdown.postValue(markdown ?: "")
|
||||
}
|
||||
|
||||
suspend fun load(context: Context, uri: Uri?): Boolean {
|
||||
if (uri == null) 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)
|
||||
markdown.postValue(content)
|
||||
editorCommands.postValue(EditorCommand.Load(content))
|
||||
this@MarkdownViewModel.fileName.postValue(fileName)
|
||||
this@MarkdownViewModel.uri.postValue(uri)
|
||||
true
|
||||
} ?: false
|
||||
} catch (ignored: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(context: Context, givenUri: Uri? = this.uri.value): Boolean {
|
||||
val uri = givenUri ?: this.uri.value ?: return false
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val fileName = uri.getName(context)
|
||||
context.contentResolver.openOutputStream(uri, "rwt")
|
||||
?.writer()
|
||||
?.use {
|
||||
it.write(markdown.value ?: "")
|
||||
}
|
||||
?: return@withContext false
|
||||
this@MarkdownViewModel.fileName.postValue(fileName)
|
||||
this@MarkdownViewModel.uri.postValue(uri)
|
||||
true
|
||||
} catch (ignored: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset(untitledFileName: String) {
|
||||
fileName.postValue(untitledFileName)
|
||||
editorCommands.postValue(EditorCommand.Load(""))
|
||||
markdown.postValue("")
|
||||
}
|
||||
|
||||
fun undo() {
|
||||
editorCommands.postValue(EditorCommand.Undo())
|
||||
}
|
||||
|
||||
fun redo() {
|
||||
editorCommands.postValue(EditorCommand.Redo())
|
||||
}
|
||||
}
|
||||
|
||||
sealed class EditorCommand {
|
||||
val consumed = false
|
||||
|
||||
class Undo: EditorCommand()
|
||||
class Redo: EditorCommand()
|
||||
class Load(val text: String): EditorCommand()
|
||||
}
|
BIN
app/src/main/res/drawable-hdpi/splash_fg.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/drawable-mdpi/splash_fg.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
app/src/main/res/drawable-xhdpi/splash_fg.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/drawable-xxhdpi/splash_fg.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/splash_fg.png
Normal file
After Width: | Height: | Size: 27 KiB |
9
app/src/main/res/drawable/ic_action_select.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
9
app/src/main/res/drawable/ic_eye_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
9
app/src/main/res/drawable/ic_favorite_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
9
app/src/main/res/drawable/ic_help_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
9
app/src/main/res/drawable/ic_info_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
|
@ -1,24 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="0.39862207"
|
||||
android:scaleY="0.39862207"
|
||||
android:translateX="27"
|
||||
android:translateY="27">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M55.033,2.91h25.4v129.646h-25.4z"
|
||||
android:strokeWidth="0.53079969" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M2.725,44.865l12.7,-21.997l117.318,67.733l-12.7,21.997z"
|
||||
android:strokeWidth="0.54258478" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M120.042,22.868l12.7,21.997l-117.318,67.733l-12.7,-21.997z"
|
||||
android:strokeWidth="0.54258478" />
|
||||
</group>
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_menu_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
10
app/src/main/res/drawable/ic_redo.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18.4,10.6C16.55,8.99 14.15,8 11.5,8c-4.65,0 -8.58,3.03 -9.96,7.22L3.9,16c1.05,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9V7l-3.6,3.6z"/>
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_settings_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
9
app/src/main/res/drawable/ic_share.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<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>
|
10
app/src/main/res/drawable/ic_undo.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.5,8c-2.65,0 -5.05,0.99 -6.9,2.6L2,7v9h9l-3.62,-3.62c1.39,-1.16 3.16,-1.88 5.12,-1.88 3.54,0 6.55,2.31 7.6,5.5l2.37,-0.78C21.08,11.03 17.15,8 12.5,8z"/>
|
||||
</vector>
|
12
app/src/main/res/drawable/splash_bg.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?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>
|
61
app/src/main/res/layout-land/fragment_main.xml
Normal file
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/drawerLayout">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.wbrawner.simplemarkdown.view.DisableableViewPager
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/bottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/colorBackground"
|
||||
app:layout_scrollFlags="scroll|enterAlways" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/colorBackground"
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:id="@+id/editTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_edit" />
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:id="@+id/previewTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_preview" />
|
||||
</com.google.android.material.tabs.TabLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/navigationView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:menu="@menu/menu_main" />
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
9
app/src/main/res/layout/activity_main.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/content"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:defaultNavHost="true"
|
||||
app:navGraph="@navigation/nav_graph" />
|
23
app/src/main/res/layout/fragment_edit.xml
Normal file
|
@ -0,0 +1,23 @@
|
|||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/markdown_edit_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.wbrawner.simplemarkdown.view.fragment.EditFragment">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/markdown_edit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@null"
|
||||
android:gravity="top"
|
||||
android:fontFamily="monospace"
|
||||
android:hint="@string/markdown_here"
|
||||
android:imeOptions="flagNoExtractUi"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingRight="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:scrollHorizontally="false"
|
||||
android:importantForAutofill="no" />
|
||||
</androidx.core.widget.NestedScrollView>
|
63
app/src/main/res/layout/fragment_main.xml
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/colorBackground"
|
||||
android:id="@+id/drawerLayout">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.wbrawner.simplemarkdown.view.DisableableViewPager
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/bottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:liftOnScroll="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/colorBackground"
|
||||
app:layout_scrollFlags="scroll|enterAlways|snap" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/colorBackground">
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:id="@+id/editTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_edit" />
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:id="@+id/previewTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_preview" />
|
||||
</com.google.android.material.tabs.TabLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/navigationView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:menu="@menu/menu_main" />
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
43
app/src/main/res/layout/fragment_markdown_info.xml
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.wbrawner.simplemarkdown.view.fragment.MarkdownInfoFragment">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBarLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="128dp"
|
||||
android:background="@color/colorBackground">
|
||||
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:elevation="0dp"
|
||||
app:layout_collapseMode="pin" />
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/infoWebview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
13
app/src/main/res/layout/fragment_preview.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.wbrawner.simplemarkdown.view.fragment.PreviewFragment">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:id="@+id/markdown_view" />
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
26
app/src/main/res/layout/fragment_settings.xml
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBarLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/colorBackground" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_settings"
|
||||
android:name="com.wbrawner.simplemarkdown.view.fragment.SettingsFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
78
app/src/main/res/layout/fragment_support.xml
Normal file
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/colorBackground"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/toolbar">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/heartIcon"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
android:src="@drawable/ic_favorite_black_24dp"
|
||||
android:tint="@color/colorAccent"
|
||||
android:contentDescription="@string/description_heart"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/supportInfoText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="@string/support_info"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/colorOnBackground"
|
||||
app:layout_constraintTop_toBottomOf="@+id/heartIcon" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/githubButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/colorBackgroundGitHub"
|
||||
android:textColor="@color/colorWhite"
|
||||
android:text="@string/action_view_github"
|
||||
app:layout_constraintTop_toBottomOf="@+id/supportInfoText" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/rateButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/colorBackgroundPlayStore"
|
||||
android:textColor="@color/colorWhite"
|
||||
android:text="@string/action_rate"
|
||||
app:layout_constraintTop_toBottomOf="@+id/githubButton" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/supportButtons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintTop_toBottomOf="@+id/rateButton" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
40
app/src/main/res/menu/menu_edit.xml
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_share"
|
||||
android:icon="@drawable/ic_share"
|
||||
android:title="@string/action_share"
|
||||
app:showAsAction="ifRoom" />
|
||||
<item
|
||||
android:id="@+id/action_undo"
|
||||
android:icon="@drawable/ic_undo"
|
||||
android:title="@string/action_undo"
|
||||
app:showAsAction="ifRoom" />
|
||||
<item
|
||||
android:id="@+id/action_redo"
|
||||
android:icon="@drawable/ic_redo"
|
||||
android:title="@string/action_redo"
|
||||
app:showAsAction="ifRoom" />
|
||||
<group android:id="@+id/editGroup">
|
||||
<item
|
||||
android:id="@+id/action_new"
|
||||
android:title="@string/action_new"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_load"
|
||||
android:title="@string/action_open"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:title="@string/action_save" />
|
||||
<item
|
||||
android:id="@+id/action_save_as"
|
||||
android:title="@string/action_save_as" />
|
||||
</group>
|
||||
<item
|
||||
android:id="@+id/action_lock_swipe"
|
||||
android:checkable="true"
|
||||
android:title="@string/action_lock_swipe"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
33
app/src/main/res/menu/menu_main.xml
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<group android:id="@+id/mainGroup">
|
||||
<item
|
||||
android:id="@+id/action_mainFragment_to_settingsContainerFragment"
|
||||
android:title="@string/action_settings"
|
||||
android:icon="@drawable/ic_settings_black_24dp"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_mainFragment_to_supportFragment"
|
||||
android:title="@string/support_title"
|
||||
android:icon="@drawable/ic_favorite_black_24dp"
|
||||
app:showAsAction="never" />
|
||||
</group>
|
||||
<group android:id="@+id/addtionalInfoGroup">
|
||||
<item
|
||||
android:id="@+id/action_mainFragment_to_helpFragment"
|
||||
android:title="@string/action_help"
|
||||
android:icon="@drawable/ic_help_black_24dp"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_mainFragment_to_librariesFragment"
|
||||
android:title="@string/action_libraries"
|
||||
android:icon="@drawable/ic_info_black_24dp"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_mainFragment_to_privacyFragment"
|
||||
android:title="@string/action_privacy"
|
||||
android:icon="@drawable/ic_eye_black_24dp"
|
||||
app:showAsAction="never" />
|
||||
</group>
|
||||
</menu>
|
|
@ -1,6 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
|
@ -1,6 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.4 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 6.8 KiB |