Compare commits
247 commits
Author | SHA1 | Date | |
---|---|---|---|
10c1f42ca3 | |||
6fe4d99dfd | |||
dd76557232 | |||
092fb5bf3a | |||
1ab9c3db55 | |||
49de5ab1f3 | |||
d8b5f84e78 | |||
8b87ca0dd7 | |||
90a2550b0d | |||
6248f464d5 | |||
3b7a6fd57c | |||
2f5ebb28f6 | |||
6569ac64b2 | |||
13962a11d7 | |||
380280686a | |||
53df7da0aa | |||
9e8c65396e | |||
730bf3f7d8 | |||
80b18b242e | |||
1a4e5b8e9e | |||
29469df9c6 | |||
0d30c2b40d | |||
055437c052 | |||
9bc75b475e | |||
1de349e161 | |||
20d688d110 | |||
554937ae03 | |||
8e6e305ecf | |||
c1ba9dcf91 | |||
9bc220d5bb | |||
bb158f0120 | |||
8809d295c4 | |||
7de4996816 | |||
a4ef52f2cc | |||
ce14205c4f | |||
41607fba52 | |||
ca83a92158 | |||
c59faea4d4 | |||
d028c8cb2f | |||
f2f92b2318 | |||
e94b5a67c1 | |||
e386fcd82f | |||
cbbef5cf6f | |||
e8eb71e18b | |||
5abfe147f3 | |||
24ed864645 | |||
4f36b2f54c | |||
b6ee357407 | |||
154ccad9f1 | |||
d9032fb686 | |||
9898e09ac6 | |||
4932ac15f8 | |||
8fd4c8f8fd | |||
79b0f9996f | |||
7024ffc6d6 | |||
2f30d2fd6f | |||
0ade9be784 | |||
bf01006004 | |||
e5e072e4fe | |||
6d34bb6f94 | |||
9d26752d2b | |||
3f5c6b7ebf | |||
79d609f138 | |||
4271ded6aa | |||
7f945ba5fe | |||
12d5fcb834 | |||
3ac064c02c | |||
b0105dc5a4 | |||
ee5db64532 | |||
9b9ab9b971 | |||
428b6ec76b | |||
caade0c0d1 | |||
362a54c653 | |||
d1a9b114b6 | |||
cb973ba0ec | |||
8cb0f7c94d | |||
5035c287d2 | |||
5c2807ff9a | |||
a1b3672472 | |||
3033c65a8f | |||
7055229805 | |||
0dbcdb51b9 | |||
39451e67c2 | |||
7ee7687117 | |||
ef0ca65ee6 | |||
756251a64f | |||
c7a888a413 | |||
97181f8190 | |||
0195e4ee58 | |||
8ac6eb24a0 | |||
a1a8bb794e | |||
caf485ec61 | |||
50f0c299f5 | |||
09d073c5f3 | |||
51e5652475 | |||
01978548c6 | |||
b42b949bdb | |||
040777b99a | |||
307e7642b9 | |||
617035c424 | |||
9ce5e129a6 | |||
b1e698c9c9 | |||
c53ac549dc | |||
b6203cd6d1 | |||
094ac1fccf | |||
90ab7f9397 | |||
dc97ea7b78 | |||
9b6a5ba2be | |||
2a9d96428a | |||
265d3bfcd8 | |||
a4bcba188a | |||
955d854420 | |||
9d4f48f8ae | |||
c3c43dc068 | |||
a89cd893db | |||
714808a0d4 | |||
82ebfab5c9 | |||
170618d961 | |||
c7b1d0ec90 | |||
5721b163af | |||
caec13a0e3 | |||
44106351bc | |||
5394f8f64a | |||
2cf2ffc883 | |||
79a5a3809a | |||
840ebc4fd1 | |||
b0e8ebbf71 | |||
262b63cfa0 | |||
c6e14d7d0b | |||
86cd33ff5f | |||
06cbc5ec31 | |||
b94ba8d4c2 | |||
880393104f | |||
110d5402cd | |||
ac3cd9d5c4 | |||
92123d2f24 | |||
2a0cc4d889 | |||
de9956cbf7 | |||
75e38a97dd | |||
c792bd106e | |||
b7c2e116cf | |||
7ed94aebf4 | |||
c7f44e2b81 | |||
c86d2c2f6a | |||
f4c7057daf | |||
208e0a1a6f | |||
37f0b8bae8 | |||
ccc4299d10 | |||
e0d43d5154 | |||
a6616550dd | |||
a48f243e48 | |||
6acacf5edb | |||
a35aa62399 | |||
f891d3635d | |||
b706f5b5eb | |||
32b4518bf7 | |||
9f7142b2ab | |||
1bf01c43dd | |||
c2cc9fbd1c | |||
ae5b13dfd0 | |||
493444aaab | |||
c7e54f21d9 | |||
643345493e | |||
e352add928 | |||
ea2e949893 | |||
35d3fee732 | |||
0d9fe441e8 | |||
9f6444524f | |||
8b7d83c6ae | |||
4133bb1d72 | |||
51025d8181 | |||
f542e10264 | |||
80e323b326 | |||
c04e2ded8e | |||
535bfab8db | |||
f69bf81630 | |||
|
f51d669da3 | ||
|
729758b7bc | ||
fe2f36f06a | |||
c5355d1565 | |||
13bfe236a3 | |||
b9bc147bde | |||
c54f6c00ac | |||
5fe4942d84 | |||
b8bbd267c7 | |||
e2aca68ed5 | |||
a296e98cde | |||
c701e4db2c | |||
2e4787fd93 | |||
dce178580a | |||
8a3a6a30ba | |||
40e8326d14 | |||
b231a56c9a | |||
47dd47aa7e | |||
5af4d2028f | |||
33574ce5f5 | |||
6ac4c94937 | |||
d0c61bea52 | |||
3108114b60 | |||
c315439763 | |||
c090076901 | |||
17d1bd28c5 | |||
|
c9e40d19cb | ||
fffafaa33b | |||
89d957c2d0 | |||
94cdb70da7 | |||
62720ddd05 | |||
50d7622172 | |||
1522806e62 | |||
c2be2274e8 | |||
260c49d8d5 | |||
31e81f31b4 | |||
14dc55433a | |||
96e7b7c6b3 | |||
eb756e8525 | |||
7bf4bef8a2 | |||
3bf5862a13 | |||
b056e779e0 | |||
67bf1626a0 | |||
2b6d3ce2bc | |||
a5e002f61c | |||
29f26265f8 | |||
027ae86953 | |||
0028261847 | |||
e75e0a07d1 | |||
ed57785d0a | |||
f2ed687b02 | |||
6937cc7406 | |||
72c2fb5719 | |||
84db6b3d80 | |||
2059d54b3b | |||
0e7cdfa2b5 | |||
1affb7069a | |||
0ff5ccdbd6 | |||
f830657668 | |||
ae673fc992 | |||
d854022691 | |||
c6728f1afa | |||
76c45bb914 | |||
c16ed3cbbe | |||
a1e114b162 | |||
fa11c7d070 | |||
56f2efbb18 | |||
894fea4193 | |||
baacef8f65 | |||
8cf07491cf | |||
3b984e4b73 |
70
.forgejo/workflows/pull_request.yml
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
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
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
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,4 +12,3 @@
|
||||||
*.log
|
*.log
|
||||||
keystore.properties
|
keystore.properties
|
||||||
*.jks
|
*.jks
|
||||||
sentry.properties
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
image: openjdk:8-jdk
|
image: openjdk:24-jdk
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
ANDROID_COMPILE_SDK: "28"
|
ANDROID_COMPILE_SDK: "28"
|
||||||
|
|
38
README.md
|
@ -1,44 +1,36 @@
|
||||||
# [Simple Markdown](https://wbrawner.com/portfolio/simple-markdown/)
|
# Simple Markdown
|
||||||
|
|
||||||
[![pipeline status](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/pipeline.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
|
[![pipeline status](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml/badge.svg)](https://github.com/wbrawner/SimpleMarkdown/actions/workflows/android.yml)
|
||||||
[![coverage report](https://gitlab.com/billybrawner/SimpleMarkdown/badges/master/coverage.svg)](https://gitlab.com/billybrawner/SimpleMarkdown/commits/master)
|
|
||||||
|
|
||||||
Simple Markdown is simply a Markdown editor :) I wrote it to offer up an open source alternative to
|
<p>
|
||||||
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/1.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/2.png" style="width: 24%" />
|
||||||
|
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" style="width: 24%" />
|
||||||
|
<img alt="" src="./fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" style="width: 24%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Roadmap
|
Simple Markdown is an open source Markdown editor.
|
||||||
|
|
||||||
* [x] Auto-save
|
<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] Night mode
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||||
* [x] Save to cloud (Dropbox, Google Drive, OneDrive)
|
alt="Get it on F-Droid"
|
||||||
* [x] Custom CSS for Markdown preview
|
height="80">](https://f-droid.org/packages/com.wbrawner.simplemarkdown.free/)
|
||||||
* [ ] 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 assembleDebug
|
./gradlew assembleFreeDebug
|
||||||
|
|
||||||
### 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-2019 William Brawner
|
Copyright 2017-2022 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,3 +1,4 @@
|
||||||
/build
|
/build
|
||||||
*.apk
|
*.apk
|
||||||
|
*.aab
|
||||||
/release
|
/release
|
||||||
|
|
166
app/build.gradle
|
@ -1,166 +0,0 @@
|
||||||
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'
|
|
||||||
]))
|
|
||||||
}
|
|
201
app/build.gradle.kts
Normal file
|
@ -0,0 +1,201 @@
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,78 +0,0 @@
|
||||||
{
|
|
||||||
"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.
|
# directive in build.gradle.kts.
|
||||||
#
|
#
|
||||||
# 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,4 +27,15 @@
|
||||||
### Crashlytics ###
|
### Crashlytics ###
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
### End Crashlytics ###
|
-keep public class * extends java.lang.Exception
|
||||||
|
### Crashlytics ###
|
||||||
|
|
||||||
|
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||||
|
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||||
|
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||||
|
-dontwarn org.conscrypt.Conscrypt$Version
|
||||||
|
-dontwarn org.conscrypt.Conscrypt
|
||||||
|
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||||
|
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||||
|
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||||
|
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
|
@ -1,182 +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.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))))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,254 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
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()
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
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,26 +1,23 @@
|
||||||
<?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/AppTheme"
|
android:theme="@style/Theme.App.Starting"
|
||||||
tools:ignore="AllowBackup"
|
tools:ignore="AllowBackup"
|
||||||
tools:targetApi="n">
|
tools:targetApi="tiramisu">
|
||||||
<activity
|
<activity android:name=".MainActivity"
|
||||||
android:name=".view.activity.SplashActivity"
|
android:exported="true"
|
||||||
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" />
|
||||||
|
@ -42,7 +39,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"
|
||||||
|
@ -52,13 +49,6 @@
|
||||||
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 @@
|
||||||
First and foremost, Simple Markdown DOES NOT collect any personally identifiable information. The
|
The internet access permission is requested primarily for retrieving images from the internet in
|
||||||
internet access permission is requested primarily for retrieving images from the internet in
|
case you embed them in your markdown, but it also allows me to send automated error and crash
|
||||||
case you embed them in your markdown, but it also allows me to send automated error and crash
|
reports to myself whenever the app runs into an issue. These error reports are opt-out, and are
|
||||||
reports to myself whenever the app runs into an issue. These automated reports are powered by my own
|
powered by [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/), which is a
|
||||||
self-hosted version of [Sentry] (https://sentry.io/), which is a free and open source error
|
free error reporting solution provided by Google. These error reports are used exclusively for
|
||||||
reporting solution. These error reports are used exclusively for fixing problems that occur while
|
fixing problems that occur while you're using the app, along with some analytics info like how
|
||||||
you're using the app. For more information on the kinds of data that may be sent in these automated
|
long you use the app for, how often, and which features of the app you use. This helps me to
|
||||||
error reports, please see the [relevant documentation](https://docs.sentry.io/platforms/android/#context)
|
determine how to spend my very limited time on building out new features. I'll have to defer to
|
||||||
on Sentry's website. If you would like to opt-out of these error reports, please visit the in-app
|
[Google's Privacy Policy](https://policies.google.com/privacy) to explain how they handle the
|
||||||
settings page to disable the toggle for error reports.
|
data. As for me, I don't knowingly or willingly sell or share your data.
|
||||||
|
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
After Width: | Height: | Size: 14 KiB |
187
app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
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,19 +2,24 @@ package com.wbrawner.simplemarkdown
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.preference.PreferenceManager
|
import com.wbrawner.simplemarkdown.core.ErrorReporterTree
|
||||||
import com.wbrawner.simplemarkdown.utility.ErrorHandler
|
import com.wbrawner.simplemarkdown.utility.AndroidFileHelper
|
||||||
import com.wbrawner.simplemarkdown.utility.SentryErrorHandler
|
import com.wbrawner.simplemarkdown.utility.AndroidPreferenceHelper
|
||||||
|
import com.wbrawner.simplemarkdown.utility.FileHelper
|
||||||
|
import com.wbrawner.simplemarkdown.utility.PersistentTree
|
||||||
|
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||||
|
import com.wbrawner.simplemarkdown.utility.ReviewHelper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MarkdownApplication : Application() {
|
class MarkdownApplication : Application() {
|
||||||
val errorHandler: ErrorHandler by lazy {
|
|
||||||
SentryErrorHandler()
|
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
|
@ -24,7 +29,28 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,321 @@
|
||||||
|
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 0 until content.length) {
|
for (i in content.indices) {
|
||||||
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))
|
||||||
|
|
369
app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt
Normal file
|
@ -0,0 +1,369 @@
|
||||||
|
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)
|
|
@ -0,0 +1,43 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
125
app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownText.kt
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
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>"
|
|
@ -0,0 +1,82 @@
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,258 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,107 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
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)
|
|
@ -0,0 +1,90 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,43 +0,0 @@
|
||||||
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,53 +1,32 @@
|
||||||
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
|
||||||
|
|
||||||
fun View.showKeyboard() {
|
suspend fun AssetManager.readAssetToString(asset: String): String {
|
||||||
(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()
|
||||||
|
@ -62,3 +41,11 @@ 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
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
package com.wbrawner.simplemarkdown.view
|
|
||||||
|
|
||||||
interface ViewPagerPage {
|
|
||||||
fun onSelected()
|
|
||||||
fun onDeselected()
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,70 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,177 +0,0 @@
|
||||||
package com.wbrawner.simplemarkdown.view.fragment
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.SpannableString
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.text.style.BackgroundColorSpan
|
|
||||||
import android.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.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.markdownUpdates.value)
|
|
||||||
viewModel.originalMarkdown.observe(viewLifecycleOwner, Observer {
|
|
||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,301 +0,0 @@
|
||||||
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.markdownUpdates.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.markdownUpdates.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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
package com.wbrawner.simplemarkdown.view.fragment
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
|
|
||||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.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.markdownUpdates.value ?: "")
|
|
||||||
viewModel.markdownUpdates.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>"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
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)
|
|
||||||
// }
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
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 markdownUpdates = MutableLiveData<String>()
|
|
||||||
val originalMarkdown = MutableLiveData<String>()
|
|
||||||
val uri = MutableLiveData<Uri>()
|
|
||||||
|
|
||||||
fun updateMarkdown(markdown: String?) {
|
|
||||||
this.markdownUpdates.postValue(markdown ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun load(context: Context, uri: Uri?): Boolean {
|
|
||||||
if (uri == null) return false
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.openFileDescriptor(uri, "r")?.use {
|
|
||||||
val fileInput = FileInputStream(it.fileDescriptor)
|
|
||||||
val fileName = uri.getName(context)
|
|
||||||
val content = fileInput.reader().use(Reader::readText)
|
|
||||||
originalMarkdown.postValue(content)
|
|
||||||
markdownUpdates.postValue(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(markdownUpdates.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)
|
|
||||||
originalMarkdown.postValue("")
|
|
||||||
markdownUpdates.postValue("")
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 27 KiB |
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportHeight="24.0"
|
|
||||||
android:viewportWidth="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
|
|
||||||
</vector>
|
|
24
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<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>
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/colorOnBackground"
|
|
||||||
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z" />
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/colorOnBackground"
|
|
||||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
|
|
||||||
</vector>
|
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item
|
|
||||||
android:top="0dp"
|
|
||||||
android:bottom="0dp"
|
|
||||||
android:left="0dp"
|
|
||||||
android:right="0dp"
|
|
||||||
android:drawable="@color/colorPrimary"/>
|
|
||||||
<item
|
|
||||||
android:drawable="@drawable/splash_fg"
|
|
||||||
android:gravity="center"/>
|
|
||||||
</layer-list>
|
|
|
@ -1,61 +0,0 @@
|
||||||
<?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>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<?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" />
|
|
|
@ -1,23 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,63 +0,0 @@
|
||||||
<?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>
|
|
|
@ -1,43 +0,0 @@
|
||||||
<?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>
|
|
|
@ -1,13 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,26 +0,0 @@
|
||||||
<?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>
|
|
|
@ -1,78 +0,0 @@
|
||||||
<?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>
|
|
|
@ -1,28 +0,0 @@
|
||||||
<?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_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" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/action_lock_swipe"
|
|
||||||
android:checkable="true"
|
|
||||||
android:title="@string/action_lock_swipe"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
</menu>
|
|
|
@ -1,33 +0,0 @@
|
||||||
<?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,5 +1,6 @@
|
||||||
<?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="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -1,5 +1,6 @@
|
||||||
<?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="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 894 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
@ -1,87 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<navigation 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:id="@+id/nav_graph"
|
|
||||||
app:startDestination="@+id/mainFragment">
|
|
||||||
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/settingsContainerFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.SettingsContainerFragment"
|
|
||||||
android:label="@string/title_activity_settings" />
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/mainFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.MainFragment"
|
|
||||||
android:label="">
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mainFragment_to_helpFragment"
|
|
||||||
app:destination="@id/helpFragment"
|
|
||||||
app:enterAnim="@android:anim/slide_in_left"
|
|
||||||
app:exitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popExitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popUpTo="@id/mainFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mainFragment_to_privacyFragment"
|
|
||||||
app:destination="@id/privacyFragment"
|
|
||||||
app:enterAnim="@android:anim/slide_in_left"
|
|
||||||
app:exitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popExitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popUpTo="@id/mainFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mainFragment_to_librariesFragment"
|
|
||||||
app:destination="@id/librariesFragment"
|
|
||||||
app:enterAnim="@android:anim/slide_in_left"
|
|
||||||
app:exitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popExitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popUpTo="@id/mainFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mainFragment_to_settingsContainerFragment"
|
|
||||||
app:destination="@id/settingsContainerFragment"
|
|
||||||
app:enterAnim="@android:anim/slide_in_left"
|
|
||||||
app:exitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popExitAnim="@android:anim/slide_out_right"
|
|
||||||
app:popUpTo="@id/mainFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mainFragment_to_supportFragment"
|
|
||||||
app:destination="@id/supportFragment"
|
|
||||||
app:enterAnim="@anim/nav_default_enter_anim"
|
|
||||||
app:exitAnim="@anim/nav_default_exit_anim"
|
|
||||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
|
||||||
app:popExitAnim="@anim/nav_default_pop_exit_anim"
|
|
||||||
app:popUpTo="@id/mainFragment" />
|
|
||||||
</fragment>
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/supportFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.SupportFragment"
|
|
||||||
android:label="@string/support_title" />
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/helpFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.MarkdownInfoFragment"
|
|
||||||
android:label="@string/action_help"
|
|
||||||
tools:layout="@layout/fragment_markdown_info">
|
|
||||||
<argument
|
|
||||||
android:name="file"
|
|
||||||
app:argType="string"
|
|
||||||
android:defaultValue="Cheatsheet.md" />
|
|
||||||
</fragment>
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/privacyFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.MarkdownInfoFragment"
|
|
||||||
android:label="@string/action_privacy"
|
|
||||||
tools:layout="@layout/fragment_markdown_info">
|
|
||||||
<argument
|
|
||||||
android:name="file"
|
|
||||||
android:defaultValue="Privacy Policy.md"
|
|
||||||
app:argType="string" />
|
|
||||||
</fragment>
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/librariesFragment"
|
|
||||||
android:name="com.wbrawner.simplemarkdown.view.fragment.MarkdownInfoFragment"
|
|
||||||
android:label="@string/action_libraries"
|
|
||||||
tools:layout="@layout/fragment_markdown_info">
|
|
||||||
<argument
|
|
||||||
android:name="file"
|
|
||||||
android:defaultValue="Libraries.md"
|
|
||||||
app:argType="string" />
|
|
||||||
</fragment>
|
|
||||||
</navigation>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="colorBackground">#000000</color>
|
|
||||||
<color name="colorOnBackground">#FFFFFF</color>
|
|
||||||
</resources>
|
|