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
|
*.log
|
||||||
keystore.properties
|
keystore.properties
|
||||||
*.jks
|
*.jks
|
||||||
|
sentry.properties
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
image: openjdk:24-jdk
|
image: openjdk:8-jdk
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
ANDROID_COMPILE_SDK: "28"
|
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>
|
Simple Markdown is simply a Markdown editor :) I wrote it to offer up an open source alternative to
|
||||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" style="width: 24%" />
|
the other Markdown editors available on the Play Store. I also wanted to get some practice in
|
||||||
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" style="width: 24%" />
|
creating Android apps and have a little something to put into my portfolio.
|
||||||
<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 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>
|
* [x] Auto-save
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
* [x] Night mode
|
||||||
alt="Get it on F-Droid"
|
* [x] Save to cloud (Dropbox, Google Drive, OneDrive)
|
||||||
height="80">](https://f-droid.org/packages/com.wbrawner.simplemarkdown.free/)
|
* [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
|
## Building
|
||||||
|
|
||||||
Using Android Studio is the preferred way to build the project. To build from the command line, you can run
|
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
|
### Crashlytics
|
||||||
|
|
||||||
SimpleMarkdown makes use of Firebase Crashlytics for error reporting. You'll need to follow the
|
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.
|
[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
|
## License
|
||||||
|
|
||||||
```
|
```
|
||||||
Copyright 2017-2022 William Brawner
|
Copyright 2017-2019 William Brawner
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|
1
app/.gitignore
vendored
|
@ -1,4 +1,3 @@
|
||||||
/build
|
/build
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
|
||||||
/release
|
/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
|
# By default, the flags in this file are appended to flags specified
|
||||||
# in /home/billy/Android/Sdk/tools/proguard/proguard-android.txt
|
# in /home/billy/Android/Sdk/tools/proguard/proguard-android.txt
|
||||||
# You can edit the include path and order by changing the proguardFiles
|
# 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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
@ -27,15 +27,4 @@
|
||||||
### Crashlytics ###
|
### Crashlytics ###
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
-keep public class * extends java.lang.Exception
|
### End Crashlytics ###
|
||||||
### 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
|
|
|
@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MarkdownApplication"
|
android:name=".MarkdownApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.App.Starting"
|
android:theme="@style/AppTheme"
|
||||||
tools:ignore="AllowBackup"
|
tools:ignore="AllowBackup"
|
||||||
tools:targetApi="tiramisu">
|
tools:targetApi="n">
|
||||||
<activity android:name=".MainActivity"
|
<activity
|
||||||
android:exported="true"
|
android:name=".view.activity.SplashActivity"
|
||||||
|
android:theme="@style/AppTheme.Splash"
|
||||||
android:label="@string/app_name_short">
|
android:label="@string/app_name_short">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
@ -39,7 +42,7 @@
|
||||||
<data android:host="*" />
|
<data android:host="*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity android:name=".view.activity.MainActivity" />
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
@ -49,6 +52,13 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="io.sentry.dsn"
|
||||||
|
android:value="${sentryDsn}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="io.sentry.auto-init"
|
||||||
|
android:value="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -90,11 +90,11 @@ data 1|data 2|data 3
|
||||||
data 1|data 2|data 3
|
data 1|data 2|data 3
|
||||||
```
|
```
|
||||||
|
|
||||||
| Left Content | Center Content | Right Content |
|
Left Content|Center Content|Right Content
|
||||||
|:-------------|:--------------:|--------------:|
|
:--------|:--------:|--------:
|
||||||
| data 1 | data 2 | data 3 |
|
data 1|data 2|data 3
|
||||||
| data 1 | data 2 | data 3 |
|
data 1|data 2|data 3
|
||||||
| data 1 | data 2 | data 3 |
|
data 1|data 2|data 3
|
||||||
|
|
||||||
### Images
|
### 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 (\`\`\`):
|
Or by wrapping the code in three backticks (\`\`\`):
|
||||||
|
|
||||||
```javascript
|
```
|
||||||
function helloWorld() {
|
function helloWorld() {
|
||||||
console.log("Hello, world!")
|
console.log("Hello, world!")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
The internet access permission is requested primarily for retrieving images from the internet in
|
First and foremost, Simple Markdown DOES NOT collect any personally identifiable information. The
|
||||||
case you embed them in your markdown, but it also allows me to send automated error and crash
|
internet access permission is requested primarily for retrieving images from the internet in
|
||||||
reports to myself whenever the app runs into an issue. These error reports are opt-out, and are
|
case you embed them in your markdown, but it also allows me to send automated error and crash
|
||||||
powered by [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/), which is a
|
reports to myself whenever the app runs into an issue. These automated reports are powered by my own
|
||||||
free error reporting solution provided by Google. These error reports are used exclusively for
|
self-hosted version of [Sentry] (https://sentry.io/), which is a free and open source error
|
||||||
fixing problems that occur while you're using the app, along with some analytics info like how
|
reporting solution. These error reports are used exclusively for fixing problems that occur while
|
||||||
long you use the app for, how often, and which features of the app you use. This helps me to
|
you're using the app. For more information on the kinds of data that may be sent in these automated
|
||||||
determine how to spend my very limited time on building out new features. I'll have to defer to
|
error reports, please see the [relevant documentation](https://docs.sentry.io/platforms/android/#context)
|
||||||
[Google's Privacy Policy](https://policies.google.com/privacy) to explain how they handle the
|
on Sentry's website. If you would like to opt-out of these error reports, please visit the in-app
|
||||||
data. As for me, I don't knowingly or willingly sell or share your data.
|
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.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import com.wbrawner.simplemarkdown.core.ErrorReporterTree
|
import androidx.preference.PreferenceManager
|
||||||
import com.wbrawner.simplemarkdown.utility.AndroidFileHelper
|
import com.wbrawner.simplemarkdown.utility.ErrorHandler
|
||||||
import com.wbrawner.simplemarkdown.utility.AndroidPreferenceHelper
|
import com.wbrawner.simplemarkdown.utility.SentryErrorHandler
|
||||||
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
|
|
||||||
|
|
||||||
class MarkdownApplication : Application() {
|
class MarkdownApplication : Application() {
|
||||||
|
val errorHandler: ErrorHandler by lazy {
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
SentryErrorHandler()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
.getBoolean(getString(R.string.error_reports_enabled), true)
|
||||||
|
errorHandler.init(this, enableErrorReports)
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
||||||
.detectAll()
|
.detectAll()
|
||||||
|
@ -29,28 +24,7 @@ class MarkdownApplication : Application() {
|
||||||
.detectAll()
|
.detectAll()
|
||||||
.penaltyLog()
|
.penaltyLog()
|
||||||
.build())
|
.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()
|
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>()
|
val list = ArrayList<Sentence>()
|
||||||
var startOfSentance = 0
|
var startOfSentance = 0
|
||||||
var lineBuilder = StringBuilder()
|
var lineBuilder = StringBuilder()
|
||||||
for (i in content.indices) {
|
for (i in 0 until content.length) {
|
||||||
val c = content[i] + ""
|
val c = content[i] + ""
|
||||||
if (DELIMS.contains(c)) {
|
if (DELIMS.contains(c)) {
|
||||||
list.add(Sentence(content, startOfSentance, i))
|
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,32 +1,53 @@
|
||||||
package com.wbrawner.simplemarkdown.utility
|
package com.wbrawner.simplemarkdown.utility
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import com.commonsware.cwac.anddown.AndDown
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.Reader
|
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) {
|
return withContext(Dispatchers.IO) {
|
||||||
open(asset).reader().use(Reader::readText)
|
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 {
|
suspend fun Uri.getName(context: Context): String {
|
||||||
var fileName: String? = null
|
var fileName: String? = null
|
||||||
try {
|
try {
|
||||||
if ("content" == scheme) {
|
if ("content" == scheme) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
this@getName,
|
this@getName,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
)?.use {
|
)?.use {
|
||||||
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
it.moveToFirst()
|
it.moveToFirst()
|
||||||
|
@ -41,11 +62,3 @@ suspend fun Uri.getName(context: Context): String {
|
||||||
}
|
}
|
||||||
return fileName ?: "Untitled.md"
|
return fileName ?: "Untitled.md"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("RecursivePropertyAccessor")
|
|
||||||
val Context.activity: Activity?
|
|
||||||
get() = when (this) {
|
|
||||||
is Activity -> this
|
|
||||||
is ContextWrapper -> baseContext.activity
|
|
||||||
else -> null
|
|
||||||
}
|
|
|
@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -1,6 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</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 |