mirror of
https://github.com/PhilKes/NotallyX.git
synced 2025-06-30 13:19:54 +00:00
Compare commits
569 commits
Author | SHA1 | Date | |
---|---|---|---|
|
d00300fa0e | ||
|
f13e8227ca | ||
|
11e472cd30 | ||
|
4cc957ccd4 | ||
|
86b74762c5 | ||
|
118285545a | ||
|
402baf8056 | ||
|
de27d40880 | ||
|
66ce623e85 | ||
|
3c2400c7e6 | ||
|
c64a7b2ed7 | ||
|
fb687856f1 | ||
|
23d678c8a3 | ||
|
ade08b52ed | ||
|
62a35132e0 | ||
|
9fbe5a6b94 | ||
|
cf7f6f9dda | ||
|
01ac48f930 | ||
|
015f43e94b | ||
|
830fb6a75c | ||
|
c34ee3633e | ||
|
628bd9d564 | ||
|
3e889879fb | ||
|
b191618a46 | ||
|
5cbc62bdf7 | ||
|
d1e5770180 | ||
|
29cee8faf4 | ||
|
4f993af93f | ||
|
0f0eb80e9b | ||
|
1314ab4437 | ||
|
39022edfab | ||
|
157ecb1b13 | ||
|
fb35ffdac4 | ||
|
0fee25f022 | ||
|
2341c30586 | ||
|
e553e78efb | ||
|
724d08507a | ||
|
771546a0cb | ||
|
1a6d4083e4 | ||
|
3ac63349d8 | ||
|
06c48ab8d9 | ||
|
8d20f26eae | ||
|
66f0d33cd4 | ||
|
6d81b6f7c0 | ||
|
2c1f5d5338 | ||
|
adb981d76c | ||
|
73a0345fe4 | ||
|
0407f2bdc4 | ||
|
212c354072 | ||
|
07ff5691e2 | ||
|
20cf84ab69 | ||
|
6bfa013a6c | ||
|
66a7b02c69 | ||
|
62035091f5 | ||
|
209e19d690 | ||
|
899f2c0f9a | ||
|
3a0b699c82 | ||
|
18947835f1 | ||
|
5c0ea100ee | ||
|
392a060329 | ||
|
1aa5e2c9e7 | ||
|
0d86d7aad8 | ||
|
772a43de31 | ||
|
ad9b410d45 | ||
|
6cf2a8ce1f | ||
|
c33b639a07 | ||
|
61112a18d2 | ||
|
30f889f3db | ||
|
b95782e53a | ||
|
19df4b817f | ||
|
333b57c29d | ||
|
87124c32f4 | ||
|
f4a7074811 | ||
|
1f6afb03d4 | ||
|
56683d5255 | ||
|
e24f630acf | ||
|
6b3fec40eb | ||
|
205116ac60 | ||
|
ffeecdf1ca | ||
|
8d6b318e3b | ||
|
8d477e4366 | ||
|
79eacb079d | ||
|
c122c2cc48 | ||
|
f97f99ded0 | ||
|
506bc5c362 | ||
|
8784145b83 | ||
|
7b1aa83fca | ||
|
f9ea26f1fa | ||
|
54d835e40b | ||
|
9fd735ba95 | ||
|
b5e13ce73a | ||
|
c586eab072 | ||
|
860db3e6bb | ||
|
883998e27f | ||
|
4924ee46ec | ||
|
ad5ad25e11 | ||
|
93119098bc | ||
|
764c562859 | ||
|
b1bf6bc12c | ||
|
44d19341b1 | ||
|
be734e080c | ||
|
8a4e2f9a92 | ||
|
48c07dfe0f | ||
|
e09b0f44b7 | ||
|
155d4c1cd9 | ||
|
8e3110f077 | ||
|
17a3eda124 | ||
|
7edbcccbe4 | ||
|
f4bfa7ccb8 | ||
|
ae8bc8e859 | ||
|
8af22e1e88 | ||
|
a57628dc7a | ||
|
d239f20e6f | ||
|
2ddfd5adb9 | ||
|
b7b0b48c62 | ||
|
cc64bad689 | ||
|
f4de4133ed | ||
|
21707dfe08 | ||
|
d2ba38a20e | ||
|
fe6d6eca8b | ||
|
c5cfa7a6c9 | ||
|
5711830b0f | ||
|
dae19f07e0 | ||
|
36f3cc284f | ||
|
f38f813af6 | ||
|
6b80b37714 | ||
|
6c78204111 | ||
|
900defa670 | ||
|
a925712e1f | ||
|
11d1d1fcc5 | ||
|
67e2a35c8f | ||
|
11eeb0d5bc | ||
|
259e223637 | ||
|
d7ad549878 | ||
|
86998a84a0 | ||
|
ac2b87bba1 | ||
|
13e7b5ac1e | ||
|
5ac794885f | ||
|
320c9048a5 | ||
|
0cb3fa92df | ||
|
2bd5c575fe | ||
|
dbe1b0726b | ||
|
dad74ace96 | ||
|
467f877dcc | ||
|
3e4acd9355 | ||
|
715d1aba1d | ||
|
000bfe7466 | ||
|
b0d3cde257 | ||
|
d330f93f00 | ||
|
f64267c226 | ||
|
be16ef27a9 | ||
|
2f128f8de1 | ||
|
7bb2ee53e4 | ||
|
9fdfb61311 | ||
|
628b9a4835 | ||
|
95a3b9c048 | ||
|
1ea19694fd | ||
|
a3b7cdb984 | ||
|
ab63bfdb9d | ||
|
5a29ce7b12 | ||
|
086ca01c74 | ||
|
b15b8efb1f | ||
|
1dbca6457b | ||
|
684388634b | ||
|
d42d9ebd69 | ||
|
e48c7b5dec | ||
|
a02e45ad98 | ||
|
0215bcd676 | ||
|
7c25838d97 | ||
|
aae366eaff | ||
|
e153cafe08 | ||
|
4c0ca095d4 | ||
|
86e68b7936 | ||
|
10fe736e46 | ||
|
45e8c28808 | ||
|
b78c5c2259 | ||
|
bed5b08236 | ||
|
a00bc28d8e | ||
|
a3281d8195 | ||
|
94d470956a | ||
|
935433bef5 | ||
|
94e4b5cb7f | ||
|
6698ddfa52 | ||
|
6d2f2c00d0 | ||
|
14239cc0a6 | ||
|
0d2b5116a5 | ||
|
bf7002b0c0 | ||
|
d11b461708 | ||
|
b207b8df77 | ||
|
bd24776421 | ||
|
02930a25b8 | ||
|
437942536d | ||
|
d02171eec4 | ||
|
e57cec7eef | ||
|
ac8ec46361 | ||
|
ae70770c27 | ||
|
58e9d5a439 | ||
|
2b2f7a696b | ||
|
3b550162ff | ||
|
ba9db21ee9 | ||
|
6f2aee42fd | ||
|
b3519b6dc3 | ||
|
5dc578eb44 | ||
|
650243edc9 | ||
|
503c719a2e | ||
|
a4c7822f5c | ||
|
8ee9d99213 | ||
|
3c15cd771f | ||
|
9a1d2c4df0 | ||
|
e8ff4d7e44 | ||
|
dcd2aea1c9 | ||
|
80ac25debd | ||
|
8b9c4dec13 | ||
|
2863bb8476 | ||
|
43158e67e5 | ||
|
80da91e00b | ||
|
b88f80fe75 | ||
|
1c7ee05c69 | ||
|
34c18f8842 | ||
|
41cfb1bb2b | ||
|
0df152491f | ||
|
b2bd349aae | ||
|
55df9cff48 | ||
|
c4861a8510 | ||
|
cb5a220ec1 | ||
|
b1205b5409 | ||
|
f24c791fc3 | ||
|
157967fa82 | ||
|
74864fc134 | ||
|
08a2721ca9 | ||
|
bc6631adbb | ||
|
09864e3671 | ||
|
47f3f369c8 | ||
|
a1e863203d | ||
|
c8387e1f99 | ||
|
642eea49ae | ||
|
1d6c310e8a | ||
|
2991d441c4 | ||
|
d9568af924 | ||
|
7de8559815 | ||
|
bde9cde875 | ||
|
6e0c453d79 | ||
|
392ac47b43 | ||
|
7eb84dfa3f | ||
|
5ce329133a | ||
|
aa98179acf | ||
|
28b20ce504 | ||
|
a3bc7c9797 | ||
|
2e46527372 | ||
|
17fe0038b6 | ||
|
c89ec57534 | ||
|
55c091d28c | ||
|
93ce1c32d8 | ||
|
acdfa7003a | ||
|
dfafd22775 | ||
|
f00ce70cea | ||
|
db6afd01b6 | ||
|
45eca07d74 | ||
|
111aabd249 | ||
|
feb0764303 | ||
|
a2f14dfae7 | ||
|
9c7588d19c | ||
|
1627226765 | ||
|
ec25080056 | ||
|
4125572a42 | ||
|
f819e4a0a0 | ||
|
5738330726 | ||
|
dd90e2b9d9 | ||
|
419d2acaa7 | ||
|
b5248f8f5a | ||
|
914bf07174 | ||
|
465f7cf69b | ||
|
c1e926c402 | ||
|
87cacf4ce8 | ||
|
9e0a3f948c | ||
|
6c88f0c281 | ||
|
369c8fa393 | ||
|
2c86fbcd8a | ||
|
a86932a463 | ||
|
d7c67d9875 | ||
|
157d6a77f7 | ||
|
9c5731a006 | ||
|
9fd6a4c745 | ||
|
eaef290780 | ||
|
694c0f77a9 | ||
|
d47bd2a676 | ||
|
603fb40961 | ||
|
97aea76b50 | ||
|
1b56b9e9cc | ||
|
e49686d2da | ||
|
3f3e16a66b | ||
|
71ab55abe5 | ||
|
ec9b9984a3 | ||
|
e68bfc33c7 | ||
|
c749ff1018 | ||
|
ceaf4cc3a3 | ||
|
26b46c8fb3 | ||
|
12131fafa2 | ||
|
22ecb5dcf2 | ||
|
0f549f88e5 | ||
|
724fe1b241 | ||
|
f9f7aee5da | ||
|
0c1d9e1181 | ||
|
24b54b066b | ||
|
5508d96917 | ||
|
1a4c28818c | ||
|
c75a33dd1a | ||
|
62104e0878 | ||
|
bb9886e7e8 | ||
|
33f60ac7bc | ||
|
176f1d54e3 | ||
|
b9ad92b33a | ||
|
2438b42115 | ||
|
4c90096dd2 | ||
|
165799d12f | ||
|
7fd074bff1 | ||
|
d882187227 | ||
|
1ef391f7e8 | ||
|
9fc29d5d3b | ||
|
c4d6cb0e63 | ||
|
7e13bb5935 | ||
|
12b995f8fa | ||
|
7442aec2fa | ||
|
96141b4c8a | ||
|
b4bb45deb8 | ||
|
822762a336 | ||
|
667d8282f7 | ||
|
19f22d0e91 | ||
|
9576051fe1 | ||
|
39a9c15626 | ||
|
06a81256a1 | ||
|
c2642103fa | ||
|
960de6a849 | ||
|
5d49e52db7 | ||
|
917d2a9b06 | ||
|
22c0e7dd97 | ||
|
50c74f8a1e | ||
|
24279e6ba0 | ||
|
0db04c9fed | ||
|
ef1de6261a | ||
|
403b2e712a | ||
|
2b4a51cd0a | ||
|
eb416a81f5 | ||
|
66197fe3eb | ||
|
846937dc24 | ||
|
b11b07f45b | ||
|
fbd2e9d327 | ||
|
924d984dc8 | ||
|
fa3a335ea7 | ||
|
eb9c598e4c | ||
|
81483f5f14 | ||
|
54a68a76e0 | ||
|
d9b486d4ce | ||
|
2d9bc8d0d2 | ||
|
0dcfc2bcff | ||
|
e98645b736 | ||
|
8f3de20da0 | ||
|
3ae2dd2b0f | ||
|
f05d45c019 | ||
|
a5a0a26d63 | ||
|
94e7075f1d | ||
|
1983262262 | ||
|
41ba1e7c9b | ||
|
6bfde1bf32 | ||
|
4a494a8ca2 | ||
|
fe69699b43 | ||
|
9ad85ed459 | ||
|
0b02391209 | ||
|
199f2d5bed | ||
|
d5f3846946 | ||
|
8e4f3fe58e | ||
|
b70f7b173a | ||
|
e75c8b3ada | ||
|
61c851fad0 | ||
|
da000ea914 | ||
|
8fc493385a | ||
|
abc82ec0da | ||
|
434ee5f3ca | ||
|
7234c1711b | ||
|
d0b05872bc | ||
|
de3accd066 | ||
|
089403acb0 | ||
|
f076888643 | ||
|
b62905d0e0 | ||
|
41078d8bcc | ||
|
a6ba47e435 | ||
|
b056eb862a | ||
|
65795eaf07 | ||
|
ff4ca9e733 | ||
|
55a00e1185 | ||
|
fe66db4f56 | ||
|
a70bc19807 | ||
|
05c4607b48 | ||
|
1aea96a8a8 | ||
|
0809a3cd0a | ||
|
6b6354f4ea | ||
|
e1f12dfa47 | ||
|
bc3c1f7373 | ||
|
ee301bd60f | ||
|
49c85868dd | ||
|
5f9583868a | ||
|
b6e9ad7db3 | ||
|
1ee00e685c | ||
|
b64566af2b | ||
|
5d98773373 | ||
|
02e97768ce | ||
|
b55c01c94a | ||
|
3d4e5ff672 | ||
|
eb602cc482 | ||
|
1a80a7bcfc | ||
|
864ab3a176 | ||
|
c19cb25aad | ||
|
686125298c | ||
|
8116b69631 | ||
|
531755c542 | ||
|
1fc8b4e40d | ||
|
30575e371a | ||
|
6e0375c9f4 | ||
|
6238e3d0c1 | ||
|
85ed0d57ab | ||
|
dcf25f5cdd | ||
|
716538308b | ||
|
5875d03a0a | ||
|
9000fdbd36 | ||
|
3e37d5751f | ||
|
54df38fa9d | ||
|
991f2e5f75 | ||
|
0a478fe7c7 | ||
|
f0e1f22b3a | ||
|
28e58bae77 | ||
|
6820d8c7ff | ||
|
8356b9af7e | ||
|
f4b6b948b8 | ||
|
1db35539fc | ||
|
38218476e0 | ||
|
1b3e5b0ebc | ||
|
6a57d32d1f | ||
|
4e267e0721 | ||
|
7f546b8662 | ||
|
c0caf7fde2 | ||
|
3f3f2cb521 | ||
|
76fbf98437 | ||
|
3c3d5b51a6 | ||
|
03b3fd44c4 | ||
|
cae0307d9b | ||
|
204fe89b55 | ||
|
ec0880463b | ||
|
45fcbd4c41 | ||
|
b0a7d9927f | ||
|
8a175bb3c1 | ||
|
69fa420da4 | ||
|
6863608412 | ||
|
baebc3bfe6 | ||
|
575db7ec53 | ||
|
6e4dc48213 | ||
|
f44a2a904b | ||
|
7480e47977 | ||
|
f5b28efe76 | ||
|
d88dee636d | ||
|
058bb1743d | ||
|
4778d301c4 | ||
|
c9382c2733 | ||
|
30d5ee0a9e | ||
|
4529e3ffa4 | ||
|
a8d79e15d8 | ||
|
a10fb399cb | ||
|
9a936774dc | ||
|
f73acbca3a | ||
|
4b08daa976 | ||
|
1efc195417 | ||
|
37254a4948 | ||
|
8c45728fb3 | ||
|
b353bc07c7 | ||
|
23574e52f1 | ||
|
ca03ece684 | ||
|
22adfb8061 | ||
|
cc24b9befa | ||
|
5702ffd103 | ||
|
8c31acaca6 | ||
|
b03bba608b | ||
|
766aedb923 | ||
|
88274485e4 | ||
|
d453e48473 | ||
|
16201e6a37 | ||
|
e968d192bc | ||
|
adfa094444 | ||
|
583b8061c4 | ||
|
6125d41e05 | ||
|
df2cd719f2 | ||
|
c228eb341d | ||
|
e47f928f43 | ||
|
d31404220d | ||
|
f6824c94af | ||
|
5c97517692 | ||
|
f20312a4b6 | ||
|
f558392c6d | ||
|
5ba26e824a | ||
|
aa5a14d962 | ||
|
8ba6adcac5 | ||
|
a185443cab | ||
|
3c2f3d9cfb | ||
|
eac4545b3d | ||
|
94f47f4395 | ||
|
a0572b2d73 | ||
|
21a75ff2f0 | ||
|
750d2c53c9 | ||
|
9cf0756417 | ||
|
b8a6cbf3bf | ||
|
69bd1e8eda | ||
|
b9235d8486 | ||
|
eb176fa1cb | ||
|
c7c0c44bb4 | ||
|
eae6f816d5 | ||
|
c60221a609 | ||
|
8c32180330 | ||
|
702ddf548e | ||
|
ab9f2496e2 | ||
|
5f84c6449b | ||
|
eb2168b38a | ||
|
4e3f69220b | ||
|
d9ecf4f8ea | ||
|
e877ffb78d | ||
|
972277ecac | ||
|
3dff81c8ee | ||
|
b1e7158f06 | ||
|
aa069cef9d | ||
|
2357e6dca7 | ||
|
b9dbdd31f8 | ||
|
051bb4baec | ||
|
3406991f3a | ||
|
92220e9367 | ||
|
014ce555b9 | ||
|
e6de38a9b4 | ||
|
5c980f94cd | ||
|
6a01e69d1e | ||
|
b5d92d05a3 | ||
|
f7bfa58300 | ||
|
f42ce85938 | ||
|
58b625002d | ||
|
e7415653e6 | ||
|
1ae2a41b40 | ||
|
f6e623341c | ||
|
4e7fb47c5f | ||
|
921abb166c | ||
|
8a9c6a2483 | ||
|
bd042d53f4 | ||
|
bbcd2cd562 | ||
|
426293177b | ||
|
9fb1c73144 | ||
|
c22337a7a2 | ||
|
533d620624 | ||
|
bc870955bb | ||
|
fb35b4bdd3 | ||
|
c21c847a83 | ||
|
193022078e | ||
|
6ef6daa8c1 | ||
|
037418237b | ||
|
4dc1fbde6b | ||
|
418e226e02 | ||
|
231089a7e5 | ||
|
e38e14bdd4 | ||
|
87279902ca | ||
|
82215860bf | ||
|
54d971334e | ||
|
a61f27575d | ||
|
e84a5409e4 | ||
|
3a16d3bb6a | ||
|
e9e2221510 | ||
|
611b571f89 |
446 changed files with 323215 additions and 12450 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
ko_fi: philkes
|
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -22,7 +22,7 @@ body:
|
|||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android Version
|
||||
label: Android Version (API Level)
|
||||
description: What Android version are you using?
|
||||
- type: textarea
|
||||
id: logs
|
||||
|
|
10
.github/ISSUE_TEMPLATE/translation.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/translation.md
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: Translation Update
|
||||
about: Update translations by uploading updated translations.xlsx
|
||||
title: '<INSERT LANGUAGE HERE> translations update'
|
||||
labels: translations
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Drag'n'drop your updated translations.xlsx file here 🙂
|
56
.github/workflows/deploy.yaml
vendored
Normal file
56
.github/workflows/deploy.yaml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- documentation/**
|
||||
# Review gh actions docs if you want to further define triggers, paths, etc
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache-dependency-path: documentation/yarn.lock
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: documentation
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build website
|
||||
working-directory: documentation
|
||||
run: yarn build
|
||||
|
||||
- name: Upload Build Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: documentation/build
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
|
||||
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||
permissions:
|
||||
pages: write # to deploy to Pages
|
||||
id-token: write # to verify the deployment originates from an appropriate source
|
||||
|
||||
# Deploy to the github-pages environment
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -8,3 +8,9 @@
|
|||
.externalNativeBuild
|
||||
.cxx
|
||||
*/.attach_pid*
|
||||
fastlane/*
|
||||
!fastlane/join-testers.png
|
||||
!fastlane/metadata
|
||||
Gemfile*
|
||||
*.sh
|
||||
!generate-changelogs.sh
|
|
@ -18,6 +18,8 @@ if [ $? -ne 0 ]; then
|
|||
fi
|
||||
|
||||
# Re-stage only the initially staged Kotlin files
|
||||
echo "$initial_staged_files" | xargs git add
|
||||
for file in $initial_staged_files; do
|
||||
git add "$file"
|
||||
done
|
||||
|
||||
echo "Kotlin files formatted"
|
||||
|
|
371
CHANGELOG.md
Normal file
371
CHANGELOG.md
Normal file
|
@ -0,0 +1,371 @@
|
|||
# Changelog
|
||||
|
||||
## [v7.4.0](https://github.com/PhilKes/NotallyX/tree/v7.4.0) (2025-04-18)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.1...v7.4.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Don't force capitalization when adding a label [\#532](https://github.com/PhilKes/NotallyX/issues/532)
|
||||
- Add a screen protection against screenshot attempts [\#386](https://github.com/PhilKes/NotallyX/issues/386)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Share pure text note error [\#544](https://github.com/PhilKes/NotallyX/issues/544)
|
||||
- Crash when deleting checked items in a list [\#539](https://github.com/PhilKes/NotallyX/issues/539)
|
||||
- Keyboard don't open after closing it on Android 7 [\#537](https://github.com/PhilKes/NotallyX/issues/537)
|
||||
- Unable to open links before changing view mode [\#527](https://github.com/PhilKes/NotallyX/issues/527)
|
||||
- Reminder popup cut on small screens [\#522](https://github.com/PhilKes/NotallyX/issues/522)
|
||||
- Auto Backup failed [\#514](https://github.com/PhilKes/NotallyX/issues/514)
|
||||
|
||||
## [v7.3.1](https://github.com/PhilKes/NotallyX/tree/v7.3.1) (2025-04-08)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.0...v7.3.1)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Button to close note search doesn't work [\#519](https://github.com/PhilKes/NotallyX/issues/519)
|
||||
- app crashes when pressing label [\#517](https://github.com/PhilKes/NotallyX/issues/517)
|
||||
|
||||
## [v7.3.0](https://github.com/PhilKes/NotallyX/tree/v7.3.0) (2025-04-07)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.1...v7.3.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Persist viewMode of each note individually [\#497](https://github.com/PhilKes/NotallyX/issues/497)
|
||||
- Read-only mode by default and new notes [\#495](https://github.com/PhilKes/NotallyX/issues/495)
|
||||
- Hide notes based on labels [\#401](https://github.com/PhilKes/NotallyX/issues/401)
|
||||
- An archived note should be visible in its label's folder. [\#398](https://github.com/PhilKes/NotallyX/issues/398)
|
||||
- Sharing notes from the app [\#380](https://github.com/PhilKes/NotallyX/issues/380)
|
||||
- Add support for json notes import [\#377](https://github.com/PhilKes/NotallyX/issues/377)
|
||||
- Sharing images to the app [\#281](https://github.com/PhilKes/NotallyX/issues/281)
|
||||
- Strikethrough checked items lists [\#250](https://github.com/PhilKes/NotallyX/issues/250)
|
||||
- Click on list element to check it [\#248](https://github.com/PhilKes/NotallyX/issues/248)
|
||||
- Add long press actions to undo/redo buttons [\#244](https://github.com/PhilKes/NotallyX/issues/244)
|
||||
- Convert Note \<=\> List [\#190](https://github.com/PhilKes/NotallyX/issues/190)
|
||||
- Edit labels inside notes [\#180](https://github.com/PhilKes/NotallyX/issues/180)
|
||||
- Support Wallpaper color themes [\#175](https://github.com/PhilKes/NotallyX/issues/175)
|
||||
- View Mode [\#76](https://github.com/PhilKes/NotallyX/issues/76)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Android 7.0 Navigation bar color issue [\#515](https://github.com/PhilKes/NotallyX/issues/515)
|
||||
- Search Mode loop for Android \< 9.0 [\#508](https://github.com/PhilKes/NotallyX/issues/508)
|
||||
- BaseNote.viewMode database migration error [\#505](https://github.com/PhilKes/NotallyX/issues/505)
|
||||
- New list items can't be added with linebreak/enter [\#496](https://github.com/PhilKes/NotallyX/issues/496)
|
||||
- Undo changes more than the last changed character [\#472](https://github.com/PhilKes/NotallyX/issues/472)
|
||||
- Auto Backup failed [\#468](https://github.com/PhilKes/NotallyX/issues/468)
|
||||
- Amount of backups to keep in periodic backups are not respected if nextcloud mount is used. [\#133](https://github.com/PhilKes/NotallyX/issues/133)
|
||||
|
||||
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
|
||||
|
||||
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
|
||||
|
||||
## [v7.2.0](https://github.com/PhilKes/NotallyX/tree/v7.2.0) (2025-03-08)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.1.0...v7.2.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Sort notes by color [\#442](https://github.com/PhilKes/NotallyX/issues/442)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Unable to locate the 'Uncheck all' option [\#444](https://github.com/PhilKes/NotallyX/issues/444)
|
||||
- List crash when last unchecked item moved [\#436](https://github.com/PhilKes/NotallyX/issues/436)
|
||||
- Pasting multi line text in empty lists crash [\#434](https://github.com/PhilKes/NotallyX/issues/434)
|
||||
- Quickly tapping delete button crash [\#428](https://github.com/PhilKes/NotallyX/issues/428)
|
||||
- First list item can keep parent property [\#427](https://github.com/PhilKes/NotallyX/issues/427)
|
||||
- \(List\) Move last unchecked item above parent bug [\#425](https://github.com/PhilKes/NotallyX/issues/425)
|
||||
- Dragging child item to same position breaks parent association [\#422](https://github.com/PhilKes/NotallyX/issues/422)
|
||||
- Disabling list auto sorting crash [\#421](https://github.com/PhilKes/NotallyX/issues/421)
|
||||
- Crash while indenting a checklist item [\#419](https://github.com/PhilKes/NotallyX/issues/419)
|
||||
- List swipe as subtask crash [\#418](https://github.com/PhilKes/NotallyX/issues/418)
|
||||
- List crash [\#413](https://github.com/PhilKes/NotallyX/issues/413)
|
||||
- Checked parent + subtask places between item [\#410](https://github.com/PhilKes/NotallyX/issues/410)
|
||||
- App crashed while screen was off [\#408](https://github.com/PhilKes/NotallyX/issues/408)
|
||||
- Unchecked items can't be deleted [\#407](https://github.com/PhilKes/NotallyX/issues/407)
|
||||
- Some list items can't be set to subtask after unchecked [\#406](https://github.com/PhilKes/NotallyX/issues/406)
|
||||
- List items deleted [\#405](https://github.com/PhilKes/NotallyX/issues/405)
|
||||
- List item parent task becomes subtask [\#404](https://github.com/PhilKes/NotallyX/issues/404)
|
||||
- Check empty item crash [\#403](https://github.com/PhilKes/NotallyX/issues/403)
|
||||
- Drag problem in long checklist [\#396](https://github.com/PhilKes/NotallyX/issues/396)
|
||||
- List swap items bug [\#395](https://github.com/PhilKes/NotallyX/issues/395)
|
||||
- Background crashes [\#323](https://github.com/PhilKes/NotallyX/issues/323)
|
||||
|
||||
## [v7.1.0](https://github.com/PhilKes/NotallyX/tree/v7.1.0) (2025-02-20)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.0.0...v7.1.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Dark mode for note colors [\#352](https://github.com/PhilKes/NotallyX/issues/352)
|
||||
- Add "new color" option when deleting a color [\#347](https://github.com/PhilKes/NotallyX/issues/347)
|
||||
- Make "Start view" as default view [\#339](https://github.com/PhilKes/NotallyX/issues/339)
|
||||
- Display first item as title for lists without title [\#317](https://github.com/PhilKes/NotallyX/issues/317)
|
||||
- Remove delete option from top bar since it's already in the bottom more menu [\#316](https://github.com/PhilKes/NotallyX/issues/316)
|
||||
- Add "Export" to bottom menu [\#315](https://github.com/PhilKes/NotallyX/issues/315)
|
||||
- Move "Hide labels" switch up below the labels slider [\#311](https://github.com/PhilKes/NotallyX/issues/311)
|
||||
- Display widgets with the notes' colors [\#300](https://github.com/PhilKes/NotallyX/issues/300)
|
||||
- Allow Pinning a Specific Label as the Starting Page [\#269](https://github.com/PhilKes/NotallyX/issues/269)
|
||||
- Move checked / unchecked items in list [\#251](https://github.com/PhilKes/NotallyX/issues/251)
|
||||
- Moving all labels to the sidebar [\#240](https://github.com/PhilKes/NotallyX/issues/240)
|
||||
- Add "no label" category [\#219](https://github.com/PhilKes/NotallyX/issues/219)
|
||||
- Manual color selection [\#187](https://github.com/PhilKes/NotallyX/issues/187)
|
||||
- Pure Dark Mode [\#16](https://github.com/PhilKes/NotallyX/issues/16)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Moving group of task at first position wrong order [\#392](https://github.com/PhilKes/NotallyX/issues/392)
|
||||
- Parent task not checked in specific case [\#391](https://github.com/PhilKes/NotallyX/issues/391)
|
||||
- Add auto-backup no error notification [\#381](https://github.com/PhilKes/NotallyX/issues/381)
|
||||
- Backup on save setting not restored [\#373](https://github.com/PhilKes/NotallyX/issues/373)
|
||||
- Start View setting not restored [\#367](https://github.com/PhilKes/NotallyX/issues/367)
|
||||
- \(List\) Child item can't be place after the item below it [\#362](https://github.com/PhilKes/NotallyX/issues/362)
|
||||
- Android 9 Crash java.lang.NoSuchMethodError: getTextSelectHandleLeft\(\) [\#358](https://github.com/PhilKes/NotallyX/issues/358)
|
||||
- List items order bug [\#357](https://github.com/PhilKes/NotallyX/issues/357)
|
||||
- Lists parent-child items crash [\#356](https://github.com/PhilKes/NotallyX/issues/356)
|
||||
- List new items wrong position [\#354](https://github.com/PhilKes/NotallyX/issues/354)
|
||||
- Replacement color message title limited to 2 lines [\#348](https://github.com/PhilKes/NotallyX/issues/348)
|
||||
- \(Lists\) Delete checked items and undo crash [\#331](https://github.com/PhilKes/NotallyX/issues/331)
|
||||
- Sort List items strange bug [\#330](https://github.com/PhilKes/NotallyX/issues/330)
|
||||
- Backup folder re-select prompt closes automatically [\#324](https://github.com/PhilKes/NotallyX/issues/324)
|
||||
- Biometric lock can't be enabled [\#259](https://github.com/PhilKes/NotallyX/issues/259)
|
||||
|
||||
|
||||
## [v7.0.0](https://github.com/PhilKes/NotallyX/tree/v7.0.0) (2025-01-27)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.1...v7.0.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Add Reminders menu to navigation panel [\#294](https://github.com/PhilKes/NotallyX/issues/294)
|
||||
- Extend notes colors to full screen [\#264](https://github.com/PhilKes/NotallyX/issues/264)
|
||||
- Auto-backup on note modification [\#203](https://github.com/PhilKes/NotallyX/issues/203)
|
||||
- Option to show full date inside the note regardless of chosen date format [\#111](https://github.com/PhilKes/NotallyX/issues/111)
|
||||
- Reminder for Notes [\#85](https://github.com/PhilKes/NotallyX/issues/85)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Restoring settings and backup folder [\#310](https://github.com/PhilKes/NotallyX/issues/310)
|
||||
- Settings can't be imported [\#307](https://github.com/PhilKes/NotallyX/issues/307)
|
||||
- Label create/edit dialog broken [\#302](https://github.com/PhilKes/NotallyX/issues/302)
|
||||
- Rotating screen moves the cursor to the end of note [\#293](https://github.com/PhilKes/NotallyX/issues/293)
|
||||
- Creating an empty note corrupts auto backup archive [\#288](https://github.com/PhilKes/NotallyX/issues/288)
|
||||
- Changes to check lists \(checking items and removing items\) reverts when screen rotatates [\#287](https://github.com/PhilKes/NotallyX/issues/287)
|
||||
- Auto-backups stop exporting after set limit is reached [\#270](https://github.com/PhilKes/NotallyX/issues/270)
|
||||
- Link is not saved if it's the last edit [\#267](https://github.com/PhilKes/NotallyX/issues/267)
|
||||
- Labels hidden in overview not applied when importing JSON [\#266](https://github.com/PhilKes/NotallyX/issues/266)
|
||||
- An unexpected error occurred. Sorry for the inconvenience [\#262](https://github.com/PhilKes/NotallyX/issues/262)
|
||||
- List subtasks bugs [\#207](https://github.com/PhilKes/NotallyX/issues/207)
|
||||
- Remove link also remove text [\#201](https://github.com/PhilKes/NotallyX/issues/201)
|
||||
- Biometric Lock crash [\#177](https://github.com/PhilKes/NotallyX/issues/177)
|
||||
- Import from Evernote and Google Keep not working [\#134](https://github.com/PhilKes/NotallyX/issues/134)
|
||||
|
||||
## [v6.4.1](https://github.com/PhilKes/NotallyX/tree/v6.4.1) (2025-01-17)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.0...v6.4.1)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Bottom AppBar cutoff + chinese translations [\#176](https://github.com/PhilKes/NotallyX/issues/176)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Crash loop when enabling biometrics [\#256](https://github.com/PhilKes/NotallyX/issues/256)
|
||||
- Crash when creating notes in 6.4.0 [\#255](https://github.com/PhilKes/NotallyX/issues/255)
|
||||
|
||||
## [v6.4.0](https://github.com/PhilKes/NotallyX/tree/v6.4.0) (2025-01-17)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.3.0...v6.4.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Prevent word wrap in titles [\#220](https://github.com/PhilKes/NotallyX/issues/220)
|
||||
- Move more top menu to bottom appbar [\#206](https://github.com/PhilKes/NotallyX/issues/206)
|
||||
- App settings backup [\#204](https://github.com/PhilKes/NotallyX/issues/204)
|
||||
- Add a touch bar to scroll quickly [\#202](https://github.com/PhilKes/NotallyX/issues/202)
|
||||
- Select all notes menu button [\#186](https://github.com/PhilKes/NotallyX/issues/186)
|
||||
- One line entries in main menu [\#185](https://github.com/PhilKes/NotallyX/issues/185)
|
||||
- Some suggestions [\#183](https://github.com/PhilKes/NotallyX/issues/183)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Cutting text during search causes app crash [\#245](https://github.com/PhilKes/NotallyX/issues/245)
|
||||
- Long notes can't be shared [\#243](https://github.com/PhilKes/NotallyX/issues/243)
|
||||
- Trigger disable/enable dataOnExternalStorage on settings import [\#231](https://github.com/PhilKes/NotallyX/issues/231)
|
||||
- Fast scrollbar in notes overview lags [\#230](https://github.com/PhilKes/NotallyX/issues/230)
|
||||
- Handle user unenrolling device biometrics while biometric lock is enabled [\#229](https://github.com/PhilKes/NotallyX/issues/229)
|
||||
- Crash on import if no file explorer app installed [\#227](https://github.com/PhilKes/NotallyX/issues/227)
|
||||
- Assign label inactive checkboxes [\#221](https://github.com/PhilKes/NotallyX/issues/221)
|
||||
- List icon bug in main view [\#215](https://github.com/PhilKes/NotallyX/issues/215)
|
||||
- Import plain text issues [\#209](https://github.com/PhilKes/NotallyX/issues/209)
|
||||
- "Auto backup period in days" cursor buggy [\#192](https://github.com/PhilKes/NotallyX/issues/192)
|
||||
- Unable to view full link while editing [\#181](https://github.com/PhilKes/NotallyX/issues/181)
|
||||
- Scrollbar missing ... [\#178](https://github.com/PhilKes/NotallyX/issues/178)
|
||||
- Widget is invisible when placed [\#156](https://github.com/PhilKes/NotallyX/issues/156)
|
||||
|
||||
**Closed issues:**
|
||||
|
||||
- Items in Lists [\#184](https://github.com/PhilKes/NotallyX/issues/184)
|
||||
|
||||
## [v6.3.0](https://github.com/PhilKes/NotallyX/tree/v6.3.0) (2024-12-23)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.2...v6.3.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Possibility to directly report bug when crash occurs [\#170](https://github.com/PhilKes/NotallyX/issues/170)
|
||||
- "Link Note" text for notes without a title [\#166](https://github.com/PhilKes/NotallyX/issues/166)
|
||||
- Improve app theme's color contrasts [\#163](https://github.com/PhilKes/NotallyX/issues/163)
|
||||
- Paste text containing link does not convert to clickable link + polish translation [\#157](https://github.com/PhilKes/NotallyX/issues/157)
|
||||
- Bottom navigation to increase accessibility for one handed usage [\#129](https://github.com/PhilKes/NotallyX/issues/129)
|
||||
- Search in selected note [\#108](https://github.com/PhilKes/NotallyX/issues/108)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- App crashes when enabling biometric lock [\#168](https://github.com/PhilKes/NotallyX/issues/168)
|
||||
- Barely visible Navigation buttons on Motorola devices in Light Theme [\#161](https://github.com/PhilKes/NotallyX/issues/161)
|
||||
|
||||
## [v6.2.2](https://github.com/PhilKes/NotallyX/tree/v6.2.2) (2024-12-09)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.1...v6.2.2)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Encrypted backups [\#151](https://github.com/PhilKes/NotallyX/issues/151)
|
||||
- Parse list items when pasting a list formatted text into a list note [\#150](https://github.com/PhilKes/NotallyX/issues/150)
|
||||
- Chinese display of options interface [\#149](https://github.com/PhilKes/NotallyX/issues/149)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- List notes deleting item even though text is not empty on backspace pressed [\#142](https://github.com/PhilKes/NotallyX/issues/142)
|
||||
|
||||
## [v6.2.1](https://github.com/PhilKes/NotallyX/tree/v6.2.1) (2024-12-06)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.0...v6.2.1)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Add change color option inside of a Note [\#140](https://github.com/PhilKes/NotallyX/issues/140)
|
||||
- Let user choose whether add items in a todo-list at the bottom or top of already existing items [\#132](https://github.com/PhilKes/NotallyX/issues/132)
|
||||
- Migrate Theme to Material 3 [\#104](https://github.com/PhilKes/NotallyX/issues/104)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Exporting multiple notes with same title overwrites files [\#144](https://github.com/PhilKes/NotallyX/issues/144)
|
||||
- Single notes export as "Untitled.txt" [\#143](https://github.com/PhilKes/NotallyX/issues/143)
|
||||
|
||||
## [v6.2.0](https://github.com/PhilKes/NotallyX/tree/v6.2.0) (2024-12-03)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.2...v6.2.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Replace positions of "Link Note" and "Select all" [\#136](https://github.com/PhilKes/NotallyX/issues/136)
|
||||
- Allow to add Notes in Label View [\#128](https://github.com/PhilKes/NotallyX/issues/128)
|
||||
- Empty notes deleted forever [\#118](https://github.com/PhilKes/NotallyX/issues/118)
|
||||
- Sync devices using Syncthing [\#109](https://github.com/PhilKes/NotallyX/issues/109)
|
||||
- Import from txt files [\#103](https://github.com/PhilKes/NotallyX/issues/103)
|
||||
- Add more Export formats [\#62](https://github.com/PhilKes/NotallyX/issues/62)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Using delete option from inside a note won't delete the note [\#131](https://github.com/PhilKes/NotallyX/issues/131)
|
||||
- The app crashes when creating a link [\#112](https://github.com/PhilKes/NotallyX/issues/112)
|
||||
- Pinning does not work [\#110](https://github.com/PhilKes/NotallyX/issues/110)
|
||||
|
||||
## [v6.1.2](https://github.com/PhilKes/NotallyX/tree/v6.1.2) (2024-11-19)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.1...v6.1.2)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- F-Droid can't build [\#126](https://github.com/PhilKes/NotallyX/issues/126)
|
||||
- Undo in list can be quite destructive [\#125](https://github.com/PhilKes/NotallyX/issues/125)
|
||||
- Actions like pin/label/archive only work from overview, not individual note/list [\#124](https://github.com/PhilKes/NotallyX/issues/124)
|
||||
- Jumbled notes after opening settings [\#100](https://github.com/PhilKes/NotallyX/issues/100)
|
||||
|
||||
## [v6.1.1](https://github.com/PhilKes/NotallyX/tree/v6.1.1) (2024-11-14)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.0...v6.1.1)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Show delete option in top bar after selecting a note [\#105](https://github.com/PhilKes/NotallyX/issues/105)
|
||||
- Clear previous search term by default [\#96](https://github.com/PhilKes/NotallyX/issues/96)
|
||||
- Color the navigation bar [\#95](https://github.com/PhilKes/NotallyX/issues/95)
|
||||
- Make multiselection easier [\#91](https://github.com/PhilKes/NotallyX/issues/91)
|
||||
- Auto Discard Empty Notes [\#86](https://github.com/PhilKes/NotallyX/issues/86)
|
||||
- Pin/label multiple Notes [\#78](https://github.com/PhilKes/NotallyX/issues/78)
|
||||
- Hide widget notes when app locked. [\#75](https://github.com/PhilKes/NotallyX/issues/75)
|
||||
- Creating categories [\#72](https://github.com/PhilKes/NotallyX/issues/72)
|
||||
- Add Search Bar to Archived and Deleted Notes Sections [\#68](https://github.com/PhilKes/NotallyX/issues/68)
|
||||
- Ability to swipe ListItem on DragHandle [\#67](https://github.com/PhilKes/NotallyX/issues/67)
|
||||
- Show Last Modified Dates in Notes [\#60](https://github.com/PhilKes/NotallyX/issues/60)
|
||||
- Import from major note apps [\#53](https://github.com/PhilKes/NotallyX/issues/53)
|
||||
- Display Labels in NavigationView [\#49](https://github.com/PhilKes/NotallyX/issues/49)
|
||||
- Unneeded Notification Permission on Image Upload [\#34](https://github.com/PhilKes/NotallyX/issues/34)
|
||||
- Linking Notes Within Notes [\#32](https://github.com/PhilKes/NotallyX/issues/32)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Deleted notes won't "delete forever" [\#117](https://github.com/PhilKes/NotallyX/issues/117)
|
||||
- Empty Auto Backup [\#101](https://github.com/PhilKes/NotallyX/issues/101)
|
||||
- Selector Moves Left in Widget When Selecting Untitled Notes [\#63](https://github.com/PhilKes/NotallyX/issues/63)
|
||||
|
||||
## [v6.0](https://github.com/PhilKes/NotallyX/tree/v6.0) (2024-10-28)
|
||||
|
||||
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/a29bff9a2d1adcbea47cb024ab21426bd678c016...v6.0)
|
||||
|
||||
### Added Features
|
||||
|
||||
- Change icon and add monochrome icon [\#44](https://github.com/PhilKes/NotallyX/issues/44)
|
||||
- Improve copy&paste behaviour [\#40](https://github.com/PhilKes/NotallyX/issues/40)
|
||||
- Option to Change a Note in Widget [\#36](https://github.com/PhilKes/NotallyX/issues/36)
|
||||
- Improve auto-backups [\#31](https://github.com/PhilKes/NotallyX/issues/31)
|
||||
- Lock Notes via PIN/Fingerprint [\#30](https://github.com/PhilKes/NotallyX/issues/30)
|
||||
- More options for sorting Notes in Overview [\#29](https://github.com/PhilKes/NotallyX/issues/29)
|
||||
- Support subtasks in Widgets \(for list notes\) [\#6](https://github.com/PhilKes/NotallyX/issues/6)
|
||||
- Improving Image Display on Notes [\#15](https://github.com/PhilKes/NotallyX/issues/15)
|
||||
- File attachment [\#9](https://github.com/PhilKes/NotallyX/issues/9)
|
||||
- Highlighting Completed Tasks in widget [\#17](https://github.com/PhilKes/NotallyX/issues/17)
|
||||
- Encrypt backups [\#18](https://github.com/PhilKes/NotallyX/issues/18)
|
||||
- Undo deleting/archiving notes [\#19](https://github.com/PhilKes/NotallyX/issues/19)
|
||||
|
||||
### Fixed Bugs
|
||||
|
||||
- Title Change Doesn’t Update Last Modified Date [\#61](https://github.com/PhilKes/NotallyX/issues/61)
|
||||
- Biometric Lock Improvement [\#58](https://github.com/PhilKes/NotallyX/issues/58)
|
||||
- App crashes when pin lock is enabled [\#50](https://github.com/PhilKes/NotallyX/issues/50)
|
||||
- Undo action ignored Spans [\#47](https://github.com/PhilKes/NotallyX/issues/47)
|
||||
- Crash After Importing a Note [\#13](https://github.com/PhilKes/NotallyX/issues/13)
|
||||
- Tasks Disappear When Changing App Language [\#4](https://github.com/PhilKes/NotallyX/issues/4)
|
||||
- Unable to Swipe Back After Adding Tasks [\#5](https://github.com/PhilKes/NotallyX/issues/5)
|
||||
- App Crash When Importing Notes and Opening a Task Note [\#7](https://github.com/PhilKes/NotallyX/issues/7)
|
||||
- improving subtasks [\#8](https://github.com/PhilKes/NotallyX/issues/8)
|
||||
|
||||
|
||||
|
||||
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
25
Privacy-Policy.md
Normal file
25
Privacy-Policy.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
## Privacy Policy
|
||||
|
||||
This privacy policy applies to the NotallyX app (hereby referred to as "Application") for mobile devices that was created as an Open Source service. This service is intended for use "AS IS".
|
||||
|
||||
### What information does the Application obtain and how is it used?
|
||||
|
||||
The Application does not obtain any information when you download and use it. Registration is not required to use the Application.
|
||||
|
||||
### Does the Application collect precise real time location information of the device?
|
||||
|
||||
This Application does not collect precise information about the location of your mobile device.
|
||||
|
||||
### Do third parties see and/or have access to information obtained by the Application?
|
||||
|
||||
Since the Application does not collect any information, no data is shared with third parties.
|
||||
|
||||
### Your Consent
|
||||
|
||||
By using the Application, you are consenting to this Privacy Policy now and as amended by the developer.
|
||||
|
||||
### Contact Us
|
||||
|
||||
If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact us via email at philkeyplaystore@gmail.com.
|
||||
|
||||
This privacy policy is effective as of 2024-10-30
|
|
@ -1 +0,0 @@
|
|||
No user data is collected
|
48
README.md
48
README.md
|
@ -2,6 +2,13 @@
|
|||
<img src="fastlane/metadata/android/en-US/images/icon.png" alt="icon" width="90"/>
|
||||
<br />
|
||||
<b>NotallyX | Minimalistic note taking app</b>
|
||||
<p>
|
||||
<center>
|
||||
<a href='https://play.google.com/store/apps/details?id=com.philkes.notallyx&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>
|
||||
<a href="https://f-droid.org/en/packages/com.philkes.notallyx"><img alt='IzzyOnDroid' height='80' src='https://fdroid.gitlab.io/artwork/badge/get-it-on.png' /></a>
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.philkes.notallyx"><img alt='F-Droid' height='80' src='https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png' /></a>
|
||||
</center>
|
||||
</p>
|
||||
</h2>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; width: 100%;">
|
||||
|
@ -20,7 +27,8 @@
|
|||
[Notally](https://github.com/OmGodse/Notally), but eXtended
|
||||
|
||||
* Create **rich text** notes with support for bold, italics, mono space and strike-through
|
||||
* Create **task lists** and order them with subtasks
|
||||
* Create **task lists** and order them with subtasks (+ auto-sort checked items to the end)
|
||||
* Set **reminders** with notifications for important notes
|
||||
* Complement your notes with any type of file such as **pictures**, PDFs, etc.
|
||||
* **Sort notes** by title, last modified date, creation date
|
||||
* **Color, pin and label** your notes for quick organisation
|
||||
|
@ -29,6 +37,7 @@
|
|||
* Use **Home Screen Widget** to access important notes fast
|
||||
* **Lock your notes via Biometric/PIN**
|
||||
* Configurable **auto-backups**
|
||||
* Create quick audio notes
|
||||
* Display the notes either in a **List or Grid**
|
||||
* Quickly share notes by text
|
||||
* Extensive preferences to adjust views to your liking
|
||||
|
@ -36,19 +45,42 @@
|
|||
* Adaptive android app icon
|
||||
* Support for Lollipop devices and up
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Translations
|
||||
All translations are crowd sourced. To contribute, follow these [guidelines](https://m2.material.io/design/communication/writing.html) and open a pull request.
|
||||
### Bug Reports / Feature-Requests
|
||||
If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new/choose)
|
||||
|
||||
When using the app and an unknown error occurs, causing the app to crash you will see a dialog (see showcase video in https://github.com/PhilKes/NotallyX/pull/171) from which you can immediately create a bug report on Github with the crash details pre-filled.
|
||||
|
||||
#### Beta Releases
|
||||
|
||||
I occasionally release BETA versions of the app during development, since its very valuable for me to get feedback before publicly releasing a new version.
|
||||
These BETA releases have another `applicationId` as the release versions, thats why when you install a BETA version it will show up on your device as a separate app called `NotallyX BETA`.
|
||||
BETA versions also have their own data, they do not use the data of your NotallyX app
|
||||
You can download the most recent BETA release [here on Github](https://github.com/PhilKes/NotallyX/releases/tag/beta)
|
||||
|
||||
### Translations
|
||||
All translations are crowd sourced.
|
||||
To contribute:
|
||||
1. Download current [translations.xlsx](https://github.com/PhilKes/NotallyX/raw/refs/heads/main/app/translations.xlsx)
|
||||
2. Open in Excel/LibreOffice and add missing translations
|
||||
Notes:
|
||||
- Missing translations are marked in red
|
||||
- You can filter by key or any language column values
|
||||
- Non-Translatable strings are hidden and marked in gray, do not add translations for them
|
||||
- For plurals, some languages need/have more quantity strings than others, if a quantity string in the default language (english) is not needed the row is highlighted in yellow. If your language does not need that quantity string either, ignore them.
|
||||
3. Open a [Update Translations Issue](https://github.com/PhilKes/NotallyX/issues/new?assignees=&labels=translations&projects=&template=translation.md&title=%3CINSERT+LANGUAGE+HERE%3E+translations+update)
|
||||
4. I will create a Pull-Request to add your updated translations
|
||||
|
||||
See [Android Translations Converter](https://github.com/PhilKes/android-translations-converter-plugin) for more details
|
||||
|
||||
### Contributing
|
||||
If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new)
|
||||
|
||||
If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet) and start working.
|
||||
The project is a default Android project written in Kotlin.
|
||||
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working, and run `./gradlew ktfmtFormat` for common formatting.
|
||||
If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet), leave a comment that you want to work on it and start developing by forking this repo.
|
||||
|
||||
The project is a default Android project written in Kotlin, I highly recommend using Android Studio for development. Also be sure to test your changes with an Android device/emulator that uses the same Android SDK Version as defined in the `build.gradle` `targetSdk`.
|
||||
|
||||
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working (`./gradlew test`), and run `./gradlew ktfmtFormat` for common formatting (also executed automatically as pre-commit hook).
|
||||
|
||||
### Attribution
|
||||
The original Notally project was developed by [OmGodse](https://github.com/OmGodse) under the [GPL 3.0 License](https://github.com/OmGodse/Notally/blob/master/LICENSE.md).
|
||||
|
|
142
app/build.gradle
142
app/build.gradle
|
@ -1,142 +0,0 @@
|
|||
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'com.ncorti.ktfmt.gradle' version '0.20.1'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.0'
|
||||
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
namespace 'com.philkes.notallyx'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'com.philkes.notallyx'
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
versionCode 601
|
||||
versionName "6.1-RC1"
|
||||
resourceConfigurations += ['en', 'ca', 'cs', 'da', 'de', 'el', 'es', 'fr', 'hu', 'in', 'it', 'ja', 'my', 'nb', 'nl', 'nn', 'pl', 'pt-rBR', 'pt-rPT', 'ro', 'ru', 'sk', 'sv', 'tl', 'tr', 'uk', 'vi', 'zh-rCN']
|
||||
vectorDrawables.generatedDensities = []
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
}
|
||||
release {
|
||||
crunchPngs false
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
kotlinOptions { jvmTarget = "1.8" }
|
||||
|
||||
buildFeatures { viewBinding true }
|
||||
|
||||
packagingOptions.resources {
|
||||
excludes += ["DebugProbesKt.bin", "META-INF/**.version", "kotlin/**.kotlin_builtins", "kotlin-tooling-metadata.json"]
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(KotlinCompile).configureEach {
|
||||
kotlinOptions.jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
ktfmt {
|
||||
kotlinLangStyle()
|
||||
}
|
||||
|
||||
tasks.register('ktfmtPrecommit', KtfmtFormatTask) {
|
||||
source = project.fileTree(rootDir)
|
||||
include("**/*.kt")
|
||||
}
|
||||
|
||||
tasks.register('installLocalGitHooks', Copy) {
|
||||
def scriptsDir = new File(rootProject.rootDir, '.scripts/')
|
||||
def hooksDir = new File(rootProject.rootDir, '.git/hooks')
|
||||
from(scriptsDir) {
|
||||
include 'pre-commit', 'pre-commit.bat'
|
||||
}
|
||||
into { hooksDir }
|
||||
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
|
||||
outputs.dir(hooksDir)
|
||||
fileMode 0775
|
||||
}
|
||||
preBuild.dependsOn installLocalGitHooks
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'junit:junit:4.13.2'
|
||||
final def navVersion = "2.3.5"
|
||||
final def roomVersion = "2.6.1"
|
||||
|
||||
ksp "androidx.room:room-compiler:$roomVersion"
|
||||
implementation "androidx.room:room-ktx:$roomVersion"
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation "net.zetetic:android-database-sqlcipher:4.5.3"
|
||||
implementation "androidx.sqlite:sqlite-ktx:2.4.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
implementation 'net.lingala.zip4j:zip4j:2.11.5'
|
||||
|
||||
implementation "androidx.work:work-runtime:2.9.1"
|
||||
|
||||
//noinspection GradleDependency
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
|
||||
//noinspection GradleDependency
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
|
||||
|
||||
implementation "org.ocpsoft.prettytime:prettytime:4.0.6.Final"
|
||||
implementation "com.google.android.material:material:1.12.0"
|
||||
|
||||
implementation 'com.github.zerobranch:SwipeLayout:1.3.1'
|
||||
|
||||
implementation "com.github.bumptech.glide:glide:4.15.1"
|
||||
implementation "com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0"
|
||||
|
||||
implementation "com.google.code.findbugs:jsr305:3.0.2"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
implementation("org.simpleframework:simple-xml:2.7.1") {
|
||||
exclude group: 'xpp3', module: 'xpp3'
|
||||
}
|
||||
implementation 'org.jsoup:jsoup:1.18.1'
|
||||
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "androidx.test:core:1.6.1"
|
||||
testImplementation "androidx.test:core-ktx:1.6.1"
|
||||
testImplementation "org.mockito:mockito-core:5.13.0"
|
||||
testImplementation "org.mockito.kotlin:mockito-kotlin:5.4.0"
|
||||
testImplementation "io.mockk:mockk:1.13.12"
|
||||
testImplementation "org.json:json:20180813"
|
||||
testImplementation "org.assertj:assertj-core:3.24.2"
|
||||
testImplementation "org.robolectric:robolectric:4.13"
|
||||
|
||||
androidTestImplementation "androidx.room:room-testing:$roomVersion"
|
||||
androidTestImplementation "androidx.work:work-testing:2.9.1"
|
||||
|
||||
}
|
223
app/build.gradle.kts
Normal file
223
app/build.gradle.kts
Normal file
|
@ -0,0 +1,223 @@
|
|||
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
||||
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
|
||||
import org.apache.commons.configuration2.PropertiesConfiguration
|
||||
import org.apache.commons.configuration2.io.FileHandler
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.parcelize")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.ncorti.ktfmt.gradle") version "0.20.1"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
|
||||
id("io.github.philkes.android-translations-converter") version "1.0.5"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.philkes.notallyx"
|
||||
compileSdk = 34
|
||||
ndkVersion = "29.0.13113456"
|
||||
defaultConfig {
|
||||
applicationId = "com.philkes.notallyx"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = project.findProperty("app.versionCode").toString().toInt()
|
||||
versionName = project.findProperty("app.versionName").toString()
|
||||
resourceConfigurations += listOf(
|
||||
"en", "ca", "cs", "da", "de", "el", "es", "fr", "hu", "in", "it", "ja", "my", "nb", "nl", "nn", "pl", "pt-rBR", "pt-rPT", "ro", "ru", "sk", "sv", "tl", "tr", "uk", "vi", "zh-rCN", "zh-rTW"
|
||||
)
|
||||
vectorDrawables.generatedDensities?.clear()
|
||||
ndk {
|
||||
debugSymbolLevel= "FULL"
|
||||
}
|
||||
}
|
||||
ksp {
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(providers.gradleProperty("RELEASE_STORE_FILE").get())
|
||||
storePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").get()
|
||||
keyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").get()
|
||||
keyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").get()
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
resValue("string", "app_name", "NotallyX DEBUG")
|
||||
}
|
||||
release {
|
||||
isCrunchPngs = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
create("beta"){
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".beta"
|
||||
versionNameSuffix = "-BETA"
|
||||
resValue("string", "app_name", "NotallyX BETA")
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
this.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
output.outputFileName = "NotallyX-$versionName.apk"
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes += listOf(
|
||||
"DebugProbesKt.bin",
|
||||
"META-INF/**.version",
|
||||
"kotlin/**.kotlin_builtins",
|
||||
"kotlin-tooling-metadata.json"
|
||||
)
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
ktfmt {
|
||||
kotlinLangStyle()
|
||||
}
|
||||
|
||||
tasks.register<KtfmtFormatTask>("ktfmtPrecommit") {
|
||||
source = project.fileTree(rootDir)
|
||||
include("**/*.kt")
|
||||
}
|
||||
|
||||
tasks.register<Copy>("installLocalGitHooks") {
|
||||
val scriptsDir = File(rootProject.rootDir, ".scripts/")
|
||||
val hooksDir = File(rootProject.rootDir, ".git/hooks")
|
||||
from(scriptsDir) {
|
||||
include("pre-commit", "pre-commit.bat")
|
||||
}
|
||||
into(hooksDir)
|
||||
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
|
||||
outputs.dir(hooksDir)
|
||||
fileMode = 509 // 0775 octal in decimal
|
||||
// If this throws permission denied:
|
||||
// chmod +rwx ./.git/hooks/pre-commit*
|
||||
}
|
||||
|
||||
tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel)
|
||||
|
||||
tasks.register("generateChangelogs") {
|
||||
doLast {
|
||||
val githubToken = providers.gradleProperty("CHANGELOG_GITHUB_TOKEN").orNull
|
||||
|
||||
val command = mutableListOf(
|
||||
"bash",
|
||||
rootProject.file("generate-changelogs.sh").absolutePath,
|
||||
"v${project.findProperty("app.lastVersionName").toString()}",
|
||||
rootProject.file("CHANGELOG.md").absolutePath
|
||||
)
|
||||
if (!githubToken.isNullOrEmpty()) {
|
||||
command.add(githubToken)
|
||||
} else {
|
||||
println("CHANGELOG_GITHUB_TOKEN not found, which limits the allowed amount of Github API calls")
|
||||
}
|
||||
exec {
|
||||
commandLine(command)
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
}
|
||||
|
||||
val config = PropertiesConfiguration()
|
||||
val fileHandler = FileHandler(config).apply {
|
||||
file = rootProject.file("gradle.properties")
|
||||
load()
|
||||
}
|
||||
val currentVersionName = config.getProperty("app.versionName")
|
||||
config.setProperty("app.lastVersionName", currentVersionName)
|
||||
fileHandler.save()
|
||||
println("Updated app.lastVersionName to $currentVersionName")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("bundleRelease").configure {
|
||||
dependsOn(tasks.named("testReleaseUnitTest"))
|
||||
}
|
||||
tasks.named("assembleRelease").configure {
|
||||
dependsOn(tasks.named("testReleaseUnitTest"))
|
||||
finalizedBy(tasks.named("generateChangelogs"))
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val navVersion = "2.3.5"
|
||||
val roomVersion = "2.6.1"
|
||||
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
ksp("androidx.room:room-compiler:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
|
||||
implementation("androidx.work:work-runtime:2.9.1")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
implementation("cat.ereza:customactivityoncrash:2.4.0")
|
||||
implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0")
|
||||
implementation("com.github.bumptech.glide:glide:4.15.1")
|
||||
implementation("cn.Leaqi:SwipeDrawer:1.6")
|
||||
implementation("com.github.skydoves:colorpickerview:2.3.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("com.google.code.findbugs:jsr305:3.0.2")
|
||||
implementation("me.zhanghai.android.fastscroll:library:1.3.0")
|
||||
implementation("net.lingala.zip4j:zip4j:2.11.5")
|
||||
implementation("net.zetetic:android-database-sqlcipher:4.5.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
implementation("org.jsoup:jsoup:1.18.1")
|
||||
implementation("org.ocpsoft.prettytime:prettytime:4.0.6.Final")
|
||||
implementation("org.simpleframework:simple-xml:2.7.1") {
|
||||
exclude(group = "xpp3", module = "xpp3")
|
||||
}
|
||||
|
||||
androidTestImplementation("androidx.room:room-testing:$roomVersion")
|
||||
androidTestImplementation("androidx.work:work-testing:2.9.1")
|
||||
testImplementation("androidx.arch.core:core-testing:2.2.0")
|
||||
testImplementation("androidx.test:core-ktx:1.6.1")
|
||||
testImplementation("androidx.test:core:1.6.1")
|
||||
testImplementation("io.mockk:mockk:1.13.12")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.assertj:assertj-core:3.24.2")
|
||||
testImplementation("org.json:json:20180813")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
|
||||
testImplementation("org.mockito:mockito-core:5.13.0")
|
||||
testImplementation("org.robolectric:robolectric:4.13")
|
||||
}
|
271494
app/obfuscation/mapping.txt
Normal file
271494
app/obfuscation/mapping.txt
Normal file
File diff suppressed because it is too large
Load diff
16
app/proguard-rules.pro
vendored
16
app/proguard-rules.pro
vendored
|
@ -1,6 +1,6 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
@ -11,14 +11,12 @@
|
|||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
-keepattributes LineNumberTable,SourceFile
|
||||
-renamesourcefileattribute SourceFile
|
||||
-dontobfuscate
|
||||
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
|
||||
-printmapping obfuscation/mapping.txt
|
||||
|
||||
-keep class ** extends androidx.navigation.Navigator
|
||||
-keep class ** implements org.ocpsoft.prettytime.TimeUnit
|
||||
|
||||
|
@ -47,3 +45,5 @@
|
|||
-keep class * implements org.simpleframework.xml.core.Extractor {
|
||||
public *;
|
||||
}
|
||||
|
||||
-keep class * implements java.io.Serializable
|
|
@ -2,11 +2,11 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "a0ebadcc625f8b49bf549975d7288f10",
|
||||
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BaseNote",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL)",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
@ -97,6 +97,12 @@
|
|||
"columnName": "audios",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reminders",
|
||||
"columnName": "reminders",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -146,7 +152,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a0ebadcc625f8b49bf549975d7288f10')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
|
||||
]
|
||||
}
|
||||
}
|
158
app/schemas/com.philkes.notallyx.data.NotallyDatabase/7.json
Normal file
158
app/schemas/com.philkes.notallyx.data.NotallyDatabase/7.json
Normal file
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BaseNote",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "modifiedTimestamp",
|
||||
"columnName": "modifiedTimestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "labels",
|
||||
"columnName": "labels",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "spans",
|
||||
"columnName": "spans",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "items",
|
||||
"columnName": "items",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "images",
|
||||
"columnName": "images",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "files",
|
||||
"columnName": "files",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "audios",
|
||||
"columnName": "audios",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reminders",
|
||||
"columnName": "reminders",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"folder",
|
||||
"pinned",
|
||||
"timestamp",
|
||||
"labels"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Label",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
|
||||
]
|
||||
}
|
||||
}
|
158
app/schemas/com.philkes.notallyx.data.NotallyDatabase/8.json
Normal file
158
app/schemas/com.philkes.notallyx.data.NotallyDatabase/8.json
Normal file
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BaseNote",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "modifiedTimestamp",
|
||||
"columnName": "modifiedTimestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "labels",
|
||||
"columnName": "labels",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "spans",
|
||||
"columnName": "spans",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "items",
|
||||
"columnName": "items",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "images",
|
||||
"columnName": "images",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "files",
|
||||
"columnName": "files",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "audios",
|
||||
"columnName": "audios",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reminders",
|
||||
"columnName": "reminders",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"folder",
|
||||
"pinned",
|
||||
"timestamp",
|
||||
"labels"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Label",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
|
||||
]
|
||||
}
|
||||
}
|
164
app/schemas/com.philkes.notallyx.data.NotallyDatabase/9.json
Normal file
164
app/schemas/com.philkes.notallyx.data.NotallyDatabase/9.json
Normal file
|
@ -0,0 +1,164 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "042b20b5b4cfc8415e6cf6348196e869",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BaseNote",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL, `viewMode` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "modifiedTimestamp",
|
||||
"columnName": "modifiedTimestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "labels",
|
||||
"columnName": "labels",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "spans",
|
||||
"columnName": "spans",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "items",
|
||||
"columnName": "items",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "images",
|
||||
"columnName": "images",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "files",
|
||||
"columnName": "files",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "audios",
|
||||
"columnName": "audios",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reminders",
|
||||
"columnName": "reminders",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewMode",
|
||||
"columnName": "viewMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"folder",
|
||||
"pinned",
|
||||
"timestamp",
|
||||
"labels"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Label",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '042b20b5b4cfc8415e6cf6348196e869')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -6,12 +6,15 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||
tools:node="remove" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
|
||||
<application
|
||||
|
@ -40,8 +43,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".presentation.activity.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/MainActivity">
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -66,15 +68,38 @@
|
|||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="file" android:mimeType="text/*" />
|
||||
<data android:scheme="content" android:mimeType="text/*" />
|
||||
|
||||
<data android:scheme="file" android:mimeType="application/json" />
|
||||
<data android:scheme="content" android:mimeType="application/json" />
|
||||
|
||||
<data android:scheme="file" android:mimeType="application/xml" />
|
||||
<data android:scheme="content" android:mimeType="application/xml" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".presentation.activity.note.ViewImageActivity" />
|
||||
|
||||
<activity android:name=".presentation.activity.note.SelectLabelsActivity" />
|
||||
|
||||
<activity android:name=".presentation.activity.note.reminders.RemindersActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".presentation.activity.note.RecordAudioActivity"
|
||||
android:launchMode="singleTask" />
|
||||
|
@ -96,6 +121,16 @@
|
|||
android:exported="false">
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".utils.ErrorActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/unknown_error"
|
||||
android:process=":error_activity">
|
||||
<intent-filter>
|
||||
<action android:name="cat.ereza.customactivityoncrash.ERROR" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".presentation.widget.WidgetProvider"
|
||||
android:exported="false"
|
||||
|
@ -111,6 +146,14 @@
|
|||
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".presentation.activity.note.reminders.ReminderReceiver" android:enabled="true" android:exported="true" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".presentation.widget.WidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
@ -122,7 +165,8 @@
|
|||
|
||||
<service
|
||||
android:name=".utils.audio.AudioPlayService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
</application>
|
||||
|
||||
|
|
83
app/src/main/java/android/print/PdfExtensions.kt
Normal file
83
app/src/main/java/android/print/PdfExtensions.kt
Normal file
|
@ -0,0 +1,83 @@
|
|||
package android.print
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.philkes.notallyx.utils.nameWithoutExtension
|
||||
|
||||
/**
|
||||
* Needs to be in android.print package to access the package private methods of
|
||||
* [PrintDocumentAdapter]
|
||||
*/
|
||||
fun Context.printPdf(file: DocumentFile, content: String, pdfPrintListener: PdfPrintListener) {
|
||||
val webView = WebView(this)
|
||||
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
webView.webViewClient =
|
||||
object : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension!!)
|
||||
contentResolver.printPdf(file, adapter, pdfPrintListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentResolver.printPdf(
|
||||
file: DocumentFile,
|
||||
adapter: PrintDocumentAdapter,
|
||||
pdfPrintListener: PdfPrintListener,
|
||||
) {
|
||||
val onLayoutResult =
|
||||
object : PrintDocumentAdapter.LayoutResultCallback() {
|
||||
|
||||
override fun onLayoutFailed(error: CharSequence?) {
|
||||
pdfPrintListener.onFailure(error)
|
||||
}
|
||||
|
||||
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
|
||||
this@printPdf.writeToFile(file, adapter, pdfPrintListener)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.onLayout(null, createPrintAttributes(), null, onLayoutResult, null)
|
||||
}
|
||||
|
||||
private fun ContentResolver.writeToFile(
|
||||
file: DocumentFile,
|
||||
adapter: PrintDocumentAdapter,
|
||||
pdfPrintListener: PdfPrintListener,
|
||||
) {
|
||||
val onWriteResult =
|
||||
object : PrintDocumentAdapter.WriteResultCallback() {
|
||||
|
||||
override fun onWriteFailed(error: CharSequence?) {
|
||||
pdfPrintListener.onFailure(error)
|
||||
}
|
||||
|
||||
override fun onWriteFinished(pages: Array<out PageRange>?) {
|
||||
pdfPrintListener.onSuccess(file)
|
||||
}
|
||||
}
|
||||
|
||||
val pages = arrayOf(PageRange.ALL_PAGES)
|
||||
val fileDescriptor = openFileDescriptor(file.uri, "rw")
|
||||
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
|
||||
}
|
||||
|
||||
private fun createPrintAttributes(): PrintAttributes {
|
||||
return with(PrintAttributes.Builder()) {
|
||||
setMediaSize(PrintAttributes.MediaSize.ISO_A4)
|
||||
setMinMargins(PrintAttributes.Margins.NO_MARGINS)
|
||||
setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
interface PdfPrintListener {
|
||||
|
||||
fun onSuccess(file: DocumentFile)
|
||||
|
||||
fun onFailure(message: CharSequence?)
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
package android.print
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* This class needs to be in android.print package to access the package private methods of
|
||||
* [PrintDocumentAdapter]
|
||||
*/
|
||||
object PostPDFGenerator {
|
||||
|
||||
fun create(file: File, content: String, context: Context, onResult: OnResult) {
|
||||
val webView = WebView(context)
|
||||
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
webView.webViewClient =
|
||||
object : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension)
|
||||
print(file, adapter, onResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun print(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
|
||||
val onLayoutResult =
|
||||
object : PrintDocumentAdapter.LayoutResultCallback() {
|
||||
|
||||
override fun onLayoutFailed(error: CharSequence?) {
|
||||
onResult.onFailure(error)
|
||||
}
|
||||
|
||||
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
|
||||
writeToFile(file, adapter, onResult)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.onLayout(null, getPrintAttributes(), null, onLayoutResult, null)
|
||||
}
|
||||
|
||||
private fun writeToFile(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
|
||||
val onWriteResult =
|
||||
object : PrintDocumentAdapter.WriteResultCallback() {
|
||||
|
||||
override fun onWriteFailed(error: CharSequence?) {
|
||||
onResult.onFailure(error)
|
||||
}
|
||||
|
||||
override fun onWriteFinished(pages: Array<out PageRange>?) {
|
||||
onResult.onSuccess(file)
|
||||
}
|
||||
}
|
||||
|
||||
val pages = arrayOf(PageRange.ALL_PAGES)
|
||||
if (!file.exists()) {
|
||||
file.createNewFile()
|
||||
}
|
||||
val fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE)
|
||||
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
|
||||
}
|
||||
|
||||
private fun getPrintAttributes(): PrintAttributes {
|
||||
val builder = PrintAttributes.Builder()
|
||||
builder.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
|
||||
builder.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
|
||||
builder.setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
interface OnResult {
|
||||
|
||||
fun onSuccess(file: File)
|
||||
|
||||
fun onFailure(message: CharSequence?)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package androidx.recyclerview.widget
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.children
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import kotlin.math.abs
|
||||
|
||||
class NestedScrollViewItemTouchHelper(
|
||||
callback: Callback,
|
||||
private val scrollView: NestedScrollView,
|
||||
) : ItemTouchHelper(callback) {
|
||||
private var selectedStartY: Int = -1
|
||||
private var selectedStartScrollY: Float = -1f
|
||||
private var selectedView: View? = null
|
||||
private var dragScrollStartTimeInMs: Long = 0
|
||||
|
||||
private var lastmDy = 0f
|
||||
private var lastScrollY = 0
|
||||
private var tmpRect: Rect? = null
|
||||
|
||||
override fun select(selected: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
super.select(selected, actionState)
|
||||
if (selected != null) {
|
||||
selectedView = selected.itemView
|
||||
selectedStartY = selected.itemView.top
|
||||
selectedStartScrollY = scrollView!!.scrollY.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls [scrollView] when an item in [mRecyclerView] is dragged to the top or bottom of the
|
||||
* [scrollView].
|
||||
*
|
||||
* Inspired by
|
||||
* [https://stackoverflow.com/a/70699988/9748566](https://stackoverflow.com/a/70699988/9748566)
|
||||
*/
|
||||
override fun scrollIfNecessary(): Boolean {
|
||||
if (mSelected == null) {
|
||||
dragScrollStartTimeInMs = Long.MIN_VALUE
|
||||
return false
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val scrollDuration =
|
||||
if (dragScrollStartTimeInMs == Long.MIN_VALUE) 0 else now - dragScrollStartTimeInMs
|
||||
val lm = mRecyclerView.layoutManager
|
||||
if (tmpRect == null) {
|
||||
tmpRect = Rect()
|
||||
}
|
||||
var scrollY = 0
|
||||
val currentScrollY = scrollView.scrollY
|
||||
|
||||
// We need to use the height of NestedScrollView, not RecyclerView's!
|
||||
val actualShowingHeight =
|
||||
scrollView.height - mRecyclerView.top - mRecyclerView.paddingBottom
|
||||
|
||||
lm!!.calculateItemDecorationsForChild(mSelected.itemView, tmpRect!!)
|
||||
if (lm.canScrollVertically()) {
|
||||
// Keep scrolling if the user didnt change the drag direction
|
||||
if (lastScrollY != 0 && abs(lastmDy) >= abs(mDy)) {
|
||||
scrollY = lastScrollY
|
||||
} else {
|
||||
// The true current Y of the item in NestedScrollView, not in RecyclerView!
|
||||
val curY = (selectedStartY + mDy - currentScrollY).toInt()
|
||||
// The true mDy should plus the initial scrollY and minus current scrollY of
|
||||
// NestedScrollView
|
||||
val checkDy = (mDy + selectedStartScrollY - currentScrollY).toInt()
|
||||
val topDiff = curY - tmpRect!!.top - mRecyclerView.paddingTop
|
||||
if (checkDy < 0 && topDiff < 0) { // User is draging the item out of the top edge.
|
||||
scrollY = topDiff
|
||||
} else if (checkDy > 0) { // User is draging the item out of the bottom edge.
|
||||
val bottomDiff = (curY + mSelected.itemView.height - actualShowingHeight) + 10
|
||||
if (bottomDiff >= 0) {
|
||||
scrollY = bottomDiff
|
||||
}
|
||||
} else {
|
||||
scrollY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
lastScrollY = scrollY
|
||||
lastmDy = mDy
|
||||
if (scrollY != 0) {
|
||||
scrollY =
|
||||
mCallback.interpolateOutOfBoundsScroll(
|
||||
mRecyclerView,
|
||||
mSelected.itemView.height,
|
||||
scrollY,
|
||||
actualShowingHeight,
|
||||
scrollDuration,
|
||||
)
|
||||
}
|
||||
if (scrollY != 0) {
|
||||
val maxScrollY = scrollView.childrenHeightsSum - scrollView.height
|
||||
// Check if we can scroll further before applying the scroll
|
||||
if (
|
||||
(scrollY < 0 && scrollView.scrollY > 0) ||
|
||||
(scrollY > 0 && scrollView.scrollY < maxScrollY)
|
||||
) {
|
||||
if (dragScrollStartTimeInMs == Long.MIN_VALUE) {
|
||||
dragScrollStartTimeInMs = now
|
||||
}
|
||||
scrollView.scrollBy(0, scrollY)
|
||||
// Update the dragged item position as well
|
||||
selectedView?.translationY = selectedView!!.translationY + scrollY
|
||||
return true
|
||||
}
|
||||
}
|
||||
dragScrollStartTimeInMs = Long.MIN_VALUE
|
||||
lastScrollY = 0
|
||||
lastmDy = 0f
|
||||
return false
|
||||
}
|
||||
|
||||
private val ViewGroup.childrenHeightsSum
|
||||
get() = children.map { it.measuredHeight }.sum()
|
||||
}
|
|
@ -1,51 +1,191 @@
|
|||
package com.philkes.notallyx
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.Observer
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
|
||||
import com.philkes.notallyx.presentation.view.misc.Theme
|
||||
import com.philkes.notallyx.utils.backup.Export.scheduleAutoBackup
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.philkes.notallyx.presentation.setEnabledSecureFlag
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider
|
||||
import com.philkes.notallyx.utils.backup.AUTO_BACKUP_WORK_NAME
|
||||
import com.philkes.notallyx.utils.backup.autoBackupOnSave
|
||||
import com.philkes.notallyx.utils.backup.cancelAutoBackup
|
||||
import com.philkes.notallyx.utils.backup.containsNonCancelled
|
||||
import com.philkes.notallyx.utils.backup.deleteModifiedNoteBackup
|
||||
import com.philkes.notallyx.utils.backup.isEqualTo
|
||||
import com.philkes.notallyx.utils.backup.modifiedNoteBackupExists
|
||||
import com.philkes.notallyx.utils.backup.scheduleAutoBackup
|
||||
import com.philkes.notallyx.utils.backup.updateAutoBackup
|
||||
import com.philkes.notallyx.utils.observeOnce
|
||||
import com.philkes.notallyx.utils.security.UnlockReceiver
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NotallyXApplication : Application() {
|
||||
class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks {
|
||||
|
||||
private lateinit var biometricLockObserver: Observer<String>
|
||||
private lateinit var preferences: Preferences
|
||||
private lateinit var biometricLockObserver: Observer<BiometricLock>
|
||||
private lateinit var preferences: NotallyXPreferences
|
||||
private var unlockReceiver: UnlockReceiver? = null
|
||||
|
||||
var isLocked = true
|
||||
val locked = NotNullLiveData(true)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
preferences = Preferences.getInstance(this)
|
||||
preferences.theme.observeForever { theme ->
|
||||
registerActivityLifecycleCallbacks(this)
|
||||
if (isTestRunner()) return
|
||||
preferences = NotallyXPreferences.getInstance(this)
|
||||
if (preferences.useDynamicColors.value) {
|
||||
if (DynamicColors.isDynamicColorAvailable()) {
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
}
|
||||
} else {
|
||||
setTheme(R.style.AppTheme)
|
||||
}
|
||||
preferences.theme.observeForeverWithPrevious { (oldTheme, theme) ->
|
||||
when (theme) {
|
||||
Theme.dark ->
|
||||
Theme.DARK ->
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
Theme.light ->
|
||||
|
||||
Theme.LIGHT ->
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
Theme.followSystem ->
|
||||
|
||||
Theme.FOLLOW_SYSTEM ->
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
)
|
||||
}
|
||||
if (oldTheme != null) {
|
||||
WidgetProvider.updateWidgets(this, locked = locked.value)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAutoBackup(preferences.autoBackupPeriodDays.value.toLong(), this)
|
||||
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
|
||||
checkUpdatePeriodicBackup(
|
||||
backupFolderBefore,
|
||||
backupFolder,
|
||||
preferences.periodicBackups.value.periodInDays.toLong(),
|
||||
)
|
||||
}
|
||||
preferences.periodicBackups.observeForever { value ->
|
||||
val backupFolder = preferences.backupsFolder.value
|
||||
checkUpdatePeriodicBackup(backupFolder, backupFolder, value.periodInDays.toLong())
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) }
|
||||
biometricLockObserver = Observer {
|
||||
if (it == enabled) {
|
||||
biometricLockObserver = Observer { biometricLock ->
|
||||
if (biometricLock == BiometricLock.ENABLED) {
|
||||
unlockReceiver = UnlockReceiver(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(unlockReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(unlockReceiver, filter)
|
||||
} else if (unlockReceiver != null) {
|
||||
unregisterReceiver(unlockReceiver)
|
||||
}
|
||||
} else {
|
||||
unlockReceiver?.let { unregisterReceiver(it) }
|
||||
if (locked.value) {
|
||||
locked.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
preferences.biometricLock.observeForever(biometricLockObserver)
|
||||
|
||||
locked.observeForever { isLocked -> WidgetProvider.updateWidgets(this, locked = isLocked) }
|
||||
|
||||
preferences.backupPassword.observeForeverWithPrevious {
|
||||
(previousBackupPassword, backupPassword) ->
|
||||
if (preferences.backupOnSave.value) {
|
||||
val backupPath = preferences.backupsFolder.value
|
||||
if (backupPath != EMPTY_PATH) {
|
||||
if (
|
||||
!modifiedNoteBackupExists(backupPath) ||
|
||||
(previousBackupPassword != null &&
|
||||
previousBackupPassword != backupPassword)
|
||||
) {
|
||||
deleteModifiedNoteBackup(backupPath)
|
||||
MainScope().launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
autoBackupOnSave(
|
||||
backupPath,
|
||||
savedNote = null,
|
||||
password = backupPassword,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkUpdatePeriodicBackup(
|
||||
backupFolderBefore: String?,
|
||||
backupFolder: String,
|
||||
periodInDays: Long,
|
||||
) {
|
||||
val workManager = getWorkManagerSafe() ?: return
|
||||
workManager.getWorkInfosForUniqueWorkLiveData(AUTO_BACKUP_WORK_NAME).observeOnce { workInfos
|
||||
->
|
||||
if (backupFolder == EMPTY_PATH || periodInDays < 1) {
|
||||
if (workInfos?.containsNonCancelled() == true) {
|
||||
workManager.cancelAutoBackup()
|
||||
}
|
||||
} else if (
|
||||
workInfos.isNullOrEmpty() ||
|
||||
workInfos.all { it.state == WorkInfo.State.CANCELLED } ||
|
||||
(backupFolderBefore != null && backupFolderBefore != backupFolder)
|
||||
) {
|
||||
workManager.scheduleAutoBackup(this, periodInDays)
|
||||
} else if (
|
||||
workInfos.first().periodicityInfo?.isEqualTo(periodInDays, TimeUnit.DAYS) == false
|
||||
) {
|
||||
workManager.updateAutoBackup(workInfos, periodInDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWorkManagerSafe(): WorkManager? {
|
||||
return try {
|
||||
WorkManager.getInstance(this)
|
||||
} catch (e: Exception) {
|
||||
// TODO: Happens when ErrorActivity is launched
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun isTestRunner(): Boolean {
|
||||
return Build.FINGERPRINT.equals("robolectric", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
activity.setEnabledSecureFlag(preferences.secureFlag.value)
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
|
|
|
@ -1,222 +0,0 @@
|
|||
package com.philkes.notallyx
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.preference.PreferenceManager
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.philkes.notallyx.data.model.toPreservedByteArray
|
||||
import com.philkes.notallyx.data.model.toPreservedString
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackup
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
|
||||
import com.philkes.notallyx.presentation.view.misc.BackupPassword
|
||||
import com.philkes.notallyx.presentation.view.misc.BetterLiveData
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock
|
||||
import com.philkes.notallyx.presentation.view.misc.DateFormat
|
||||
import com.philkes.notallyx.presentation.view.misc.ListInfo
|
||||
import com.philkes.notallyx.presentation.view.misc.ListItemSorting
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxItems
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxLines
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxTitle
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting
|
||||
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.view.misc.TextInfo
|
||||
import com.philkes.notallyx.presentation.view.misc.TextSize
|
||||
import com.philkes.notallyx.presentation.view.misc.Theme
|
||||
import com.philkes.notallyx.presentation.view.misc.View
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
|
||||
private const val DATABASE_ENCRYPTION_KEY = "database_encryption_key"
|
||||
|
||||
private const val ENCRYPTION_IV = "encryption_iv"
|
||||
|
||||
/**
|
||||
* Custom implementation of androidx.preference library Way faster, simpler and smaller, logic of
|
||||
* storing preferences has been decoupled from their UI. It is backed by SharedPreferences but it
|
||||
* should be trivial to shift to another source if needed.
|
||||
*/
|
||||
class Preferences private constructor(app: Application) {
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(app)
|
||||
private val editor = preferences.edit()
|
||||
|
||||
private val encryptedPreferences by lazy {
|
||||
EncryptedSharedPreferences.create(
|
||||
app,
|
||||
"secret_shared_prefs",
|
||||
MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
// Main thread (unfortunately)
|
||||
val view = BetterLiveData(getListPref(View))
|
||||
val theme = BetterLiveData(getListPref(Theme))
|
||||
val dateFormat = BetterLiveData(getListPref(DateFormat))
|
||||
|
||||
val notesSorting = BetterLiveData(getNotesSorting(NotesSorting))
|
||||
|
||||
val textSize = BetterLiveData(getListPref(TextSize))
|
||||
val listItemSorting = BetterLiveData(getListPref(ListItemSorting))
|
||||
var maxItems = getSeekbarPref(MaxItems)
|
||||
var maxLines = getSeekbarPref(MaxLines)
|
||||
var maxTitle = getSeekbarPref(MaxTitle)
|
||||
|
||||
val autoBackupPath = BetterLiveData(getTextPref(AutoBackup))
|
||||
var autoBackupPeriodDays = BetterLiveData(getSeekbarPref(AutoBackupPeriodDays))
|
||||
var autoBackupMax = getSeekbarPref(AutoBackupMax)
|
||||
val backupPassword by lazy { BetterLiveData(getEncryptedTextPref(BackupPassword)) }
|
||||
|
||||
val biometricLock = BetterLiveData(getListPref(BiometricLock))
|
||||
var iv: ByteArray?
|
||||
get() = preferences.getString(ENCRYPTION_IV, null)?.toPreservedByteArray
|
||||
set(value) {
|
||||
editor.putString(ENCRYPTION_IV, value?.toPreservedString)
|
||||
editor.commit()
|
||||
}
|
||||
|
||||
fun getDatabasePassphrase(): ByteArray {
|
||||
val string = preferences.getString(DATABASE_ENCRYPTION_KEY, "")!!
|
||||
return string.toPreservedByteArray
|
||||
}
|
||||
|
||||
fun generatePassphrase(cipher: Cipher): ByteArray {
|
||||
val random =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
SecureRandom.getInstanceStrong()
|
||||
} else {
|
||||
SecureRandom()
|
||||
}
|
||||
val result = ByteArray(64)
|
||||
|
||||
random.nextBytes(result)
|
||||
|
||||
// filter out zero byte values, as SQLCipher does not like them
|
||||
while (result.contains(0)) {
|
||||
random.nextBytes(result)
|
||||
}
|
||||
|
||||
val encryptedPassphrase = cipher.doFinal(result)
|
||||
editor.putString(DATABASE_ENCRYPTION_KEY, encryptedPassphrase.toPreservedString)
|
||||
editor.commit()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getListPref(info: ListInfo) =
|
||||
requireNotNull(preferences.getString(info.key, info.defaultValue))
|
||||
|
||||
private fun getNotesSorting(info: NotesSorting): Pair<String, SortDirection> {
|
||||
val sortBy = requireNotNull(preferences.getString(info.key, info.defaultValue))
|
||||
val sortDirection =
|
||||
requireNotNull(preferences.getString(info.directionKey, info.defaultValueDirection))
|
||||
return Pair(sortBy, SortDirection.valueOf(sortDirection))
|
||||
}
|
||||
|
||||
private fun getTextPref(info: TextInfo) =
|
||||
requireNotNull(preferences.getString(info.key, info.defaultValue))
|
||||
|
||||
private fun getEncryptedTextPref(info: TextInfo) =
|
||||
requireNotNull(encryptedPreferences!!.getString(info.key, info.defaultValue))
|
||||
|
||||
private fun getSeekbarPref(info: SeekbarInfo) =
|
||||
requireNotNull(preferences.getInt(info.key, info.defaultValue))
|
||||
|
||||
fun getWidgetData(id: Int) = preferences.getLong("widget:$id", 0)
|
||||
|
||||
fun deleteWidget(id: Int) {
|
||||
editor.remove("widget:$id")
|
||||
editor.commit()
|
||||
}
|
||||
|
||||
fun updateWidget(id: Int, noteId: Long) {
|
||||
editor.putLong("widget:$id", noteId)
|
||||
editor.commit()
|
||||
}
|
||||
|
||||
fun getUpdatableWidgets(noteIds: LongArray): List<Pair<Int, Long>> {
|
||||
val updatableWidgets = ArrayList<Pair<Int, Long>>()
|
||||
val pairs = preferences.all
|
||||
pairs.keys.forEach { key ->
|
||||
val token = "widget:"
|
||||
if (key.startsWith(token)) {
|
||||
val end = key.substringAfter(token)
|
||||
val id = end.toIntOrNull()
|
||||
if (id != null) {
|
||||
val value = pairs[key] as? Long
|
||||
if (value != null) {
|
||||
if (noteIds.contains(value)) {
|
||||
updatableWidgets.add(Pair(id, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return updatableWidgets
|
||||
}
|
||||
|
||||
fun savePreference(info: SeekbarInfo, value: Int) {
|
||||
editor.putInt(info.key, value)
|
||||
editor.commit()
|
||||
when (info) {
|
||||
MaxItems -> maxItems = getSeekbarPref(MaxItems)
|
||||
MaxLines -> maxLines = getSeekbarPref(MaxLines)
|
||||
MaxTitle -> maxTitle = getSeekbarPref(MaxTitle)
|
||||
AutoBackupMax -> autoBackupMax = getSeekbarPref(AutoBackupMax)
|
||||
AutoBackupPeriodDays ->
|
||||
autoBackupPeriodDays.postValue(getSeekbarPref(AutoBackupPeriodDays))
|
||||
}
|
||||
}
|
||||
|
||||
fun savePreference(info: NotesSorting, sortBy: String, sortDirection: SortDirection) {
|
||||
editor.putString(info.key, sortBy)
|
||||
editor.putString(info.directionKey, sortDirection.name)
|
||||
editor.commit()
|
||||
notesSorting.postValue(getNotesSorting(info))
|
||||
}
|
||||
|
||||
fun savePreference(info: ListInfo, value: String) {
|
||||
editor.putString(info.key, value)
|
||||
editor.commit()
|
||||
when (info) {
|
||||
View -> view.postValue(getListPref(info))
|
||||
Theme -> theme.postValue(getListPref(info))
|
||||
DateFormat -> dateFormat.postValue(getListPref(info))
|
||||
TextSize -> textSize.postValue(getListPref(info))
|
||||
ListItemSorting -> listItemSorting.postValue(getListPref(info))
|
||||
BiometricLock -> biometricLock.postValue(getListPref(info))
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
fun savePreference(info: TextInfo, value: String) {
|
||||
val editor = if (info is BackupPassword) encryptedPreferences!!.edit() else this.editor
|
||||
editor.putString(info.key, value)
|
||||
editor.commit()
|
||||
when (info) {
|
||||
AutoBackup -> autoBackupPath.postValue(getTextPref(info))
|
||||
BackupPassword -> backupPassword.postValue(getEncryptedTextPref(info))
|
||||
}
|
||||
}
|
||||
|
||||
fun showDateCreated(): Boolean {
|
||||
return dateFormat.value != DateFormat.none
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@Volatile private var instance: Preferences? = null
|
||||
|
||||
fun getInstance(app: Application): Preferences {
|
||||
return instance
|
||||
?: synchronized(this) {
|
||||
val instance = Preferences(app)
|
||||
Companion.instance = instance
|
||||
return instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,222 +0,0 @@
|
|||
package com.philkes.notallyx.data
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.presentation.getFileName
|
||||
import com.philkes.notallyx.presentation.viewmodel.NotallyModel.FileType
|
||||
import com.philkes.notallyx.utils.FileError
|
||||
import com.philkes.notallyx.utils.IO.copyToFile
|
||||
import com.philkes.notallyx.utils.IO.getExternalAudioDirectory
|
||||
import com.philkes.notallyx.utils.IO.getExternalFilesDirectory
|
||||
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.utils.IO.rename
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DataUtil {
|
||||
companion object {
|
||||
|
||||
suspend fun addFile(
|
||||
app: Application,
|
||||
uri: Uri,
|
||||
directory: File,
|
||||
fileType: FileType,
|
||||
errorWhileRenaming: Int = R.string.error_while_renaming_file,
|
||||
proposedMimeType: String? = null,
|
||||
): Pair<FileAttachment?, FileError?> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val document = requireNotNull(DocumentFile.fromSingleUri(app, uri))
|
||||
val displayName = document.name ?: app.getString(R.string.unknown_name)
|
||||
try {
|
||||
|
||||
/*
|
||||
If we have reached this point, an SD card (emulated or real) exists and externalRoot
|
||||
is not null. externalRoot.exists() can be false if the folder `Images` has been deleted after
|
||||
the previous line, but externalRoot itself can't be null
|
||||
*/
|
||||
val temp = File(directory, "Temp")
|
||||
|
||||
val inputStream = requireNotNull(app.contentResolver.openInputStream(uri))
|
||||
inputStream.copyToFile(temp)
|
||||
|
||||
val originalName = app.getFileName(uri)
|
||||
when (fileType) {
|
||||
FileType.IMAGE -> {
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeFile(temp.path, options)
|
||||
val mimeType = options.outMimeType ?: proposedMimeType
|
||||
|
||||
if (mimeType != null) {
|
||||
val extension = getExtensionForMimeType(mimeType)
|
||||
if (extension != null) {
|
||||
val name = "${UUID.randomUUID()}.$extension"
|
||||
if (temp.rename(name)) {
|
||||
return@withContext Pair(
|
||||
FileAttachment(name, originalName ?: name, mimeType),
|
||||
null,
|
||||
)
|
||||
} else {
|
||||
// I don't expect this error to ever happen but just in
|
||||
// case
|
||||
return@withContext Pair(
|
||||
null,
|
||||
FileError(
|
||||
displayName,
|
||||
app.getString(errorWhileRenaming),
|
||||
fileType,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else
|
||||
return@withContext Pair(
|
||||
null,
|
||||
FileError(
|
||||
displayName,
|
||||
app.getString(R.string.image_format_not_supported),
|
||||
fileType,
|
||||
),
|
||||
)
|
||||
} else
|
||||
return@withContext Pair(
|
||||
null,
|
||||
FileError(
|
||||
displayName,
|
||||
app.getString(R.string.invalid_image),
|
||||
fileType,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
FileType.ANY -> {
|
||||
val (mimeType, fileExtension) =
|
||||
determineMimeTypeAndExtension(
|
||||
proposedMimeType,
|
||||
uri,
|
||||
app.contentResolver,
|
||||
)
|
||||
val name = "${UUID.randomUUID()}${fileExtension}"
|
||||
if (temp.rename(name)) {
|
||||
return@withContext Pair(
|
||||
FileAttachment(name, originalName ?: name, mimeType),
|
||||
null,
|
||||
)
|
||||
} else {
|
||||
// I don't expect this error to ever happen but just in case
|
||||
return@withContext Pair(
|
||||
null,
|
||||
FileError(
|
||||
displayName,
|
||||
app.getString(errorWhileRenaming),
|
||||
fileType,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Operations.log(app, exception)
|
||||
return@withContext Pair(
|
||||
null,
|
||||
FileError(displayName, app.getString(R.string.unknown_error), fileType),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineMimeTypeAndExtension(
|
||||
proposedMimeType: String?,
|
||||
uri: Uri,
|
||||
contentResolver: ContentResolver,
|
||||
) =
|
||||
if (proposedMimeType != null && proposedMimeType.contains("/")) {
|
||||
Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}")
|
||||
} else {
|
||||
val actualMimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
Pair(
|
||||
actualMimeType,
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(actualMimeType)?.let {
|
||||
".${it}"
|
||||
} ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addFile(
|
||||
app: Application,
|
||||
uri: Uri,
|
||||
proposedMimeType: String? = null,
|
||||
): Pair<FileAttachment?, FileError?> {
|
||||
val filesRoot = app.getExternalFilesDirectory()
|
||||
requireNotNull(filesRoot) { "filesRoot is null" }
|
||||
return addFile(app, uri, filesRoot, FileType.ANY, proposedMimeType = proposedMimeType)
|
||||
}
|
||||
|
||||
suspend fun addImage(
|
||||
app: Application,
|
||||
uri: Uri,
|
||||
proposedMimeType: String? = null,
|
||||
): Pair<FileAttachment?, FileError?> {
|
||||
val imagesRoot = app.getExternalImagesDirectory()
|
||||
requireNotNull(imagesRoot) { "imagesRoot is null" }
|
||||
return addFile(
|
||||
app,
|
||||
uri,
|
||||
imagesRoot,
|
||||
FileType.IMAGE,
|
||||
proposedMimeType = proposedMimeType,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addAudio(app: Application, original: File, deleteOriginalFile: Boolean): Audio {
|
||||
return withContext(Dispatchers.IO) {
|
||||
/*
|
||||
Regenerate because the directory may have been deleted between the time of activity creation
|
||||
and audio recording
|
||||
*/
|
||||
val audioRoot = app.getExternalAudioDirectory()
|
||||
requireNotNull(audioRoot) { "audioRoot is null" }
|
||||
|
||||
/*
|
||||
If we have reached this point, an SD card (emulated or real) exists and audioRoot
|
||||
is not null. audioRoot.exists() can be false if the folder `Audio` has been deleted after
|
||||
the previous line, but audioRoot itself can't be null
|
||||
*/
|
||||
val name = "${UUID.randomUUID()}.m4a"
|
||||
val final = File(audioRoot, name)
|
||||
val input = FileInputStream(original)
|
||||
input.copyToFile(final)
|
||||
|
||||
if (deleteOriginalFile) {
|
||||
original.delete()
|
||||
}
|
||||
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(final.path)
|
||||
val duration =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
Audio(name, duration?.toLong(), System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getExtensionForMimeType(type: String): String? {
|
||||
return when (type) {
|
||||
"image/png" -> "png"
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/webp" -> "webp"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
package com.philkes.notallyx.data
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
|
@ -10,21 +12,28 @@ import androidx.room.TypeConverters
|
|||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.philkes.notallyx.Preferences
|
||||
import com.philkes.notallyx.data.dao.BaseNoteDao
|
||||
import com.philkes.notallyx.data.dao.CommonDao
|
||||
import com.philkes.notallyx.data.dao.LabelDao
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.Converters
|
||||
import com.philkes.notallyx.data.model.Label
|
||||
import com.philkes.notallyx.presentation.observeForeverSkipFirst
|
||||
import com.philkes.notallyx.presentation.view.misc.BetterLiveData
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.toColorString
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.observeForeverSkipFirst
|
||||
import com.philkes.notallyx.utils.getExternalMediaDirectory
|
||||
import com.philkes.notallyx.utils.security.SQLCipherUtils
|
||||
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
|
||||
import java.io.File
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import net.sqlcipher.database.SupportFactory
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
@Database(entities = [BaseNote::class, Label::class], version = 6)
|
||||
@Database(entities = [BaseNote::class, Label::class], version = 9)
|
||||
abstract class NotallyDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getLabelDao(): LabelDao
|
||||
|
@ -37,58 +46,162 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
getBaseNoteDao().query(SimpleSQLiteQuery("pragma wal_checkpoint(FULL)"))
|
||||
}
|
||||
|
||||
private var observer: Observer<String>? = null
|
||||
private var biometricLockObserver: Observer<BiometricLock>? = null
|
||||
private var dataInPublicFolderObserver: Observer<Boolean>? = null
|
||||
|
||||
companion object {
|
||||
|
||||
const val DatabaseName = "NotallyDatabase"
|
||||
const val DATABASE_NAME = "NotallyDatabase"
|
||||
|
||||
@Volatile private var instance: BetterLiveData<NotallyDatabase>? = null
|
||||
@Volatile private var instance: NotNullLiveData<NotallyDatabase>? = null
|
||||
|
||||
fun getDatabase(app: Application): BetterLiveData<NotallyDatabase> {
|
||||
fun getCurrentDatabaseFile(context: ContextWrapper): File {
|
||||
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
|
||||
getExternalDatabaseFile(context)
|
||||
} else {
|
||||
getInternalDatabaseFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getExternalDatabaseFile(context: ContextWrapper): File {
|
||||
return File(context.getExternalMediaDirectory(), DATABASE_NAME)
|
||||
}
|
||||
|
||||
fun getExternalDatabaseFiles(context: ContextWrapper): List<File> {
|
||||
return listOf(
|
||||
File(context.getExternalMediaDirectory(), DATABASE_NAME),
|
||||
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-shm"),
|
||||
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-wal"),
|
||||
)
|
||||
}
|
||||
|
||||
fun getInternalDatabaseFile(context: Context): File {
|
||||
return context.getDatabasePath(DATABASE_NAME)
|
||||
}
|
||||
|
||||
fun getInternalDatabaseFiles(context: ContextWrapper): List<File> {
|
||||
val directory = context.getDatabasePath(DATABASE_NAME).parentFile
|
||||
return listOf(
|
||||
File(directory, DATABASE_NAME),
|
||||
File(directory, "$DATABASE_NAME-shm"),
|
||||
File(directory, "$DATABASE_NAME-wal"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCurrentDatabaseName(context: ContextWrapper): String {
|
||||
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
|
||||
getExternalDatabaseFile(context).absolutePath
|
||||
} else {
|
||||
DATABASE_NAME
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(
|
||||
context: ContextWrapper,
|
||||
observePreferences: Boolean = true,
|
||||
): NotNullLiveData<NotallyDatabase> {
|
||||
return instance
|
||||
?: synchronized(this) {
|
||||
val preferences = Preferences.getInstance(app)
|
||||
val preferences = NotallyXPreferences.getInstance(context)
|
||||
this.instance =
|
||||
BetterLiveData(
|
||||
createInstance(app, preferences, preferences.biometricLock.value)
|
||||
)
|
||||
NotNullLiveData(createInstance(context, preferences, observePreferences))
|
||||
return this.instance!!
|
||||
}
|
||||
}
|
||||
|
||||
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
|
||||
return createInstance(context, NotallyXPreferences.getInstance(context), false)
|
||||
}
|
||||
|
||||
private fun createInstance(
|
||||
app: Application,
|
||||
preferences: Preferences,
|
||||
biometrickLock: String,
|
||||
context: ContextWrapper,
|
||||
preferences: NotallyXPreferences,
|
||||
observePreferences: Boolean,
|
||||
): NotallyDatabase {
|
||||
val instanceBuilder =
|
||||
Room.databaseBuilder(app, NotallyDatabase::class.java, DatabaseName)
|
||||
.addMigrations(Migration2, Migration3, Migration4, Migration5, Migration6)
|
||||
Room.databaseBuilder(
|
||||
context,
|
||||
NotallyDatabase::class.java,
|
||||
getCurrentDatabaseName(context),
|
||||
)
|
||||
.addMigrations(
|
||||
Migration2,
|
||||
Migration3,
|
||||
Migration4,
|
||||
Migration5,
|
||||
Migration6,
|
||||
Migration7,
|
||||
Migration8,
|
||||
Migration9,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (biometrickLock == enabled) {
|
||||
val initializationVector = preferences.iv!!
|
||||
val cipher = getInitializedCipherForDecryption(iv = initializationVector)
|
||||
val encryptedPassphrase = preferences.getDatabasePassphrase()
|
||||
val passphrase = cipher.doFinal(encryptedPassphrase)
|
||||
val factory = SupportFactory(passphrase)
|
||||
instanceBuilder.openHelperFactory(factory)
|
||||
SQLiteDatabase.loadLibs(context)
|
||||
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
|
||||
if (
|
||||
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
|
||||
SQLCipherUtils.State.ENCRYPTED
|
||||
) {
|
||||
initializeDecryption(preferences, instanceBuilder)
|
||||
} else {
|
||||
preferences.biometricLock.save(BiometricLock.DISABLED)
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
|
||||
SQLCipherUtils.State.ENCRYPTED
|
||||
) {
|
||||
preferences.biometricLock.save(BiometricLock.ENABLED)
|
||||
initializeDecryption(preferences, instanceBuilder)
|
||||
}
|
||||
}
|
||||
val instance = instanceBuilder.build()
|
||||
instance.observer = Observer { newBiometrickLock ->
|
||||
NotallyDatabase.instance?.value?.observer?.let {
|
||||
if (observePreferences) {
|
||||
instance.biometricLockObserver = Observer {
|
||||
NotallyDatabase.instance?.value?.biometricLockObserver?.let {
|
||||
preferences.biometricLock.removeObserver(it)
|
||||
}
|
||||
val newInstance = createInstance(app, preferences, newBiometrickLock)
|
||||
val newInstance = createInstance(context, preferences, true)
|
||||
NotallyDatabase.instance?.postValue(newInstance)
|
||||
preferences.biometricLock.observeForeverSkipFirst(newInstance.observer!!)
|
||||
preferences.biometricLock.observeForeverSkipFirst(
|
||||
newInstance.biometricLockObserver!!
|
||||
)
|
||||
}
|
||||
preferences.biometricLock.observeForeverSkipFirst(
|
||||
instance.biometricLockObserver!!
|
||||
)
|
||||
|
||||
instance.dataInPublicFolderObserver = Observer {
|
||||
NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let {
|
||||
preferences.dataInPublicFolder.removeObserver(it)
|
||||
}
|
||||
val newInstance = createInstance(context, preferences, true)
|
||||
NotallyDatabase.instance?.postValue(newInstance)
|
||||
preferences.dataInPublicFolder.observeForeverSkipFirst(
|
||||
newInstance.dataInPublicFolderObserver!!
|
||||
)
|
||||
}
|
||||
preferences.dataInPublicFolder.observeForeverSkipFirst(
|
||||
instance.dataInPublicFolderObserver!!
|
||||
)
|
||||
}
|
||||
preferences.biometricLock.observeForeverSkipFirst(instance.observer!!)
|
||||
return instance
|
||||
}
|
||||
return instanceBuilder.build()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initializeDecryption(
|
||||
preferences: NotallyXPreferences,
|
||||
instanceBuilder: Builder<NotallyDatabase>,
|
||||
) {
|
||||
val initializationVector = preferences.iv.value!!
|
||||
val cipher = getInitializedCipherForDecryption(iv = initializationVector)
|
||||
val encryptedPassphrase = preferences.databaseEncryptionKey.value
|
||||
val passphrase = cipher.doFinal(encryptedPassphrase)
|
||||
val factory = SupportFactory(passphrase)
|
||||
instanceBuilder.openHelperFactory(factory)
|
||||
}
|
||||
|
||||
object Migration2 : Migration(1, 2) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
|
@ -127,5 +240,37 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
object Migration7 : Migration(6, 7) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE `BaseNote` ADD COLUMN `reminders` TEXT NOT NULL DEFAULT `[]`"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object Migration8 : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val cursor = db.query("SELECT id, color FROM BaseNote")
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
|
||||
val colorString = cursor.getString(cursor.getColumnIndexOrThrow("color"))
|
||||
val color = Color.valueOfOrDefault(colorString)
|
||||
val hexColor = color.toColorString()
|
||||
db.execSQL("UPDATE BaseNote SET color = ? WHERE id = ?", arrayOf(hexColor, id))
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
object Migration9 : Migration(8, 9) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE `BaseNote` ADD COLUMN `viewMode` TEXT NOT NULL DEFAULT '${NoteViewMode.EDIT.name}'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,11 +11,21 @@ import androidx.room.Update
|
|||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.LabelsInBaseNote
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.Reminder
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
|
||||
data class NoteIdReminder(val id: Long, val reminders: List<Reminder>)
|
||||
|
||||
data class NoteReminder(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val type: Type,
|
||||
val reminders: List<Reminder>,
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface BaseNoteDao {
|
||||
|
@ -28,6 +38,8 @@ interface BaseNoteDao {
|
|||
|
||||
@Update(entity = BaseNote::class) suspend fun update(labelsInBaseNotes: List<LabelsInBaseNote>)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM BaseNote") fun count(): Int
|
||||
|
||||
@Query("DELETE FROM BaseNote WHERE id = :id") suspend fun delete(id: Long)
|
||||
|
||||
@Query("DELETE FROM BaseNote WHERE id IN (:ids)") suspend fun delete(ids: LongArray)
|
||||
|
@ -58,6 +70,16 @@ interface BaseNoteDao {
|
|||
|
||||
@Query("SELECT audios FROM BaseNote") fun getAllAudios(): List<String>
|
||||
|
||||
@Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
|
||||
suspend fun getAllReminders(): List<NoteIdReminder>
|
||||
|
||||
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
|
||||
|
||||
@Query(
|
||||
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'"
|
||||
)
|
||||
fun getAllRemindersAsync(): LiveData<List<NoteReminder>>
|
||||
|
||||
@Query("SELECT id FROM BaseNote WHERE folder = 'DELETED'")
|
||||
suspend fun getDeletedNoteIds(): LongArray
|
||||
|
||||
|
@ -73,8 +95,15 @@ interface BaseNoteDao {
|
|||
@Query("UPDATE BaseNote SET folder = :folder WHERE id IN (:ids)")
|
||||
suspend fun move(ids: LongArray, folder: Folder)
|
||||
|
||||
@Query("SELECT DISTINCT color FROM BaseNote") fun getAllColorsAsync(): LiveData<List<String>>
|
||||
|
||||
@Query("SELECT DISTINCT color FROM BaseNote") suspend fun getAllColors(): List<String>
|
||||
|
||||
@Query("UPDATE BaseNote SET color = :color WHERE id IN (:ids)")
|
||||
suspend fun updateColor(ids: LongArray, color: Color)
|
||||
suspend fun updateColor(ids: LongArray, color: String)
|
||||
|
||||
@Query("UPDATE BaseNote SET color = :newColor WHERE color = :oldColor")
|
||||
suspend fun updateColor(oldColor: String, newColor: String)
|
||||
|
||||
@Query("UPDATE BaseNote SET pinned = :pinned WHERE id IN (:ids)")
|
||||
suspend fun updatePinned(ids: LongArray, pinned: Boolean)
|
||||
|
@ -82,6 +111,9 @@ interface BaseNoteDao {
|
|||
@Query("UPDATE BaseNote SET labels = :labels WHERE id = :id")
|
||||
suspend fun updateLabels(id: Long, labels: List<String>)
|
||||
|
||||
@Query("UPDATE BaseNote SET labels = :labels WHERE id IN (:ids)")
|
||||
suspend fun updateLabels(ids: LongArray, labels: List<String>)
|
||||
|
||||
@Query("UPDATE BaseNote SET items = :items WHERE id = :id")
|
||||
suspend fun updateItems(id: Long, items: List<ListItem>)
|
||||
|
||||
|
@ -94,6 +126,9 @@ interface BaseNoteDao {
|
|||
@Query("UPDATE BaseNote SET audios = :audios WHERE id = :id")
|
||||
suspend fun updateAudios(id: Long, audios: List<Audio>)
|
||||
|
||||
@Query("UPDATE BaseNote SET reminders = :reminders WHERE id = :id")
|
||||
suspend fun updateReminders(id: Long, reminders: List<Reminder>)
|
||||
|
||||
/**
|
||||
* Both id and position can be invalid.
|
||||
*
|
||||
|
@ -128,14 +163,19 @@ interface BaseNoteDao {
|
|||
* directly on the LiveData to filter the results accordingly.
|
||||
*/
|
||||
fun getBaseNotesByLabel(label: String): LiveData<List<BaseNote>> {
|
||||
val result = getBaseNotesByLabel(label, Folder.NOTES)
|
||||
val result = getBaseNotesByLabel(label, setOf(Folder.NOTES, Folder.ARCHIVED))
|
||||
return result.map { list -> list.filter { baseNote -> baseNote.labels.contains(label) } }
|
||||
}
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' ORDER BY pinned DESC, timestamp DESC"
|
||||
"SELECT * FROM BaseNote WHERE folder IN (:folders) AND labels LIKE '%' || :label || '%' ORDER BY folder DESC, pinned DESC, timestamp DESC"
|
||||
)
|
||||
fun getBaseNotesByLabel(label: String, folder: Folder): LiveData<List<BaseNote>>
|
||||
fun getBaseNotesByLabel(label: String, folders: Collection<Folder>): LiveData<List<BaseNote>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' ORDER BY pinned DESC, timestamp DESC"
|
||||
)
|
||||
fun getBaseNotesWithoutLabel(folder: Folder): LiveData<List<BaseNote>>
|
||||
|
||||
suspend fun getListOfBaseNotesByLabel(label: String): List<BaseNote> {
|
||||
val result = getListOfBaseNotesByLabelImpl(label)
|
||||
|
@ -145,16 +185,42 @@ interface BaseNoteDao {
|
|||
@Query("SELECT * FROM BaseNote WHERE labels LIKE '%' || :label || '%'")
|
||||
suspend fun getListOfBaseNotesByLabelImpl(label: String): List<BaseNote>
|
||||
|
||||
fun getBaseNotesByKeyword(keyword: String, folder: Folder): LiveData<List<BaseNote>> {
|
||||
val result = getBaseNotesByKeywordImpl(keyword, folder)
|
||||
fun getBaseNotesByKeyword(
|
||||
keyword: String,
|
||||
folder: Folder,
|
||||
label: String?,
|
||||
): LiveData<List<BaseNote>> {
|
||||
val result =
|
||||
when (label) {
|
||||
null -> getBaseNotesByKeywordUnlabeledImpl(keyword, folder)
|
||||
"" -> getBaseNotesByKeywordImpl(keyword, folder)
|
||||
else -> getBaseNotesByKeywordImpl(keyword, folder, label)
|
||||
}
|
||||
return result.map { list -> list.filter { baseNote -> matchesKeyword(baseNote, keyword) } }
|
||||
}
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
|
||||
)
|
||||
fun getBaseNotesByKeywordImpl(
|
||||
keyword: String,
|
||||
folder: Folder,
|
||||
label: String,
|
||||
): LiveData<List<BaseNote>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM BaseNote WHERE folder = :folder AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%' OR labels LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
|
||||
)
|
||||
fun getBaseNotesByKeywordImpl(keyword: String, folder: Folder): LiveData<List<BaseNote>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
|
||||
)
|
||||
fun getBaseNotesByKeywordUnlabeledImpl(
|
||||
keyword: String,
|
||||
folder: Folder,
|
||||
): LiveData<List<BaseNote>>
|
||||
|
||||
private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean {
|
||||
if (baseNote.title.contains(keyword, true)) {
|
||||
return true
|
||||
|
|
|
@ -16,10 +16,15 @@ interface LabelDao {
|
|||
|
||||
@Query("DELETE FROM Label WHERE value = :value") suspend fun delete(value: String)
|
||||
|
||||
@Query("DELETE FROM Label") suspend fun deleteAll()
|
||||
|
||||
@Query("UPDATE Label SET value = :newValue WHERE value = :oldValue")
|
||||
suspend fun update(oldValue: String, newValue: String)
|
||||
|
||||
@Query("SELECT value FROM Label ORDER BY value") fun getAll(): LiveData<List<String>>
|
||||
|
||||
@Query("SELECT value FROM Label ORDER BY value") suspend fun getArrayOfAll(): Array<String>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM Label WHERE value = :value)")
|
||||
suspend fun exists(value: String): Boolean
|
||||
}
|
||||
|
|
|
@ -11,12 +11,13 @@ interface ExternalImporter {
|
|||
/**
|
||||
* Parses [BaseNote]s from [source] and copies attached files/images/audios to [destination]
|
||||
*
|
||||
* @return List of [BaseNote]s to import + folder containing attached files
|
||||
* @return List of [BaseNote]s to import + folder containing attached files (if no attached
|
||||
* files possible, return null).
|
||||
*/
|
||||
fun import(
|
||||
app: Application,
|
||||
source: Uri,
|
||||
destination: File,
|
||||
progress: MutableLiveData<ImportProgress>? = null,
|
||||
): Pair<List<BaseNote>, File>
|
||||
): Pair<List<BaseNote>, File?>
|
||||
}
|
||||
|
|
|
@ -6,14 +6,19 @@ import android.util.Log
|
|||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.DataUtil
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
|
||||
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter
|
||||
import com.philkes.notallyx.data.imports.txt.JsonImporter
|
||||
import com.philkes.notallyx.data.imports.txt.PlainTextImporter
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.Label
|
||||
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
|
||||
import com.philkes.notallyx.utils.backup.importAudio
|
||||
import com.philkes.notallyx.utils.backup.importFile
|
||||
import com.philkes.notallyx.utils.backup.importImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
|
@ -34,6 +39,8 @@ class NotesImporter(private val app: Application, private val database: NotallyD
|
|||
when (importSource) {
|
||||
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
|
||||
ImportSource.EVERNOTE -> EvernoteImporter()
|
||||
ImportSource.PLAIN_TEXT -> PlainTextImporter()
|
||||
ImportSource.JSON -> JsonImporter()
|
||||
}.import(app, uri, tempDir, progress)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import: failed", e)
|
||||
|
@ -49,23 +56,11 @@ class NotesImporter(private val app: Application, private val database: NotallyD
|
|||
progress?.postValue(
|
||||
ImportProgress(total = totalFiles, stage = ImportStage.IMPORT_FILES)
|
||||
)
|
||||
importFiles(
|
||||
files,
|
||||
importDataFolder,
|
||||
NotallyModel.FileType.ANY,
|
||||
progress,
|
||||
totalFiles,
|
||||
counter,
|
||||
)
|
||||
importFiles(
|
||||
images,
|
||||
importDataFolder,
|
||||
NotallyModel.FileType.IMAGE,
|
||||
progress,
|
||||
totalFiles,
|
||||
counter,
|
||||
)
|
||||
importAudios(audios, importDataFolder, progress, totalFiles, counter)
|
||||
importDataFolder?.let {
|
||||
importFiles(files, it, NotallyModel.FileType.ANY, progress, totalFiles, counter)
|
||||
importFiles(images, it, NotallyModel.FileType.IMAGE, progress, totalFiles, counter)
|
||||
importAudios(audios, it, progress, totalFiles, counter)
|
||||
}
|
||||
database.getBaseNoteDao().insert(notes)
|
||||
progress?.postValue(ImportProgress(inProgress = false))
|
||||
return notes.size
|
||||
|
@ -85,9 +80,8 @@ class NotesImporter(private val app: Application, private val database: NotallyD
|
|||
files.forEach { file ->
|
||||
val uri = File(sourceFolder, file.localName).toUri()
|
||||
val (fileAttachment, error) =
|
||||
if (fileType == NotallyModel.FileType.IMAGE)
|
||||
DataUtil.addImage(app, uri, file.mimeType)
|
||||
else DataUtil.addFile(app, uri, file.mimeType)
|
||||
if (fileType == NotallyModel.FileType.IMAGE) app.importImage(uri, file.mimeType)
|
||||
else app.importFile(uri, file.mimeType)
|
||||
fileAttachment?.let {
|
||||
file.localName = fileAttachment.localName
|
||||
file.originalName = fileAttachment.originalName
|
||||
|
@ -113,7 +107,7 @@ class NotesImporter(private val app: Application, private val database: NotallyD
|
|||
) {
|
||||
audios.forEach { originalAudio ->
|
||||
val file = File(sourceFolder, originalAudio.name)
|
||||
val audio = DataUtil.addAudio(app, file, false)
|
||||
val audio = app.importAudio(file, false)
|
||||
originalAudio.name = audio.name
|
||||
originalAudio.duration = if (audio.duration == 0L) null else audio.duration
|
||||
originalAudio.timestamp = audio.timestamp
|
||||
|
@ -137,12 +131,12 @@ enum class ImportSource(
|
|||
val displayNameResId: Int,
|
||||
val mimeType: String,
|
||||
val helpTextResId: Int,
|
||||
val documentationUrl: String,
|
||||
val documentationUrl: String?,
|
||||
val iconResId: Int,
|
||||
) {
|
||||
GOOGLE_KEEP(
|
||||
R.string.google_keep,
|
||||
"application/zip",
|
||||
MIME_TYPE_ZIP,
|
||||
R.string.google_keep_help,
|
||||
"https://support.google.com/keep/answer/10017039",
|
||||
R.drawable.icon_google_keep,
|
||||
|
@ -154,4 +148,20 @@ enum class ImportSource(
|
|||
"https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML",
|
||||
R.drawable.icon_evernote,
|
||||
),
|
||||
PLAIN_TEXT(
|
||||
R.string.plain_text_files,
|
||||
FOLDER_OR_FILE_MIMETYPE,
|
||||
R.string.plain_text_files_help,
|
||||
null,
|
||||
R.drawable.text_file,
|
||||
),
|
||||
JSON(
|
||||
R.string.json_files,
|
||||
FOLDER_OR_FILE_MIMETYPE,
|
||||
R.string.json_files_help,
|
||||
null,
|
||||
R.drawable.file_json,
|
||||
),
|
||||
}
|
||||
|
||||
const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"
|
||||
|
|
|
@ -14,13 +14,14 @@ import com.philkes.notallyx.data.imports.evernote.EvernoteImporter.Companion.par
|
|||
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.utils.IO.write
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import com.philkes.notallyx.utils.log
|
||||
import com.philkes.notallyx.utils.startsWithAnyOf
|
||||
import com.philkes.notallyx.utils.write
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
|
@ -91,7 +92,7 @@ class EvernoteImporter : ExternalImporter {
|
|||
val data = Base64.decode(it.data!!.content.trimStart(), Base64.DEFAULT)
|
||||
file.write(data)
|
||||
} catch (e: Exception) {
|
||||
Operations.log(app, e)
|
||||
app.log(TAG, throwable = e)
|
||||
}
|
||||
progress?.postValue(
|
||||
ImportProgress(
|
||||
|
@ -104,6 +105,8 @@ class EvernoteImporter : ExternalImporter {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EvernoteImporter"
|
||||
|
||||
fun parseTimestamp(timestamp: String): Long {
|
||||
val format = SimpleDateFormat(EVERNOTE_DATE_FORMAT, Locale.getDefault())
|
||||
format.timeZone = TimeZone.getTimeZone("UTC")
|
||||
|
@ -140,7 +143,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
|
|||
type = if (tasks.isEmpty()) Type.NOTE else Type.LIST,
|
||||
folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not
|
||||
// exported
|
||||
color = Color.DEFAULT, // TODO: possible in Evernote?
|
||||
color = BaseNote.COLOR_DEFAULT, // TODO: possible in Evernote?
|
||||
title = title,
|
||||
pinned = false, // not exported from Evernote
|
||||
timestamp = parseTimestamp(created),
|
||||
|
@ -152,6 +155,8 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
|
|||
images = images,
|
||||
files = files,
|
||||
audios = audios,
|
||||
reminders = mutableListOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -167,11 +172,6 @@ fun Collection<EvernoteResource>.filterByExcludedMimeTypePrefixes(
|
|||
return filter { !it.mime.startsWithAnyOf(*mimeTypePrefix) }
|
||||
}
|
||||
|
||||
private fun String.startsWithAnyOf(vararg s: String): Boolean {
|
||||
s.forEach { if (startsWith(it)) return true }
|
||||
return false
|
||||
}
|
||||
|
||||
fun Collection<EvernoteResource>.toFileAttachments(): List<FileAttachment> {
|
||||
return map { FileAttachment(it.attributes!!.fileName, it.attributes.fileName, it.mime) }
|
||||
}
|
||||
|
|
|
@ -11,11 +11,13 @@ import com.philkes.notallyx.data.imports.ImportStage
|
|||
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.utils.listFilesRecursive
|
||||
import com.philkes.notallyx.utils.log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
@ -43,7 +45,7 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES))
|
||||
val dataFolder =
|
||||
try {
|
||||
unzip(destination, app.contentResolver.openInputStream(source)!!)
|
||||
app.contentResolver.openInputStream(source)!!.use { unzip(destination, it) }
|
||||
} catch (e: Exception) {
|
||||
throw ImportException(R.string.invalid_google_keep, e)
|
||||
}
|
||||
|
@ -57,17 +59,29 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
|
||||
val noteFiles =
|
||||
dataFolder
|
||||
.listFiles { file ->
|
||||
.listFilesRecursive { file ->
|
||||
file.isFile && file.extension.equals("json", ignoreCase = true)
|
||||
}
|
||||
?.toList() ?: emptyList()
|
||||
.toList()
|
||||
val total = noteFiles.size
|
||||
progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES))
|
||||
var counter = 1
|
||||
val baseNotes =
|
||||
noteFiles
|
||||
.mapNotNull { file ->
|
||||
val baseNote = file.readText().parseToBaseNote()
|
||||
val baseNote =
|
||||
try {
|
||||
val relativePath = file.parentFile!!.toRelativeString(dataFolder)
|
||||
file.readText().parseToBaseNote(relativePath)
|
||||
} catch (e: Exception) {
|
||||
app.log(
|
||||
TAG,
|
||||
msg =
|
||||
"Could not parse BaseNote from JSON in file '${file.absolutePath}'",
|
||||
throwable = e,
|
||||
)
|
||||
null
|
||||
}
|
||||
progress?.postValue(
|
||||
ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES)
|
||||
)
|
||||
|
@ -77,7 +91,7 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
return Pair(baseNotes, dataFolder)
|
||||
}
|
||||
|
||||
fun String.parseToBaseNote(): BaseNote {
|
||||
fun String.parseToBaseNote(relativePath: String? = null): BaseNote {
|
||||
val googleKeepNote = json.decodeFromString<GoogleKeepNote>(this)
|
||||
val (body, spans) =
|
||||
parseBodyAndSpansFromHtml(
|
||||
|
@ -89,15 +103,33 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
val images =
|
||||
googleKeepNote.attachments
|
||||
.filter { it.mimetype.startsWith("image") }
|
||||
.map { FileAttachment(it.filePath, it.filePath, it.mimetype) }
|
||||
.map { attachment ->
|
||||
FileAttachment(
|
||||
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
|
||||
attachment.filePath,
|
||||
attachment.mimetype,
|
||||
)
|
||||
}
|
||||
val files =
|
||||
googleKeepNote.attachments
|
||||
.filter { !it.mimetype.startsWith("audio") && !it.mimetype.startsWith("image") }
|
||||
.map { FileAttachment(it.filePath, it.filePath, it.mimetype) }
|
||||
.map { attachment ->
|
||||
FileAttachment(
|
||||
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
|
||||
attachment.filePath,
|
||||
attachment.mimetype,
|
||||
)
|
||||
}
|
||||
val audios =
|
||||
googleKeepNote.attachments
|
||||
.filter { it.mimetype.startsWith("audio") }
|
||||
.map { Audio(it.filePath, 0L, System.currentTimeMillis()) }
|
||||
.map { attachment ->
|
||||
Audio(
|
||||
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
|
||||
0L,
|
||||
System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val items =
|
||||
googleKeepNote.listContent.mapIndexed { index, item ->
|
||||
ListItem(
|
||||
|
@ -118,7 +150,7 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
googleKeepNote.isArchived -> Folder.ARCHIVED
|
||||
else -> Folder.NOTES
|
||||
},
|
||||
color = Color.DEFAULT, // Ignoring color mapping
|
||||
color = BaseNote.COLOR_DEFAULT, // Ignoring color mapping
|
||||
title = googleKeepNote.title,
|
||||
pinned = googleKeepNote.isPinned,
|
||||
timestamp = googleKeepNote.createdTimestampUsec / 1000,
|
||||
|
@ -130,6 +162,8 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
images = images,
|
||||
files = files,
|
||||
audios = audios,
|
||||
reminders = mutableListOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -163,18 +197,22 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
throw IOException("Failed to create directory $parent")
|
||||
}
|
||||
}
|
||||
val fos = FileOutputStream(newFile)
|
||||
FileOutputStream(newFile).use {
|
||||
var len: Int
|
||||
while ((zis.read(buffer).also { len = it }) > 0) {
|
||||
fos.write(buffer, 0, len)
|
||||
while ((zis.read(buffer).also { length -> len = length }) > 0) {
|
||||
it.write(buffer, 0, len)
|
||||
}
|
||||
}
|
||||
fos.close()
|
||||
}
|
||||
zipEntry = zis.nextEntry
|
||||
}
|
||||
|
||||
zis.closeEntry()
|
||||
zis.close()
|
||||
return File(destinationPath, "Takeout/Keep")
|
||||
return destinationPath
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GoogleKeepImporter"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package com.philkes.notallyx.data.imports.google
|
||||
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GoogleKeepNote(
|
||||
val attachments: List<GoogleKeepAttachment> = listOf(),
|
||||
val color: String = Color.DEFAULT.name,
|
||||
val color: String = BaseNote.COLOR_DEFAULT,
|
||||
val isTrashed: Boolean = false,
|
||||
val isArchived: Boolean = false,
|
||||
val isPinned: Boolean = false,
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package com.philkes.notallyx.data.imports.txt
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.philkes.notallyx.data.imports.ExternalImporter
|
||||
import com.philkes.notallyx.data.imports.ImportProgress
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.toBaseNote
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_JSON
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class JsonImporter : ExternalImporter {
|
||||
|
||||
override fun import(
|
||||
app: Application,
|
||||
source: Uri,
|
||||
destination: File,
|
||||
progress: MutableLiveData<ImportProgress>?,
|
||||
): Pair<List<BaseNote>, File?> {
|
||||
val notes = mutableListOf<BaseNote>()
|
||||
fun readJsonFiles(file: DocumentFile) {
|
||||
when {
|
||||
file.isDirectory -> {
|
||||
file.listFiles().forEach { readJsonFiles(it) }
|
||||
}
|
||||
file.isFile -> {
|
||||
if (file.type != MIME_TYPE_JSON) {
|
||||
return
|
||||
}
|
||||
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
|
||||
val content =
|
||||
app.contentResolver.openInputStream(file.uri)?.use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
reader.readText()
|
||||
}
|
||||
} ?: ""
|
||||
notes.add(content.toBaseNote().copy(id = 0L, title = fileNameWithoutExtension))
|
||||
}
|
||||
}
|
||||
}
|
||||
val file =
|
||||
if (source.pathSegments.firstOrNull() == "tree") {
|
||||
DocumentFile.fromTreeUri(app, source)
|
||||
} else DocumentFile.fromSingleUri(app, source)
|
||||
file?.let { readJsonFiles(it) }
|
||||
return Pair(notes, null)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package com.philkes.notallyx.data.imports.txt
|
||||
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
|
||||
fun CharSequence.extractListItems(regex: Regex): List<ListItem> {
|
||||
return regex
|
||||
.findAll(this)
|
||||
.mapIndexedNotNull { idx, matchResult ->
|
||||
val isChild = matchResult.groupValues[1] != ""
|
||||
val isChecked = matchResult.groupValues[2] != ""
|
||||
val itemText = matchResult.groupValues[3]
|
||||
if (itemText.isNotBlank()) {
|
||||
ListItem(itemText.trimStart(), isChecked, isChild, idx, mutableListOf())
|
||||
} else null
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun CharSequence.findListSyntaxRegex(
|
||||
checkContains: Boolean = false,
|
||||
plainNewLineAllowed: Boolean = false,
|
||||
): Regex? {
|
||||
val checkCallback: (String) -> Boolean =
|
||||
if (checkContains) {
|
||||
{ string -> startsWith(string) || contains(string, ignoreCase = true) }
|
||||
} else {
|
||||
{ string -> startsWith(string) }
|
||||
}
|
||||
if (checkCallback("- [ ]") || checkCallback("- [x]")) {
|
||||
return "\n?(\\s*)-? ?\\[? ?([xX]?)\\]?(.*)".toRegex()
|
||||
}
|
||||
if (checkCallback("[ ]") || checkCallback("[✓]")) {
|
||||
return "\n?(\\s*)\\[? ?(✓?)\\]?(.*)".toRegex()
|
||||
}
|
||||
if (checkCallback("-") || checkCallback("*")) {
|
||||
return "\n?(\\s*)[-*]?\\s*()(.*)".toRegex()
|
||||
}
|
||||
if (plainNewLineAllowed && contains("\n")) {
|
||||
return "\n?(\\s*)()(.*)".toRegex()
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package com.philkes.notallyx.data.imports.txt
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.philkes.notallyx.data.imports.ExternalImporter
|
||||
import com.philkes.notallyx.data.imports.ImportProgress
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_JSON
|
||||
import com.philkes.notallyx.utils.readFileContents
|
||||
import java.io.File
|
||||
|
||||
class PlainTextImporter : ExternalImporter {
|
||||
|
||||
override fun import(
|
||||
app: Application,
|
||||
source: Uri,
|
||||
destination: File,
|
||||
progress: MutableLiveData<ImportProgress>?,
|
||||
): Pair<List<BaseNote>, File?> {
|
||||
val notes = mutableListOf<BaseNote>()
|
||||
fun readTxtFiles(file: DocumentFile) {
|
||||
when {
|
||||
file.isDirectory -> {
|
||||
file.listFiles().forEach { readTxtFiles(it) }
|
||||
}
|
||||
|
||||
file.isFile -> {
|
||||
if (file.type?.isTextMimeType() == false) {
|
||||
return
|
||||
}
|
||||
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
|
||||
var content = app.contentResolver.readFileContents(file.uri)
|
||||
val listItems = mutableListOf<ListItem>()
|
||||
content.findListSyntaxRegex()?.let { listSyntaxRegex ->
|
||||
listItems.addAll(content.extractListItems(listSyntaxRegex))
|
||||
content = ""
|
||||
}
|
||||
val timestamp = System.currentTimeMillis()
|
||||
notes.add(
|
||||
BaseNote(
|
||||
id = 0L, // Auto-generated
|
||||
type = if (listItems.isEmpty()) Type.NOTE else Type.LIST,
|
||||
folder = Folder.NOTES,
|
||||
color = BaseNote.COLOR_DEFAULT,
|
||||
title = fileNameWithoutExtension,
|
||||
pinned = false,
|
||||
timestamp = timestamp,
|
||||
modifiedTimestamp = timestamp,
|
||||
labels = listOf(),
|
||||
body = content,
|
||||
spans = listOf(),
|
||||
items = listItems,
|
||||
images = listOf(),
|
||||
files = listOf(),
|
||||
audios = listOf(),
|
||||
reminders = listOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val file =
|
||||
if (source.pathSegments.firstOrNull() == "tree") {
|
||||
DocumentFile.fromTreeUri(app, source)
|
||||
} else DocumentFile.fromSingleUri(app, source)
|
||||
file?.let { readTxtFiles(it) }
|
||||
return Pair(notes, null)
|
||||
}
|
||||
|
||||
private fun String.isTextMimeType(): Boolean {
|
||||
return startsWith("text/") || this in APPLICATION_TEXT_MIME_TYPES
|
||||
}
|
||||
}
|
||||
|
||||
val APPLICATION_TEXT_MIME_TYPES =
|
||||
arrayOf(
|
||||
MIME_TYPE_JSON,
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/xhtml+xml",
|
||||
"application/yaml",
|
||||
)
|
|
@ -4,12 +4,15 @@ import androidx.room.Entity
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/** Format: `#RRGGBB` or `#AARRGGBB` or [BaseNote.COLOR_DEFAULT] */
|
||||
typealias ColorString = String
|
||||
|
||||
@Entity(indices = [Index(value = ["id", "folder", "pinned", "timestamp", "labels"])])
|
||||
data class BaseNote(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long,
|
||||
val type: Type,
|
||||
val folder: Folder,
|
||||
val color: Color,
|
||||
val color: ColorString,
|
||||
val title: String,
|
||||
val pinned: Boolean,
|
||||
val timestamp: Long,
|
||||
|
@ -21,4 +24,70 @@ data class BaseNote(
|
|||
val images: List<FileAttachment>,
|
||||
val files: List<FileAttachment>,
|
||||
val audios: List<Audio>,
|
||||
) : Item
|
||||
val reminders: List<Reminder>,
|
||||
val viewMode: NoteViewMode,
|
||||
) : Item {
|
||||
|
||||
companion object {
|
||||
const val COLOR_DEFAULT = "DEFAULT"
|
||||
const val COLOR_NEW = "NEW"
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as BaseNote
|
||||
|
||||
if (id != other.id) return false
|
||||
if (type != other.type) return false
|
||||
if (folder != other.folder) return false
|
||||
if (color != other.color) return false
|
||||
if (title != other.title) return false
|
||||
if (pinned != other.pinned) return false
|
||||
if (timestamp != other.timestamp) return false
|
||||
if (labels != other.labels) return false
|
||||
if (body != other.body) return false
|
||||
if (spans != other.spans) return false
|
||||
if (items != other.items) return false
|
||||
if (images != other.images) return false
|
||||
if (files != other.files) return false
|
||||
if (audios != other.audios) return false
|
||||
if (reminders != other.reminders) return false
|
||||
if (viewMode != other.viewMode) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + type.hashCode()
|
||||
result = 31 * result + folder.hashCode()
|
||||
result = 31 * result + color.hashCode()
|
||||
result = 31 * result + title.hashCode()
|
||||
result = 31 * result + pinned.hashCode()
|
||||
result = 31 * result + timestamp.hashCode()
|
||||
result = 31 * result + labels.hashCode()
|
||||
result = 31 * result + body.hashCode()
|
||||
result = 31 * result + spans.hashCode()
|
||||
result = 31 * result + items.hashCode()
|
||||
result = 31 * result + images.hashCode()
|
||||
result = 31 * result + files.hashCode()
|
||||
result = 31 * result + audios.hashCode()
|
||||
result = 31 * result + reminders.hashCode()
|
||||
result = 31 * result + viewMode.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.deepCopy(): BaseNote {
|
||||
return copy(
|
||||
labels = labels.toMutableList(),
|
||||
spans = spans.map { it.copy() }.toMutableList(),
|
||||
items = items.map { it.copy() }.toMutableList(),
|
||||
images = images.map { it.copy() }.toMutableList(),
|
||||
files = files.map { it.copy() }.toMutableList(),
|
||||
audios = audios.map { it.copy() }.toMutableList(),
|
||||
reminders = reminders.map { it.copy() }.toMutableList(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,5 +12,45 @@ enum class Color {
|
|||
DUSK,
|
||||
FLOWER,
|
||||
BLOSSOM,
|
||||
CLAY,
|
||||
CLAY;
|
||||
|
||||
companion object {
|
||||
fun allColorStrings() = entries.map { it.toColorString() }.toList()
|
||||
|
||||
fun valueOfOrDefault(value: String) =
|
||||
try {
|
||||
Color.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Color.toColorString() =
|
||||
when (this) {
|
||||
Color.DEFAULT -> BaseNote.COLOR_DEFAULT
|
||||
Color.CORAL -> "#FAAFA9"
|
||||
Color.ORANGE -> "#FFCC80"
|
||||
Color.SAND -> "#FFF8B9"
|
||||
Color.STORM -> "#AFCCDC"
|
||||
Color.FOG -> "#D3E4EC"
|
||||
Color.SAGE -> "#B4DED4"
|
||||
Color.MINT -> "#E2F6D3"
|
||||
Color.DUSK -> "#D3BFDB"
|
||||
Color.FLOWER -> "#F8BBD0"
|
||||
Color.BLOSSOM -> "#F5E2DC"
|
||||
Color.CLAY -> "#E9E3D3"
|
||||
}
|
||||
|
||||
fun String.parseToColorString() =
|
||||
try {
|
||||
android.graphics.Color.parseColor(this)
|
||||
this
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
val colorEnum = Color.valueOf(this)
|
||||
colorEnum.toColorString()
|
||||
} catch (e: Exception) {
|
||||
BaseNote.COLOR_DEFAULT
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.Date
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
@ -9,7 +10,9 @@ object Converters {
|
|||
|
||||
@TypeConverter fun labelsToJson(labels: List<String>) = JSONArray(labels).toString()
|
||||
|
||||
@TypeConverter fun jsonToLabels(json: String) = JSONArray(json).iterable<String>().toList()
|
||||
@TypeConverter fun jsonToLabels(json: String) = jsonToLabels(JSONArray(json))
|
||||
|
||||
fun jsonToLabels(jsonArray: JSONArray) = jsonArray.iterable<String>().toList()
|
||||
|
||||
@TypeConverter
|
||||
fun filesToJson(files: List<FileAttachment>): String {
|
||||
|
@ -23,10 +26,10 @@ object Converters {
|
|||
return JSONArray(objects).toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToFiles(json: String): List<FileAttachment> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.map { jsonObject ->
|
||||
@TypeConverter fun jsonToFiles(json: String) = jsonToFiles(JSONArray(json))
|
||||
|
||||
fun jsonToFiles(jsonArray: JSONArray): List<FileAttachment> {
|
||||
return jsonArray.iterable<JSONObject>().map { jsonObject ->
|
||||
val localName = getSafeLocalName(jsonObject)
|
||||
val originalName = getSafeOriginalName(jsonObject)
|
||||
val mimeType = jsonObject.getString("mimeType")
|
||||
|
@ -46,10 +49,10 @@ object Converters {
|
|||
return JSONArray(objects).toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToAudios(json: String): List<Audio> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.map { jsonObject ->
|
||||
@TypeConverter fun jsonToAudios(json: String) = jsonToAudios(JSONArray(json))
|
||||
|
||||
fun jsonToAudios(json: JSONArray): List<Audio> {
|
||||
return json.iterable<JSONObject>().map { jsonObject ->
|
||||
val name = jsonObject.getString("name")
|
||||
val duration = jsonObject.getSafeLong("duration")
|
||||
val timestamp = jsonObject.getLong("timestamp")
|
||||
|
@ -57,31 +60,63 @@ object Converters {
|
|||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToSpans(json: String): List<SpanRepresentation> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.map { jsonObject ->
|
||||
@TypeConverter fun jsonToSpans(json: String) = jsonToSpans(JSONArray(json))
|
||||
|
||||
fun jsonToSpans(jsonArray: JSONArray): List<SpanRepresentation> {
|
||||
return jsonArray
|
||||
.iterable<JSONObject>()
|
||||
.map { jsonObject ->
|
||||
val bold = jsonObject.getSafeBoolean("bold")
|
||||
val link = jsonObject.getSafeBoolean("link")
|
||||
val linkData = jsonObject.getSafeString("linkData")
|
||||
val italic = jsonObject.getSafeBoolean("italic")
|
||||
val monospace = jsonObject.getSafeBoolean("monospace")
|
||||
val strikethrough = jsonObject.getSafeBoolean("strikethrough")
|
||||
try {
|
||||
val start = jsonObject.getInt("start")
|
||||
val end = jsonObject.getInt("end")
|
||||
SpanRepresentation(start, end, bold, link, linkData, italic, monospace, strikethrough)
|
||||
SpanRepresentation(
|
||||
start,
|
||||
end,
|
||||
bold,
|
||||
link,
|
||||
linkData,
|
||||
italic,
|
||||
monospace,
|
||||
strikethrough,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
.filterNotNull()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun spansToJson(list: List<SpanRepresentation>) = spansToJSONArray(list).toString()
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToItems(json: String): List<ListItem> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.map { jsonObject ->
|
||||
val body = jsonObject.getString("body")
|
||||
val checked = jsonObject.getBoolean("checked")
|
||||
fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray {
|
||||
val objects =
|
||||
list.map { representation ->
|
||||
val jsonObject = JSONObject()
|
||||
jsonObject.put("bold", representation.bold)
|
||||
jsonObject.put("link", representation.link)
|
||||
jsonObject.put("linkData", representation.linkData)
|
||||
jsonObject.put("italic", representation.italic)
|
||||
jsonObject.put("monospace", representation.monospace)
|
||||
jsonObject.put("strikethrough", representation.strikethrough)
|
||||
jsonObject.put("start", representation.start)
|
||||
jsonObject.put("end", representation.end)
|
||||
}
|
||||
return JSONArray(objects)
|
||||
}
|
||||
|
||||
@TypeConverter fun jsonToItems(json: String) = jsonToItems(JSONArray(json))
|
||||
|
||||
fun jsonToItems(json: JSONArray): List<ListItem> {
|
||||
return json.iterable<JSONObject>().map { jsonObject ->
|
||||
val body = jsonObject.getSafeString("body") ?: ""
|
||||
val checked = jsonObject.getSafeBoolean("checked")
|
||||
val isChild = jsonObject.getSafeBoolean("isChild")
|
||||
val order = jsonObject.getSafeInt("order")
|
||||
ListItem(body, checked, isChild, order, mutableListOf())
|
||||
|
@ -102,22 +137,55 @@ object Converters {
|
|||
return JSONArray(objects)
|
||||
}
|
||||
|
||||
fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray {
|
||||
@TypeConverter
|
||||
fun remindersToJson(reminders: List<Reminder>) = remindersToJSONArray(reminders).toString()
|
||||
|
||||
fun remindersToJSONArray(reminders: List<Reminder>): JSONArray {
|
||||
val objects =
|
||||
list.map { representation ->
|
||||
val jsonObject = JSONObject()
|
||||
jsonObject.put("bold", representation.bold)
|
||||
jsonObject.put("link", representation.link)
|
||||
jsonObject.put("linkData", representation.linkData)
|
||||
jsonObject.put("italic", representation.italic)
|
||||
jsonObject.put("monospace", representation.monospace)
|
||||
jsonObject.put("strikethrough", representation.strikethrough)
|
||||
jsonObject.put("start", representation.start)
|
||||
jsonObject.put("end", representation.end)
|
||||
reminders.map { reminder ->
|
||||
JSONObject().apply {
|
||||
put("id", reminder.id) // Store date as long timestamp
|
||||
put("dateTime", reminder.dateTime.time) // Store date as long timestamp
|
||||
put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) })
|
||||
}
|
||||
}
|
||||
return JSONArray(objects)
|
||||
}
|
||||
|
||||
@TypeConverter fun jsonToReminders(json: String) = jsonToReminders(JSONArray(json))
|
||||
|
||||
fun jsonToReminders(jsonArray: JSONArray): List<Reminder> {
|
||||
return jsonArray.iterable<JSONObject>().map { jsonObject ->
|
||||
val id = jsonObject.getLong("id")
|
||||
val dateTime = Date(jsonObject.getLong("dateTime"))
|
||||
val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) }
|
||||
Reminder(id, dateTime, repetition)
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun repetitionToJson(repetition: Repetition): String {
|
||||
return repetitionToJsonObject(repetition).toString()
|
||||
}
|
||||
|
||||
fun repetitionToJsonObject(repetition: Repetition): JSONObject {
|
||||
val jsonObject = JSONObject()
|
||||
jsonObject.put("value", repetition.value)
|
||||
jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string
|
||||
return jsonObject
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToRepetition(json: String): Repetition {
|
||||
val jsonObject = JSONObject(json)
|
||||
val value = jsonObject.getInt("value")
|
||||
val unit =
|
||||
RepetitionTimeUnit.valueOf(
|
||||
jsonObject.getString("unit")
|
||||
) // Convert string back to TimeUnit
|
||||
return Repetition(value, unit)
|
||||
}
|
||||
|
||||
private fun getSafeLocalName(jsonObject: JSONObject): String {
|
||||
return try {
|
||||
jsonObject.getString("localName")
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
enum class Folder {
|
||||
import java.io.Serializable
|
||||
|
||||
enum class Folder : Serializable {
|
||||
NOTES,
|
||||
DELETED,
|
||||
ARCHIVED,
|
||||
ARCHIVED;
|
||||
|
||||
companion object {
|
||||
fun valueOfOrDefault(value: String) =
|
||||
try {
|
||||
valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
NOTES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ data class ListItem(
|
|||
return false
|
||||
}
|
||||
return (this.body == other.body &&
|
||||
this.order == other.order &&
|
||||
this.checked == other.checked &&
|
||||
this.isChild == other.isChild)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.areAllChecked
|
||||
|
||||
operator fun ListItem.plus(list: List<ListItem>): List<ListItem> {
|
||||
return mutableListOf(this) + list
|
||||
}
|
||||
|
@ -8,35 +10,17 @@ fun ListItem.findChild(childId: Int): ListItem? {
|
|||
return this.children.find { child -> child.id == childId }
|
||||
}
|
||||
|
||||
fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean {
|
||||
return this.none { !it.checked && it != except }
|
||||
fun ListItem.check(checked: Boolean, checkChildren: Boolean = true) {
|
||||
this.checked = checked
|
||||
if (checkChildren) {
|
||||
this.children.forEach { child -> child.checked = checked }
|
||||
}
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.containsId(id: Int): Boolean {
|
||||
return this.any { it.id == id }
|
||||
fun ListItem.shouldParentBeUnchecked(): Boolean {
|
||||
return children.isNotEmpty() && !children.areAllChecked() && checked
|
||||
}
|
||||
|
||||
fun Collection<ListItem>.toReadableString(): String {
|
||||
return map { "$it uncheckedPos: ${it.order} id: ${it.id}" }.joinToString("\n")
|
||||
}
|
||||
|
||||
fun List<ListItem>.findChildrenPositions(parentPosition: Int): List<Int> {
|
||||
val childrenPositions = mutableListOf<Int>()
|
||||
for (position in parentPosition + 1 until this.size) {
|
||||
if (this[position].isChild) {
|
||||
childrenPositions.add(position)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return childrenPositions
|
||||
}
|
||||
|
||||
fun List<ListItem>.findParentPosition(childPosition: Int): Int? {
|
||||
for (position in childPosition - 1 downTo 0) {
|
||||
if (!this[position].isChild) {
|
||||
return position
|
||||
}
|
||||
}
|
||||
return null
|
||||
fun ListItem.shouldParentBeChecked(): Boolean {
|
||||
return children.isNotEmpty() && children.areAllChecked() && !checked
|
||||
}
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
import android.util.Patterns
|
||||
|
||||
fun CharSequence?.isWebUrl(): Boolean {
|
||||
return this?.let { Patterns.WEB_URL.matcher(this).matches() } ?: false
|
||||
}
|
||||
import android.content.Context
|
||||
import android.text.Html
|
||||
import androidx.core.text.toHtml
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.dao.NoteIdReminder
|
||||
import com.philkes.notallyx.data.model.BaseNote.Companion.COLOR_DEFAULT
|
||||
import com.philkes.notallyx.presentation.applySpans
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val NOTE_URL_PREFIX = "note://"
|
||||
private val NOTE_URL_POSTFIX_NOTE = "/${Type.NOTE.name}"
|
||||
|
@ -31,32 +41,282 @@ fun String.getNoteTypeFromUrl(): Type {
|
|||
return Type.valueOf(substringAfterLast("/"))
|
||||
}
|
||||
|
||||
fun String.getUrl(start: Int, end: Int): String {
|
||||
return if (end <= length) {
|
||||
substring(start, end).toUrl()
|
||||
} else substring(start, length).toUrl()
|
||||
}
|
||||
|
||||
private fun String.toUrl(): String {
|
||||
return when {
|
||||
matches(Patterns.PHONE.toRegex()) -> "tel:$this"
|
||||
matches(Patterns.EMAIL_ADDRESS.toRegex()) -> "mailto:$this"
|
||||
matches(Patterns.DOMAIN_NAME.toRegex()) -> "http://$this"
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
val FileAttachment.isImage: Boolean
|
||||
get() {
|
||||
return mimeType.startsWith("image/")
|
||||
return mimeType.isImageMimeType
|
||||
}
|
||||
val String.isImageMimeType: Boolean
|
||||
get() {
|
||||
return startsWith("image/")
|
||||
}
|
||||
val String.isAudioMimeType: Boolean
|
||||
get() {
|
||||
return startsWith("audio/")
|
||||
}
|
||||
|
||||
val String.toPreservedByteArray: ByteArray
|
||||
get() {
|
||||
return this.toByteArray(Charsets.ISO_8859_1)
|
||||
fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
|
||||
buildString {
|
||||
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
|
||||
val body =
|
||||
when (type) {
|
||||
Type.NOTE -> body
|
||||
Type.LIST -> items.toText()
|
||||
}
|
||||
|
||||
val ByteArray.toPreservedString: String
|
||||
get() {
|
||||
return String(this, Charsets.ISO_8859_1)
|
||||
if (title.isNotEmpty() && includeTitle) {
|
||||
append("${title}\n\n")
|
||||
}
|
||||
if (includeCreationDate) {
|
||||
append("$date\n\n")
|
||||
}
|
||||
append(body)
|
||||
return toString()
|
||||
}
|
||||
|
||||
fun BaseNote.toJson(): String {
|
||||
val jsonObject =
|
||||
JSONObject()
|
||||
.put("type", type.name)
|
||||
.put("color", color)
|
||||
.put("title", title)
|
||||
.put("pinned", pinned)
|
||||
.put("timestamp", timestamp)
|
||||
.put("modifiedTimestamp", modifiedTimestamp)
|
||||
.put("labels", JSONArray(labels))
|
||||
|
||||
when (type) {
|
||||
Type.NOTE -> {
|
||||
jsonObject.put("body", body)
|
||||
jsonObject.put("spans", Converters.spansToJSONArray(spans))
|
||||
}
|
||||
|
||||
Type.LIST -> {
|
||||
jsonObject.put("items", Converters.itemsToJSONArray(items))
|
||||
}
|
||||
}
|
||||
jsonObject.put("reminders", Converters.remindersToJSONArray(reminders))
|
||||
jsonObject.put("viewMode", viewMode.name)
|
||||
return jsonObject.toString(2)
|
||||
}
|
||||
|
||||
fun String.toBaseNote(): BaseNote {
|
||||
val jsonObject = JSONObject(this)
|
||||
val id = jsonObject.getLongOrDefault("id", -1L)
|
||||
val type = Type.valueOfOrDefault(jsonObject.getStringOrDefault("type", Type.NOTE.name))
|
||||
val folder = Folder.valueOfOrDefault(jsonObject.getStringOrDefault("folder", Folder.NOTES.name))
|
||||
val color =
|
||||
jsonObject.getStringOrDefault("color", COLOR_DEFAULT).takeIf { it.isValid() }
|
||||
?: COLOR_DEFAULT
|
||||
val title = jsonObject.getStringOrDefault("title", "")
|
||||
val pinned = jsonObject.getBooleanOrDefault("pinned", false)
|
||||
val timestamp = jsonObject.getLongOrDefault("timestamp", System.currentTimeMillis())
|
||||
val modifiedTimestamp = jsonObject.getLongOrDefault("modifiedTimestamp", timestamp)
|
||||
val labels = Converters.jsonToLabels(jsonObject.getArrayOrEmpty("labels"))
|
||||
val body = jsonObject.getStringOrDefault("body", "")
|
||||
val spans = Converters.jsonToSpans(jsonObject.getArrayOrEmpty("spans"))
|
||||
val items = Converters.jsonToItems(jsonObject.getArrayOrEmpty("items"))
|
||||
val images = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("images"))
|
||||
val files = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("files"))
|
||||
val audios = Converters.jsonToAudios(jsonObject.getArrayOrEmpty("audios"))
|
||||
val reminders = Converters.jsonToReminders(jsonObject.getArrayOrEmpty("reminders"))
|
||||
val viewMode = NoteViewMode.valueOfOrDefault(jsonObject.getStringOrDefault("viewMode", ""))
|
||||
return BaseNote(
|
||||
id,
|
||||
type,
|
||||
folder,
|
||||
color,
|
||||
title,
|
||||
pinned,
|
||||
timestamp,
|
||||
modifiedTimestamp,
|
||||
labels,
|
||||
body,
|
||||
spans,
|
||||
items,
|
||||
images,
|
||||
files,
|
||||
audios,
|
||||
reminders,
|
||||
viewMode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.getStringOrDefault(key: String, defaultValue: String): String {
|
||||
return try {
|
||||
getString(key)
|
||||
} catch (exception: JSONException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.getArrayOrEmpty(key: String): JSONArray {
|
||||
return try {
|
||||
getJSONArray(key)
|
||||
} catch (exception: JSONException) {
|
||||
JSONArray("[]")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.getBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
|
||||
return try {
|
||||
getBoolean(key)
|
||||
} catch (exception: JSONException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.getLongOrDefault(key: String, defaultValue: Long): Long {
|
||||
return try {
|
||||
getLong(key)
|
||||
} catch (exception: JSONException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.toHtml(showDateCreated: Boolean) = buildString {
|
||||
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
|
||||
val title = Html.escapeHtml(title)
|
||||
|
||||
append("<!DOCTYPE html>")
|
||||
append("<html><head>")
|
||||
append("<meta charset=\"UTF-8\"><title>$title</title>")
|
||||
append("</head><body>")
|
||||
append("<h2>$title</h2>")
|
||||
|
||||
if (showDateCreated) {
|
||||
append("<p>$date</p>")
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.NOTE -> {
|
||||
val body = body.applySpans(spans).toHtml()
|
||||
append(body)
|
||||
}
|
||||
|
||||
Type.LIST -> {
|
||||
append("<ol style=\"list-style: none; padding: 0;\">")
|
||||
items.forEach { item ->
|
||||
val body = Html.escapeHtml(item.body)
|
||||
val checked = if (item.checked) "checked" else ""
|
||||
val child = if (item.isChild) "style=\"margin-left: 20px\"" else ""
|
||||
append("<li><input type=\"checkbox\" $child $checked>$body</li>")
|
||||
}
|
||||
append("</ol>")
|
||||
}
|
||||
}
|
||||
append("</body></html>")
|
||||
}
|
||||
|
||||
fun List<BaseNote>.toNoteIdReminders() = map { NoteIdReminder(it.id, it.reminders) }
|
||||
|
||||
fun BaseNote.attachmentsDifferFrom(other: BaseNote): Boolean {
|
||||
return files.size != other.files.size ||
|
||||
files.any { file -> other.files.none { it.localName == file.localName } } ||
|
||||
other.files.any { file -> files.none { it.localName == file.localName } } ||
|
||||
images.any { image -> other.images.none { it.localName == image.localName } } ||
|
||||
other.images.any { image -> images.none { it.localName == image.localName } } ||
|
||||
audios.any { audio -> other.audios.none { it.name == audio.name } } ||
|
||||
other.audios.any { audio -> audios.none { it.name == audio.name } }
|
||||
}
|
||||
|
||||
fun Date.toText(): String = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(this)
|
||||
|
||||
fun Repetition.toText(context: Context): String =
|
||||
when {
|
||||
value == 1 && unit == RepetitionTimeUnit.DAYS -> context.getString(R.string.daily)
|
||||
value == 1 && unit == RepetitionTimeUnit.WEEKS -> context.getString(R.string.weekly)
|
||||
value == 1 && unit == RepetitionTimeUnit.MONTHS -> context.getString(R.string.monthly)
|
||||
value == 1 && unit == RepetitionTimeUnit.YEARS -> context.getString(R.string.yearly)
|
||||
else -> "${context.getString(R.string.every)} $value ${unit.toText(context)}"
|
||||
}
|
||||
|
||||
private fun RepetitionTimeUnit.toText(context: Context): String {
|
||||
val resId =
|
||||
when (this) {
|
||||
RepetitionTimeUnit.MINUTES -> R.string.minutes
|
||||
RepetitionTimeUnit.HOURS -> R.string.hours
|
||||
RepetitionTimeUnit.DAYS -> R.string.days
|
||||
RepetitionTimeUnit.WEEKS -> R.string.weeks
|
||||
RepetitionTimeUnit.MONTHS -> R.string.months
|
||||
RepetitionTimeUnit.YEARS -> R.string.years
|
||||
}
|
||||
return context.getString(resId)
|
||||
}
|
||||
|
||||
fun Collection<Reminder>.copy() = map { it.copy() }
|
||||
|
||||
fun RepetitionTimeUnit.toCalendarField(): Int {
|
||||
return when (this) {
|
||||
RepetitionTimeUnit.MINUTES -> Calendar.MINUTE
|
||||
RepetitionTimeUnit.HOURS -> Calendar.HOUR
|
||||
RepetitionTimeUnit.DAYS -> Calendar.DAY_OF_MONTH
|
||||
RepetitionTimeUnit.WEEKS -> Calendar.WEEK_OF_YEAR
|
||||
RepetitionTimeUnit.MONTHS -> Calendar.MONTH
|
||||
RepetitionTimeUnit.YEARS -> Calendar.YEAR
|
||||
}
|
||||
}
|
||||
|
||||
fun Reminder.nextNotification(from: Date = Date()): Date? {
|
||||
if (from.before(dateTime)) {
|
||||
return dateTime
|
||||
}
|
||||
if (repetition == null) {
|
||||
return null
|
||||
}
|
||||
val timeDifferenceMillis: Long = from.time - dateTime.time
|
||||
val intervalsPassed = timeDifferenceMillis / repetition!!.toMillis()
|
||||
val unitsUntilNext = ((repetition!!.value) * (intervalsPassed + 1)).toInt()
|
||||
val reminderStart = dateTime.toCalendar()
|
||||
reminderStart.add(repetition!!.unit.toCalendarField(), unitsUntilNext)
|
||||
return reminderStart.time
|
||||
}
|
||||
|
||||
fun Reminder.nextRepetition(from: Date = Date()): Date? {
|
||||
if (repetition == null) {
|
||||
return null
|
||||
}
|
||||
return nextNotification(from)
|
||||
}
|
||||
|
||||
fun Reminder.hasUpcomingNotification() = !(dateTime.before(Date()) && repetition == null)
|
||||
|
||||
fun Repetition.toMillis(): Long {
|
||||
return Calendar.getInstance()
|
||||
.apply {
|
||||
timeInMillis = 0
|
||||
add(unit.toCalendarField(), value)
|
||||
}
|
||||
.timeInMillis
|
||||
}
|
||||
|
||||
fun Collection<Reminder>.hasAnyUpcomingNotifications(): Boolean {
|
||||
return any { it.hasUpcomingNotification() }
|
||||
}
|
||||
|
||||
fun Collection<Reminder>.findNextNotificationDate(): Date? {
|
||||
return mapNotNull { it.nextNotification() }.minByOrNull { it }
|
||||
}
|
||||
|
||||
fun Date.toCalendar() = Calendar.getInstance().apply { timeInMillis = this@toCalendar.time }
|
||||
|
||||
fun List<ListItem>.toText() = buildString {
|
||||
for (item in this@toText) {
|
||||
val check = if (item.checked) "[✓]" else "[ ]"
|
||||
val childIndentation = if (item.isChild) " " else ""
|
||||
appendLine("$childIndentation$check ${item.body}")
|
||||
}
|
||||
}
|
||||
|
||||
fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) }
|
||||
|
||||
fun ColorString.isValid() =
|
||||
when (this) {
|
||||
COLOR_DEFAULT -> true
|
||||
else ->
|
||||
try {
|
||||
android.graphics.Color.parseColor(this)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.StaticTextProvider
|
||||
|
||||
enum class NoteViewMode(override val textResId: Int) : StaticTextProvider {
|
||||
READ_ONLY(R.string.read_only),
|
||||
EDIT(R.string.edit);
|
||||
|
||||
companion object {
|
||||
fun valueOfOrDefault(value: String) =
|
||||
try {
|
||||
NoteViewMode.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
EDIT
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.philkes.notallyx.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import java.util.Date
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Reminder(var id: Long, var dateTime: Date, var repetition: Repetition?) : Parcelable
|
||||
|
||||
@Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable
|
||||
|
||||
enum class RepetitionTimeUnit {
|
||||
MINUTES,
|
||||
HOURS,
|
||||
DAYS,
|
||||
WEEKS,
|
||||
MONTHS,
|
||||
YEARS,
|
||||
}
|
|
@ -21,15 +21,16 @@ class SearchResult(
|
|||
value = emptyList()
|
||||
}
|
||||
|
||||
fun fetch(keyword: String, folder: Folder) {
|
||||
fun fetch(keyword: String, folder: Folder, label: String?) {
|
||||
job?.cancel()
|
||||
liveData?.removeObserver(observer)
|
||||
job =
|
||||
scope.launch {
|
||||
if (keyword.isNotEmpty()) {
|
||||
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder)
|
||||
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
|
||||
// if (keyword.isNotEmpty())
|
||||
// baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
|
||||
// else baseNoteDao.getFrom(folder)
|
||||
liveData?.observeForever(observer)
|
||||
} else value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,5 +2,14 @@ package com.philkes.notallyx.data.model
|
|||
|
||||
enum class Type {
|
||||
NOTE,
|
||||
LIST,
|
||||
LIST;
|
||||
|
||||
companion object {
|
||||
fun valueOfOrDefault(value: String) =
|
||||
try {
|
||||
Type.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
NOTE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,19 +2,19 @@ package com.philkes.notallyx.presentation
|
|||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Typeface
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.Settings
|
||||
import android.text.Editable
|
||||
import android.text.InputType
|
||||
import android.text.Spannable
|
||||
|
@ -23,45 +23,92 @@ import android.text.TextWatcher
|
|||
import android.text.style.CharacterStyle
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.SuggestionSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.util.TypedValue
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Button
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity.INPUT_METHOD_SERVICE
|
||||
import androidx.appcompat.app.AppCompatActivity.KEYGUARD_SERVICE
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.marginTop
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.RelativeCornerSize
|
||||
import com.google.android.material.shape.RoundedCornerTreatment
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.imports.ImportProgress
|
||||
import com.philkes.notallyx.data.imports.ImportStage
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.SpanRepresentation
|
||||
import com.philkes.notallyx.data.model.getUrl
|
||||
import com.philkes.notallyx.databinding.DialogInputBinding
|
||||
import com.philkes.notallyx.databinding.DialogProgressBinding
|
||||
import com.philkes.notallyx.presentation.view.misc.DateFormat
|
||||
import com.philkes.notallyx.presentation.view.misc.EditTextWithHistory
|
||||
import com.philkes.notallyx.databinding.LabelBinding
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import com.philkes.notallyx.presentation.view.misc.Progress
|
||||
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeHistory
|
||||
import com.philkes.notallyx.utils.changehistory.EditTextState
|
||||
import com.philkes.notallyx.utils.changehistory.EditTextWithHistoryChange
|
||||
import java.io.File
|
||||
import com.philkes.notallyx.utils.getUrl
|
||||
import java.util.Date
|
||||
import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
||||
import org.ocpsoft.prettytime.PrettyTime
|
||||
|
||||
/**
|
||||
|
@ -75,7 +122,7 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
|
|||
->
|
||||
try {
|
||||
if (bold) {
|
||||
editable.setSpan(StyleSpan(Typeface.BOLD), start, end)
|
||||
editable.setSpan(createBoldSpan(), start, end)
|
||||
}
|
||||
if (italic) {
|
||||
editable.setSpan(StyleSpan(Typeface.ITALIC), start, end)
|
||||
|
@ -97,16 +144,25 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
|
|||
return editable
|
||||
}
|
||||
|
||||
fun createBoldSpan() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
StyleSpan(Typeface.BOLD, 700)
|
||||
} else {
|
||||
StyleSpan(Typeface.BOLD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts or removes spans based on the selection range.
|
||||
*
|
||||
* @param selectionStart the start index of the selection
|
||||
* @param selectionEnd the end index of the selection
|
||||
*/
|
||||
fun Editable.removeSelectionFromSpan(selectionStart: Int, selectionEnd: Int) {
|
||||
// Get all spans of type CharacterStyle (can be extended to other types)
|
||||
val spans = getSpans(selectionStart, selectionEnd, CharacterStyle::class.java)
|
||||
|
||||
fun Editable.removeSelectionFromSpans(
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int,
|
||||
spans: Collection<CharacterStyle> =
|
||||
getSpans(selectionStart, selectionEnd, CharacterStyle::class.java).toList(),
|
||||
) {
|
||||
for (span in spans) {
|
||||
val spanStart = getSpanStart(span)
|
||||
val spanEnd = getSpanEnd(span)
|
||||
|
@ -163,95 +219,127 @@ fun EditText.setOnNextAction(onNext: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Menu.add(title: Int, drawable: Int, onClick: (item: MenuItem) -> Unit): MenuItem {
|
||||
return add(Menu.NONE, title, drawable, MenuItem.SHOW_AS_ACTION_IF_ROOM, onClick)
|
||||
}
|
||||
|
||||
fun Menu.add(
|
||||
title: Int,
|
||||
drawable: Int,
|
||||
showAsAction: Int,
|
||||
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM,
|
||||
groupId: Int = Menu.NONE,
|
||||
order: Int = Menu.NONE,
|
||||
onClick: (item: MenuItem) -> Unit,
|
||||
): MenuItem {
|
||||
return add(Menu.NONE, title, drawable, showAsAction, onClick)
|
||||
}
|
||||
|
||||
fun Menu.add(
|
||||
groupId: Int,
|
||||
title: Int,
|
||||
drawable: Int,
|
||||
showAsAction: Int,
|
||||
onClick: (item: MenuItem) -> Unit,
|
||||
): MenuItem {
|
||||
val menuItem = add(groupId, Menu.NONE, Menu.NONE, title)
|
||||
menuItem.setIcon(drawable)
|
||||
menuItem.setOnMenuItemClickListener { item ->
|
||||
val menuItem =
|
||||
add(groupId, Menu.NONE, order, title).setIcon(drawable).setOnMenuItemClickListener { item ->
|
||||
onClick(item)
|
||||
item.isChecked = true
|
||||
return@setOnMenuItemClickListener false
|
||||
}
|
||||
menuItem.setShowAsAction(showAsAction)
|
||||
return menuItem
|
||||
}
|
||||
|
||||
fun ViewGroup.addIconButton(
|
||||
title: Int,
|
||||
drawable: Int,
|
||||
marginStart: Int = 10,
|
||||
onLongClick: View.OnLongClickListener? = null,
|
||||
onClick: View.OnClickListener? = null,
|
||||
): ImageButton {
|
||||
val view =
|
||||
ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply {
|
||||
setImageResource(drawable)
|
||||
val titleText = context.getString(title)
|
||||
contentDescription = titleText
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
tooltipText = titleText
|
||||
}
|
||||
val outValue = TypedValue()
|
||||
context.theme.resolveAttribute(android.R.attr.actionBarItemBackground, outValue, true)
|
||||
setBackgroundResource(outValue.resourceId)
|
||||
setOnLongClickListener(onLongClick)
|
||||
setOnClickListener(onClick)
|
||||
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
adjustViewBounds = true
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
.apply { setMargins(marginStart.dp, marginTop, 0, marginBottom) }
|
||||
setPadding(8.dp)
|
||||
}
|
||||
addView(view)
|
||||
return view
|
||||
}
|
||||
|
||||
fun TextView.displayFormattedTimestamp(
|
||||
timestamp: Long?,
|
||||
dateFormat: String,
|
||||
dateFormat: DateFormat,
|
||||
prefixResId: Int? = null,
|
||||
) {
|
||||
if (dateFormat != DateFormat.none && timestamp != null) {
|
||||
if (dateFormat != DateFormat.NONE && timestamp != null) {
|
||||
visibility = View.VISIBLE
|
||||
text =
|
||||
"${prefixResId?.let { getString(it) } ?: ""} ${formatTimestamp(timestamp, dateFormat)}"
|
||||
} else visibility = View.GONE
|
||||
}
|
||||
|
||||
fun Int.dp(context: Context): Int =
|
||||
TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
this.toFloat(),
|
||||
context.resources.displayMetrics,
|
||||
)
|
||||
.toInt()
|
||||
val Int.dp: Int
|
||||
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
val Float.dp: Int
|
||||
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
/**
|
||||
* Creates a TextWatcher for an EditText that is part of a list. Everytime the text is changed, a
|
||||
* Change is added to the ChangeHistory.
|
||||
*
|
||||
* @param positionGetter Function to determine the current position of the EditText in the list
|
||||
* (e.g. the current adapterPosition when using RecyclerViewer.Adapter)
|
||||
* @param updateModel Function to update the model. Is called on any text changes and on undo/redo.
|
||||
* (e.g. the current absoluteAdapterPosition when using RecyclerViewer.Adapter)
|
||||
* @param onTextChanged optional text change handler. Returns whether or not the original change
|
||||
* should be ignored or not.
|
||||
*/
|
||||
fun EditText.createListTextWatcherWithHistory(listManager: ListManager, positionGetter: () -> Int) =
|
||||
fun EditText.createListTextWatcherWithHistory(
|
||||
listManager: ListManager,
|
||||
positionGetter: () -> Int,
|
||||
onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Boolean)? = null,
|
||||
) =
|
||||
object : TextWatcher {
|
||||
private lateinit var currentTextBefore: String
|
||||
private var ignoreOriginalChange: Boolean = false
|
||||
private lateinit var textBefore: Editable
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
currentTextBefore = s.toString()
|
||||
textBefore = text.clone()
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
ignoreOriginalChange = onTextChanged?.invoke(s!!, start, count) ?: false
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val textAfter = s!!.clone()
|
||||
if (textAfter.hasNotChanged(textBefore)) {
|
||||
return
|
||||
}
|
||||
if (!ignoreOriginalChange) {
|
||||
listManager.changeText(
|
||||
this@createListTextWatcherWithHistory,
|
||||
this,
|
||||
positionGetter.invoke(),
|
||||
currentTextBefore,
|
||||
requireNotNull(s).toString(),
|
||||
EditTextState(textAfter, selectionStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun EditTextWithHistory.createTextWatcherWithHistory(
|
||||
fun StylableEditTextWithHistory.createTextWatcherWithHistory(
|
||||
changeHistory: ChangeHistory,
|
||||
onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Unit)? = null,
|
||||
updateModel: (text: Editable) -> Unit,
|
||||
) =
|
||||
object : TextWatcher {
|
||||
private lateinit var currentTextBefore: Editable
|
||||
private lateinit var stateBefore: EditTextState
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
currentTextBefore = this@createTextWatcherWithHistory.getTextClone()
|
||||
stateBefore = EditTextState(getTextClone(), selectionStart)
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
|
@ -259,21 +347,26 @@ fun EditTextWithHistory.createTextWatcherWithHistory(
|
|||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val textBefore = currentTextBefore.clone()
|
||||
val textAfter = requireNotNull(s).clone()
|
||||
if (textAfter.hasNotChanged(stateBefore.text)) {
|
||||
return
|
||||
}
|
||||
updateModel.invoke(textAfter)
|
||||
|
||||
changeHistory.push(
|
||||
EditTextWithHistoryChange(
|
||||
this@createTextWatcherWithHistory,
|
||||
textBefore,
|
||||
textAfter,
|
||||
stateBefore,
|
||||
EditTextState(textAfter, selectionStart),
|
||||
updateModel,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Editable.hasNotChanged(before: Editable): Boolean {
|
||||
return toString() == before.toString() && getSpans<SuggestionSpan>().isNotEmpty()
|
||||
}
|
||||
|
||||
fun Editable.clone(): Editable = Editable.Factory.getInstance().newEditable(this)
|
||||
|
||||
fun View.getString(id: Int, vararg formatArgs: String): String {
|
||||
|
@ -296,67 +389,14 @@ fun RadioGroup.checkedTag(): Any {
|
|||
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
|
||||
}
|
||||
|
||||
fun Context.canAuthenticateWithBiometrics(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val keyguardManager: KeyguardManager =
|
||||
this.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
|
||||
val packageManager: PackageManager = this.packageManager
|
||||
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
|
||||
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
}
|
||||
if (!keyguardManager.isKeyguardSecure) {
|
||||
return BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
val biometricManager: BiometricManager =
|
||||
this.getSystemService(BiometricManager::class.java)
|
||||
return biometricManager.canAuthenticate()
|
||||
} else {
|
||||
val biometricManager: BiometricManager =
|
||||
this.getSystemService(BiometricManager::class.java)
|
||||
return biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
}
|
||||
}
|
||||
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
fun Context.showKeyboard(view: View) {
|
||||
ContextCompat.getSystemService(this, InputMethodManager::class.java)
|
||||
?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
|
||||
fun <T> LiveData<T>.observeForeverSkipFirst(observer: Observer<T>) {
|
||||
var isFirstEvent = true
|
||||
this.observeForever { value ->
|
||||
if (isFirstEvent) {
|
||||
isFirstEvent = false
|
||||
} else {
|
||||
observer.onChanged(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.fileNameWithoutExtension(): String {
|
||||
return this.substringAfterLast("/") // Remove the path
|
||||
.substringBeforeLast(".") // Remove the extension
|
||||
}
|
||||
|
||||
fun Context.getFileName(uri: Uri): String? =
|
||||
when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||
else -> uri.path?.let(::File)?.name
|
||||
}
|
||||
|
||||
fun Context.getContentFileName(uri: Uri): String? =
|
||||
runCatching {
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return@use cursor
|
||||
.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
||||
.let(cursor::getString)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
|
||||
fun Activity.showKeyboard(view: View) {
|
||||
val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
inputMethodManager.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
|
||||
fun Context.hideKeyboard(view: View) {
|
||||
ContextCompat.getSystemService(this, InputMethodManager::class.java)
|
||||
?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
fun MutableLiveData<out Progress>.setupProgressDialog(activity: Activity, titleId: Int) {
|
||||
|
@ -392,6 +432,20 @@ fun MutableLiveData<ImportProgress>.setupImportProgressDialog(fragment: Fragment
|
|||
}
|
||||
}
|
||||
|
||||
fun <T, C> NotNullLiveData<T>.merge(liveData: NotNullLiveData<C>): MediatorLiveData<Pair<T, C>> {
|
||||
return MediatorLiveData<Pair<T, C>>().apply {
|
||||
addSource(this@merge) { value1 -> value = Pair(value1, liveData.value) }
|
||||
addSource(liveData) { value2 -> value = Pair(this@merge.value, value2) }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, C> NotNullLiveData<T>.merge(liveData: LiveData<C>): MediatorLiveData<Pair<T, C?>> {
|
||||
return MediatorLiveData<Pair<T, C?>>().apply {
|
||||
addSource(this@merge) { value1 -> value = Pair(value1, liveData.value) }
|
||||
addSource(liveData) { value2 -> value = Pair(this@merge.value, value2) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Progress> MutableLiveData<T>.setupProgressDialog(
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
|
@ -435,7 +489,8 @@ private fun <T : Progress> MutableLiveData<T>.setupProgressDialog(
|
|||
|
||||
fun Activity.checkNotificationPermission(
|
||||
requestCode: Int,
|
||||
messageResId: Int,
|
||||
alsoCheckAlarmPermission: Boolean = false,
|
||||
alarmPermissionResultLauncher: ActivityResultLauncher<Intent>? = null,
|
||||
onSuccess: () -> Unit,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
|
@ -443,46 +498,511 @@ fun Activity.checkNotificationPermission(
|
|||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (shouldShowRequestPermissionRationale(permission)) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(messageResId)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> onSuccess() }
|
||||
.setMessage(R.string.please_grant_notally_notification)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.continue_) { _, _ ->
|
||||
requestPermissions(arrayOf(permission), requestCode)
|
||||
}
|
||||
.setOnDismissListener { onSuccess() }
|
||||
.show()
|
||||
} else requestPermissions(arrayOf(permission), requestCode)
|
||||
} else onSuccess()
|
||||
} else if (alsoCheckAlarmPermission)
|
||||
checkAlarmPermission(alarmPermissionResultLauncher, onSuccess)
|
||||
else onSuccess()
|
||||
} else if (alsoCheckAlarmPermission)
|
||||
checkAlarmPermission(alarmPermissionResultLauncher, onSuccess)
|
||||
else onSuccess()
|
||||
}
|
||||
|
||||
fun Activity.checkAlarmPermission(
|
||||
resultLauncher: ActivityResultLauncher<Intent>?,
|
||||
onSuccess: () -> Unit,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (getSystemService<AlarmManager>()!!.canScheduleExactAlarms()) {
|
||||
onSuccess()
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.please_grant_notally_alarm)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.continue_) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
if (resultLauncher != null) {
|
||||
resultLauncher.launch(intent)
|
||||
} else {
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
} else onSuccess()
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long, dateFormat: String): String {
|
||||
fun Activity.setEnabledSecureFlag(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.displayEditLabelDialog(
|
||||
oldValue: String,
|
||||
model: BaseNoteModel,
|
||||
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
|
||||
) {
|
||||
requireContext().displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
|
||||
}
|
||||
|
||||
fun Activity.displayEditLabelDialog(
|
||||
oldValue: String,
|
||||
model: BaseNoteModel,
|
||||
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
|
||||
) {
|
||||
displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
|
||||
}
|
||||
|
||||
fun Context.displayEditLabelDialog(
|
||||
oldValue: String,
|
||||
model: BaseNoteModel,
|
||||
layoutInflater: LayoutInflater,
|
||||
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
|
||||
) {
|
||||
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
|
||||
dialogBinding.EditText.setText(oldValue)
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setView(dialogBinding.root)
|
||||
.setTitle(R.string.edit_label)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val value = dialogBinding.EditText.text.toString().trim()
|
||||
if (value.isNotEmpty()) {
|
||||
model.updateLabel(oldValue, value) { success ->
|
||||
if (success) {
|
||||
onUpdateLabel?.invoke(oldValue, value)
|
||||
dialog.dismiss()
|
||||
} else showToast(R.string.label_exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
|
||||
dialogBinding.EditText.doAfterTextChanged { text ->
|
||||
positiveButton.isEnabled = !text.isNullOrEmpty()
|
||||
}
|
||||
positiveButton.isEnabled = oldValue.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
|
||||
val date = Date(timestamp)
|
||||
return when (dateFormat) {
|
||||
DateFormat.relative -> PrettyTime().format(date)
|
||||
DateFormat.RELATIVE -> PrettyTime().format(date)
|
||||
else -> java.text.DateFormat.getDateInstance(java.text.DateFormat.FULL).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.copyToClipBoard(text: CharSequence) {
|
||||
val clipboard: ClipboardManager =
|
||||
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("label", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
}
|
||||
|
||||
fun ClipboardManager.getLatestText(): CharSequence? {
|
||||
return if (primaryClip!!.itemCount > 0) primaryClip!!.getItemAt(0)!!.text else null
|
||||
}
|
||||
|
||||
fun MaterialAlertDialogBuilder.showAndFocus(view: View): AlertDialog {
|
||||
val dialog = show()
|
||||
view.requestFocus()
|
||||
if (view is EditText) {
|
||||
dialog.window?.setSoftInputMode(SOFT_INPUT_STATE_VISIBLE)
|
||||
fun MaterialAlertDialogBuilder.showAndFocus(
|
||||
viewToFocus: View? = null,
|
||||
selectAll: Boolean = false,
|
||||
allowFullSize: Boolean = false,
|
||||
onShowListener: DialogInterface.OnShowListener? = null,
|
||||
applyToPositiveButton: ((positiveButton: Button) -> Unit)? = null,
|
||||
): AlertDialog {
|
||||
if (allowFullSize) {
|
||||
setBackgroundInsetEnd(0)
|
||||
setBackgroundInsetStart(0)
|
||||
setBackgroundInsetBottom(0)
|
||||
setBackgroundInsetTop(0)
|
||||
}
|
||||
return create().apply {
|
||||
viewToFocus?.requestFocus()
|
||||
if (viewToFocus is EditText) {
|
||||
if (selectAll) {
|
||||
viewToFocus.selectAll()
|
||||
}
|
||||
window?.setSoftInputMode(SOFT_INPUT_STATE_VISIBLE)
|
||||
}
|
||||
if (allowFullSize) {
|
||||
window?.setLayout(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
onShowListener?.let { setOnShowListener(it) }
|
||||
show()
|
||||
applyToPositiveButton?.let {
|
||||
getButton(AlertDialog.BUTTON_POSITIVE)?.let { positiveButton -> it(positiveButton) }
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
fun Context.getQuantityString(id: Int, quantity: Int, vararg formatArgs: Any): String {
|
||||
return resources.getQuantityString(id, quantity, quantity, *formatArgs)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun @receiver:ColorInt Int.withAlpha(alpha: Float): Int {
|
||||
return android.graphics.Color.argb(
|
||||
(255 * alpha).toInt(),
|
||||
android.graphics.Color.red(this),
|
||||
android.graphics.Color.green(this),
|
||||
android.graphics.Color.blue(this),
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.getColorFromAttr(@AttrRes attr: Int): Int {
|
||||
val typedValue = TypedValue()
|
||||
val resolved = theme.resolveAttribute(attr, typedValue, true)
|
||||
if (!resolved) {
|
||||
throw IllegalArgumentException("Attribute not found in current theme")
|
||||
}
|
||||
return if (typedValue.resourceId != 0) {
|
||||
// It's a reference (@color/something), resolve it properly
|
||||
ContextCompat.getColor(this, typedValue.resourceId)
|
||||
} else {
|
||||
// It's a direct color value
|
||||
typedValue.data
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setControlsContrastColorForAllViews(
|
||||
@ColorInt backgroundColor: Int,
|
||||
overwriteBackground: Boolean = true,
|
||||
) {
|
||||
val controlsColor = context.getContrastFontColor(backgroundColor)
|
||||
setControlsColorForAllViews(controlsColor, backgroundColor, overwriteBackground)
|
||||
}
|
||||
|
||||
var counter = 0
|
||||
|
||||
fun View.setControlsColorForAllViews(
|
||||
@ColorInt controlsColor: Int,
|
||||
@ColorInt backgroundColor: Int,
|
||||
overwriteBackground: Boolean = true,
|
||||
) {
|
||||
if (this is ViewGroup) {
|
||||
for (i in 0 until childCount) {
|
||||
val child = getChildAt(i)
|
||||
child.setControlsColorForAllViews(
|
||||
controlsColor,
|
||||
backgroundColor,
|
||||
overwriteBackground,
|
||||
) // Recursive call for nested layouts
|
||||
}
|
||||
if (this is MaterialCardView) {
|
||||
checkedIconTint = ColorStateList.valueOf(controlsColor)
|
||||
val colorStateList =
|
||||
ColorStateList(
|
||||
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
|
||||
intArrayOf(controlsColor, controlsColor.withAlpha(0.3f)),
|
||||
)
|
||||
setStrokeColor(colorStateList)
|
||||
}
|
||||
} else {
|
||||
val controlsStateList =
|
||||
ColorStateList(
|
||||
arrayOf(
|
||||
intArrayOf(android.R.attr.state_enabled),
|
||||
intArrayOf(-android.R.attr.state_enabled),
|
||||
),
|
||||
intArrayOf(controlsColor, controlsColor.withAlpha(0.3f)),
|
||||
)
|
||||
if (this is Chip) {
|
||||
setTextColor(controlsStateList)
|
||||
setLinkTextColor(controlsStateList)
|
||||
chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
|
||||
chipIconTint = controlsStateList
|
||||
chipStrokeColor = controlsStateList
|
||||
return
|
||||
}
|
||||
if (this is TextView) {
|
||||
setCompoundDrawableTint(controlsColor)
|
||||
setTextColor(controlsStateList)
|
||||
setLinkTextColor(controlsStateList)
|
||||
if (isTextSelectable || this is EditText) {
|
||||
val highlight = controlsColor.withAlpha(0.4f)
|
||||
setHintTextColor(highlight)
|
||||
highlightColor = highlight
|
||||
setSelectionHandleColor(controlsColor.withAlpha(0.8f))
|
||||
}
|
||||
}
|
||||
if (this is CompoundButton) {
|
||||
buttonTintList = controlsStateList
|
||||
}
|
||||
if (this is MaterialButton) {
|
||||
iconTint = controlsStateList
|
||||
}
|
||||
if (this is ImageButton) {
|
||||
imageTintList = controlsStateList
|
||||
}
|
||||
if (this is MaterialCheckBox) {
|
||||
buttonIconTintList = ColorStateList.valueOf(backgroundColor)
|
||||
}
|
||||
if (overwriteBackground) {
|
||||
backgroundTintList = ColorStateList.valueOf(controlsColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TextView.setSelectionHandleColor(@ColorInt color: Int) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
textSelectHandleLeft?.withTint(color)?.let { setTextSelectHandleLeft(it) }
|
||||
textSelectHandleRight?.withTint(color)?.let { setTextSelectHandleRight(it) }
|
||||
textSelectHandle?.withTint(color)?.let { setTextSelectHandle(it) }
|
||||
textCursorDrawable?.let { DrawableCompat.setTint(it, color) }
|
||||
} else {
|
||||
setSelectHandleColor(color)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* This uses light-graylisted Android APIs. Verified that it works in devices running APIs 21
|
||||
* and 28. Source: https://gist.github.com/carranca/7e3414622ad7fc6ef375c8cd8dc840c9
|
||||
*/
|
||||
private fun TextView.setSelectHandleColor(@ColorInt color: Int) {
|
||||
// Retrieve a reference to this text field's android.widget.Editor
|
||||
val editor = getEditor()
|
||||
|
||||
handles.forEach {
|
||||
// Retrieve the field pointing to the drawable currently being used for the select handle
|
||||
val resourceField = TextView::class.java.getDeclaredField(it.resourceFieldName)
|
||||
resourceField.isAccessible = true
|
||||
|
||||
// Retrieve the drawable resource from that field
|
||||
val drawableId = resourceField.getInt(this)
|
||||
val drawable = ContextCompat.getDrawable(context, drawableId)
|
||||
|
||||
// Apply a filter on that drawable with the desired colour
|
||||
drawable?.setColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
// Override the drawable being used by the Editor with our coloured drawable
|
||||
val selectHandleField = editor.javaClass.getDeclaredField(it.selectHandleFieldName)
|
||||
selectHandleField.isAccessible = true
|
||||
selectHandleField.set(editor, drawable)
|
||||
}
|
||||
}
|
||||
|
||||
private class HandleDescriptor(val resourceFieldName: String, val selectHandleFieldName: String)
|
||||
|
||||
private val handles =
|
||||
arrayOf(
|
||||
HandleDescriptor(
|
||||
resourceFieldName = "mTextSelectHandleRes",
|
||||
selectHandleFieldName = "mSelectHandleCenter",
|
||||
),
|
||||
HandleDescriptor(
|
||||
resourceFieldName = "mTextSelectHandleLeftRes",
|
||||
selectHandleFieldName = "mSelectHandleLeft",
|
||||
),
|
||||
HandleDescriptor(
|
||||
resourceFieldName = "mTextSelectHandleRightRes",
|
||||
selectHandleFieldName = "mSelectHandleRight",
|
||||
),
|
||||
)
|
||||
|
||||
private fun TextView.getEditor(): Any {
|
||||
val editorField = TextView::class.java.getDeclaredField("mEditor")
|
||||
editorField.isAccessible = true
|
||||
return editorField.get(this)
|
||||
}
|
||||
|
||||
fun TextView.setCompoundDrawableTint(@ColorInt color: Int) {
|
||||
compoundDrawablesRelative.forEach { drawable ->
|
||||
drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) }
|
||||
}
|
||||
compoundDrawables.forEach { drawable ->
|
||||
drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) }
|
||||
}
|
||||
(background as? MaterialShapeDrawable)?.setStrokeTint(color)
|
||||
}
|
||||
|
||||
fun Drawable.withTint(@ColorInt color: Int): Drawable {
|
||||
val oldDrawable = DrawableCompat.unwrap<Drawable>(this)
|
||||
val newDrawable = oldDrawable.constantState?.newDrawable()?.mutate() ?: oldDrawable.mutate()
|
||||
val wrappedDrawable = DrawableCompat.wrap(newDrawable)
|
||||
DrawableCompat.setTint(wrappedDrawable, color)
|
||||
return wrappedDrawable
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun Context.getContrastFontColor(@ColorInt backgroundColor: Int): Int {
|
||||
return if (backgroundColor.isLightColor()) ContextCompat.getColor(this, R.color.TextDark)
|
||||
else ContextCompat.getColor(this, R.color.TextLight)
|
||||
}
|
||||
|
||||
fun @receiver:ColorInt Int.isLightColor() = ColorUtils.calculateLuminance(this) > 0.5
|
||||
|
||||
fun MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) =
|
||||
setNegativeButton(R.string.cancel, listener)
|
||||
|
||||
fun Fragment.showDialog(
|
||||
messageResId: Int,
|
||||
positiveButtonTextResId: Int,
|
||||
onPositiveButtonClickListener: DialogInterface.OnClickListener,
|
||||
) =
|
||||
requireContext()
|
||||
.showDialog(messageResId, positiveButtonTextResId, onPositiveButtonClickListener)
|
||||
|
||||
fun Context.showDialog(
|
||||
messageResId: Int,
|
||||
positiveButtonTextResId: Int,
|
||||
onPositiveButtonClickListener: DialogInterface.OnClickListener,
|
||||
) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(messageResId)
|
||||
.setPositiveButton(positiveButtonTextResId, onPositiveButtonClickListener)
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
fun Fragment.showToast(messageResId: Int) = requireContext().showToast(messageResId)
|
||||
|
||||
fun Context.showToast(messageResId: Int) =
|
||||
ContextCompat.getMainExecutor(this).execute {
|
||||
Toast.makeText(this, messageResId, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
fun Context.showToast(message: CharSequence) =
|
||||
ContextCompat.getMainExecutor(this).execute {
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
fun Context.restartApplication(
|
||||
fragmentIdToOpen: Int? = null,
|
||||
extra: Pair<String, Boolean>? = null,
|
||||
) {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val componentName = intent!!.component
|
||||
val mainIntent =
|
||||
Intent.makeRestartActivityTask(componentName).apply {
|
||||
fragmentIdToOpen?.let { putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, it) }
|
||||
extra?.let { (key, value) -> putExtra(key, value) }
|
||||
}
|
||||
mainIntent.setPackage(packageName)
|
||||
startActivity(mainIntent)
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun Context.extractColor(color: String): Int {
|
||||
return when (color) {
|
||||
BaseNote.COLOR_DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
|
||||
else -> android.graphics.Color.parseColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
fun ViewGroup.addFastScroll(context: Context) {
|
||||
FastScrollerBuilder(this)
|
||||
.useMd2Style()
|
||||
.setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!)
|
||||
.setPadding(0, 0, 2.dp, 0)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) {
|
||||
val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view)
|
||||
windowInsetsControllerCompat.isAppearanceLightStatusBars = value
|
||||
windowInsetsControllerCompat.isAppearanceLightNavigationBars = value
|
||||
}
|
||||
|
||||
fun ChipGroup.bindLabels(
|
||||
labels: List<String>,
|
||||
textSize: TextSize,
|
||||
paddingTop: Boolean,
|
||||
color: Int? = null,
|
||||
onClick: ((label: String) -> Unit)? = null,
|
||||
onLongClick: ((label: String) -> Unit)? = null,
|
||||
) {
|
||||
if (labels.isEmpty()) {
|
||||
visibility = View.GONE
|
||||
} else {
|
||||
apply {
|
||||
visibility = View.VISIBLE
|
||||
removeAllViews()
|
||||
updatePadding(top = if (paddingTop) 8.dp else 0)
|
||||
}
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val labelSize = textSize.displayBodySize
|
||||
for (label in labels) {
|
||||
LabelBinding.inflate(inflater, this, true).root.apply {
|
||||
background = getOutlinedDrawable(this@bindLabels.context)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
|
||||
text = label
|
||||
color?.let { setControlsContrastColorForAllViews(it) }
|
||||
onClick?.let { setOnClickListener { it(label) } }
|
||||
onLongClick?.let {
|
||||
setOnLongClickListener {
|
||||
it(label)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOutlinedDrawable(context: Context): MaterialShapeDrawable {
|
||||
val model =
|
||||
ShapeAppearanceModel.builder()
|
||||
.setAllCorners(RoundedCornerTreatment())
|
||||
.setAllCornerSizes(RelativeCornerSize(0.5f))
|
||||
.build()
|
||||
|
||||
return MaterialShapeDrawable(model).apply {
|
||||
fillColor = ColorStateList.valueOf(0)
|
||||
strokeWidth = context.resources.displayMetrics.density
|
||||
strokeColor = ContextCompat.getColorStateList(context, R.color.chip_stroke)
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.initListView(context: Context) {
|
||||
setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
|
||||
setPadding(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
val RecyclerView.focusedViewHolder
|
||||
get() =
|
||||
focusedChild?.let { view ->
|
||||
val position = getChildAdapterPosition(view)
|
||||
if (position == RecyclerView.NO_POSITION) {
|
||||
null
|
||||
} else {
|
||||
findViewHolderForAdapterPosition(position)
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.showKeyboardOnFocusedItem() {
|
||||
(focusedViewHolder as? ListItemVH)?.let {
|
||||
it.binding.root.context?.showKeyboard(it.binding.EditText)
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.hideKeyboardOnFocusedItem() {
|
||||
(focusedViewHolder as? ListItemVH)?.let {
|
||||
it.binding.root.context?.hideKeyboard(it.binding.EditText)
|
||||
}
|
||||
}
|
||||
|
||||
fun MaterialAutoCompleteTextView.select(value: CharSequence) {
|
||||
setText(value, false)
|
||||
}
|
||||
|
||||
fun Context.createTextView(textResId: Int, padding: Int = 16.dp): TextView {
|
||||
return AppCompatTextView(this).apply {
|
||||
setText(textResId)
|
||||
TextViewCompat.setTextAppearance(
|
||||
this,
|
||||
android.R.style.TextAppearance_Material_DialogWindowTitle,
|
||||
)
|
||||
updatePadding(padding, padding, padding, padding)
|
||||
maxLines = Integer.MAX_VALUE
|
||||
ellipsize = null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@ package com.philkes.notallyx.presentation.activity
|
|||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.philkes.notallyx.Preferences
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider
|
||||
|
||||
class ConfigureWidgetActivity : PickNoteActivity() {
|
||||
|
@ -27,12 +27,12 @@ class ConfigureWidgetActivity : PickNoteActivity() {
|
|||
|
||||
override fun onClick(position: Int) {
|
||||
if (position != -1) {
|
||||
val preferences = Preferences.getInstance(application)
|
||||
val noteId = (adapter.getItem(position) as BaseNote).id
|
||||
preferences.updateWidget(id, noteId)
|
||||
val preferences = NotallyXPreferences.getInstance(application)
|
||||
val baseNote = adapter.getItem(position) as BaseNote
|
||||
preferences.updateWidget(id, baseNote.id, baseNote.type)
|
||||
|
||||
val manager = AppWidgetManager.getInstance(this)
|
||||
WidgetProvider.updateWidget(this, manager, id, noteId)
|
||||
WidgetProvider.updateWidget(application, manager, id, baseNote.id, baseNote.type)
|
||||
|
||||
val success = Intent()
|
||||
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
|
||||
|
|
|
@ -3,33 +3,55 @@ package com.philkes.notallyx.presentation.activity
|
|||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Intent
|
||||
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT
|
||||
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.NotallyXApplication
|
||||
import com.philkes.notallyx.Preferences
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
|
||||
|
||||
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
||||
|
||||
private lateinit var notallyXApplication: NotallyXApplication
|
||||
private lateinit var biometricAuthenticationActivityResultLauncher:
|
||||
ActivityResultLauncher<Intent>
|
||||
|
||||
protected lateinit var binding: T
|
||||
protected lateinit var preferences: Preferences
|
||||
protected lateinit var preferences: NotallyXPreferences
|
||||
val baseModel: BaseNoteModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
notallyXApplication = (application as NotallyXApplication)
|
||||
preferences = Preferences.getInstance(application)
|
||||
preferences = NotallyXPreferences.getInstance(application)
|
||||
|
||||
biometricAuthenticationActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
unlock()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (preferences.biometricLock.value == enabled) {
|
||||
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
|
||||
if (hasToAuthenticateWithBiometric()) {
|
||||
hide()
|
||||
showLockScreen()
|
||||
|
@ -42,36 +64,62 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (preferences.biometricLock.value == enabled) {
|
||||
if (
|
||||
preferences.biometricLock.value == BiometricLock.ENABLED &&
|
||||
notallyXApplication.locked.value
|
||||
) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_BIOMETRIC_AUTHENTICATION) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
notallyXApplication.isLocked = false
|
||||
show()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun showLockScreen() {
|
||||
showBiometricOrPinPrompt(
|
||||
true,
|
||||
preferences.iv!!,
|
||||
REQUEST_BIOMETRIC_AUTHENTICATION,
|
||||
preferences.iv.value!!,
|
||||
biometricAuthenticationActivityResultLauncher,
|
||||
R.string.unlock,
|
||||
onSuccess = {
|
||||
notallyXApplication.isLocked = false
|
||||
show()
|
||||
},
|
||||
) {
|
||||
finish()
|
||||
onSuccess = { unlock() },
|
||||
) { errorCode ->
|
||||
when (errorCode) {
|
||||
BIOMETRIC_ERROR_NO_BIOMETRICS -> {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.unlock_with_biometrics_not_setup)
|
||||
.setPositiveButton(R.string.disable) { _, _ ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
baseModel.disableBiometricLock()
|
||||
}
|
||||
show()
|
||||
}
|
||||
.setNegativeButton(R.string.tap_to_set_up) { _, _ ->
|
||||
val intent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
|
||||
} else {
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
baseModel.disableBiometricLock()
|
||||
showToast(R.string.biometrics_disable_success)
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
else -> finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unlock() {
|
||||
notallyXApplication.locked.value = false
|
||||
show()
|
||||
}
|
||||
|
||||
protected fun show() {
|
||||
|
@ -83,16 +131,12 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun hasToAuthenticateWithBiometric(): Boolean {
|
||||
val keyguardManager: KeyguardManager =
|
||||
this.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
(keyguardManager.isDeviceLocked || notallyXApplication.isLocked)
|
||||
return ContextCompat.getSystemService(this, KeyguardManager::class.java)?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
(it.isDeviceLocked || notallyXApplication.locked.value)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_BIOMETRIC_AUTHENTICATION = 11
|
||||
} ?: false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,26 +1,24 @@
|
|||
package com.philkes.notallyx.presentation.activity.main
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.print.PostPDFGenerator
|
||||
import android.transition.TransitionManager
|
||||
import android.view.Menu
|
||||
import android.view.Menu.CATEGORY_CONTAINER
|
||||
import android.view.Menu.CATEGORY_SYSTEM
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.core.view.children
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.navOptions
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
|
@ -30,41 +28,54 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.platform.MaterialFade
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.databinding.ActivityMainBinding
|
||||
import com.philkes.notallyx.databinding.DialogColorBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment
|
||||
import com.philkes.notallyx.presentation.activity.note.EditListActivity
|
||||
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.applySpans
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.movedToResId
|
||||
import com.philkes.notallyx.presentation.view.main.ColorAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox
|
||||
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import java.io.File
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_EMPTY
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_NONE
|
||||
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
|
||||
import com.philkes.notallyx.utils.backup.exportNotes
|
||||
import com.philkes.notallyx.utils.shareNote
|
||||
import com.philkes.notallyx.utils.showColorSelectDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : LockedActivity<ActivityMainBinding>() {
|
||||
|
||||
private lateinit var navController: NavController
|
||||
private lateinit var configuration: AppBarConfiguration
|
||||
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private val model: BaseNoteModel by viewModels()
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (model.actionMode.enabled.value) {
|
||||
model.actionMode.close(true)
|
||||
} else super.onBackPressed()
|
||||
private var isStartViewFragment = false
|
||||
private val actionModeCancelCallback =
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
baseModel.actionMode.close(true)
|
||||
}
|
||||
}
|
||||
|
||||
var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
baseModel.keyword = ""
|
||||
return navController.navigateUp(configuration)
|
||||
}
|
||||
|
||||
|
@ -77,48 +88,183 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
setupMenu()
|
||||
setupActionMode()
|
||||
setupNavigation()
|
||||
setupSearch()
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
|
||||
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
|
||||
if (fragmentIdToLoad != -1) {
|
||||
navController.navigate(fragmentIdToLoad, intent.extras)
|
||||
} else if (savedInstanceState == null) {
|
||||
navigateToStartView()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_EXPORT_FILE && resultCode == Activity.RESULT_OK) {
|
||||
data?.data?.let { uri -> model.writeCurrentFileToUri(uri) }
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (baseModel.actionMode.enabled.value) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!isStartViewFragment &&
|
||||
!intent.getBooleanExtra(EXTRA_SKIP_START_VIEW_ON_BACK, false)
|
||||
) {
|
||||
navigateToStartView()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
|
||||
}
|
||||
|
||||
private fun getStartViewNavigation(): Pair<Int, Bundle> {
|
||||
return when (val startView = preferences.startView.value) {
|
||||
START_VIEW_DEFAULT -> Pair(R.id.Notes, Bundle())
|
||||
START_VIEW_UNLABELED -> Pair(R.id.Unlabeled, Bundle())
|
||||
else -> {
|
||||
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, startView) }
|
||||
Pair(R.id.DisplayLabel, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToStartView() {
|
||||
val (id, bundle) = getStartViewNavigation()
|
||||
navController.navigate(id, bundle)
|
||||
}
|
||||
|
||||
private fun setupFAB() {
|
||||
binding.TakeNote.setOnClickListener {
|
||||
val intent = Intent(this, EditNoteActivity::class.java)
|
||||
startActivity(intent)
|
||||
startActivity(prepareNewNoteIntent(intent))
|
||||
}
|
||||
binding.MakeList.setOnClickListener {
|
||||
val intent = Intent(this, EditListActivity::class.java)
|
||||
startActivity(intent)
|
||||
startActivity(prepareNewNoteIntent(intent))
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareNewNoteIntent(intent: Intent): Intent {
|
||||
return supportFragmentManager
|
||||
.findFragmentById(R.id.NavHostFragment)
|
||||
?.childFragmentManager
|
||||
?.fragments
|
||||
?.firstOrNull()
|
||||
?.let { fragment ->
|
||||
return if (fragment is NotallyFragment) {
|
||||
fragment.prepareNewNoteIntent(intent)
|
||||
} else intent
|
||||
} ?: intent
|
||||
}
|
||||
|
||||
private var labelsMenuItems: List<MenuItem> = listOf()
|
||||
private var labelsMoreMenuItem: MenuItem? = null
|
||||
private var labels: List<String> = listOf()
|
||||
private var labelsLiveData: LiveData<List<String>>? = null
|
||||
|
||||
private fun setupMenu() {
|
||||
binding.NavigationView.menu.apply {
|
||||
add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
|
||||
add(1, R.id.Labels, 0, R.string.labels).setCheckable(true).setIcon(R.drawable.label)
|
||||
add(2, R.id.Deleted, 0, R.string.deleted).setCheckable(true).setIcon(R.drawable.delete)
|
||||
add(2, R.id.Archived, 0, R.string.archived)
|
||||
|
||||
addStaticLabelsMenuItems()
|
||||
NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
|
||||
labelsLiveData?.removeObservers(this@MainActivity)
|
||||
labelsLiveData =
|
||||
database.getLabelDao().getAll().also {
|
||||
it.observe(this@MainActivity) { labels ->
|
||||
this@MainActivity.labels = labels
|
||||
setupLabelsMenuItems(labels, preferences.maxLabels.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(2, R.id.Deleted, CATEGORY_SYSTEM + 1, R.string.deleted)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.delete)
|
||||
add(2, R.id.Archived, CATEGORY_SYSTEM + 2, R.string.archived)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.archive)
|
||||
add(3, R.id.Settings, 0, R.string.settings)
|
||||
add(3, R.id.Reminders, CATEGORY_SYSTEM + 3, R.string.reminders)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.notifications)
|
||||
add(3, R.id.Settings, CATEGORY_SYSTEM + 4, R.string.settings)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.settings)
|
||||
}
|
||||
try {
|
||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
val version = pInfo.versionName
|
||||
binding.Version.text = "v$version"
|
||||
} catch (_: PackageManager.NameNotFoundException) {}
|
||||
baseModel.preferences.labelsHidden.observe(this) { hiddenLabels ->
|
||||
hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
|
||||
}
|
||||
baseModel.preferences.maxLabels.observe(this) { maxLabels ->
|
||||
binding.NavigationView.menu.setupLabelsMenuItems(labels, maxLabels)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Menu.addStaticLabelsMenuItems() {
|
||||
add(1, R.id.Unlabeled, CATEGORY_CONTAINER + 1, R.string.unlabeled)
|
||||
.setCheckable(true)
|
||||
.setChecked(baseModel.currentLabel == CURRENT_LABEL_NONE)
|
||||
.setIcon(R.drawable.label_off)
|
||||
add(1, R.id.Labels, CATEGORY_CONTAINER + 2, R.string.labels)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.label_more)
|
||||
}
|
||||
|
||||
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
|
||||
removeGroup(1)
|
||||
addStaticLabelsMenuItems()
|
||||
labelsMenuItems =
|
||||
labels
|
||||
.mapIndexed { index, label ->
|
||||
add(1, R.id.DisplayLabel, CATEGORY_CONTAINER + index + 3, label)
|
||||
.setCheckable(true)
|
||||
.setChecked(baseModel.currentLabel == label)
|
||||
.setVisible(index < maxLabelsToDisplay)
|
||||
.setIcon(R.drawable.label)
|
||||
.setOnMenuItemClickListener {
|
||||
navigateToLabel(label)
|
||||
false
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
labelsMoreMenuItem =
|
||||
if (labelsMenuItems.size > maxLabelsToDisplay) {
|
||||
add(
|
||||
1,
|
||||
R.id.Labels,
|
||||
CATEGORY_CONTAINER + labelsMenuItems.size + 2,
|
||||
getString(R.string.more, labelsMenuItems.size - maxLabelsToDisplay),
|
||||
)
|
||||
.setCheckable(true)
|
||||
.setIcon(R.drawable.label)
|
||||
} else null
|
||||
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
|
||||
setupActionBarWithNavController(navController, configuration)
|
||||
hideLabelsInNavigation(baseModel.preferences.labelsHidden.value, maxLabelsToDisplay)
|
||||
}
|
||||
|
||||
private fun navigateToLabel(label: String) {
|
||||
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
|
||||
navController.navigate(R.id.DisplayLabel, bundle)
|
||||
}
|
||||
|
||||
private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) {
|
||||
var visibleLabels = 0
|
||||
labelsMenuItems.forEach { menuItem ->
|
||||
val visible =
|
||||
!hiddenLabels.contains(menuItem.title) && visibleLabels < maxLabelsToDisplay
|
||||
menuItem.setVisible(visible)
|
||||
if (visible) {
|
||||
visibleLabels++
|
||||
}
|
||||
}
|
||||
labelsMoreMenuItem?.setTitle(getString(R.string.more, labels.size - visibleLabels))
|
||||
}
|
||||
|
||||
private fun setupActionMode() {
|
||||
binding.ActionMode.setNavigationOnClickListener { model.actionMode.close(true) }
|
||||
binding.ActionMode.setNavigationOnClickListener { baseModel.actionMode.close(true) }
|
||||
|
||||
val transition =
|
||||
MaterialFade().apply {
|
||||
|
@ -130,7 +276,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
excludeTarget(binding.NavigationView, true)
|
||||
}
|
||||
|
||||
model.actionMode.enabled.observe(this) { enabled ->
|
||||
baseModel.actionMode.enabled.observe(this) { enabled ->
|
||||
TransitionManager.beginDelayedTransition(binding.RelativeLayout, transition)
|
||||
if (enabled) {
|
||||
binding.Toolbar.visibility = View.GONE
|
||||
|
@ -141,255 +287,124 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
binding.ActionMode.visibility = View.GONE
|
||||
binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
|
||||
}
|
||||
actionModeCancelCallback.isEnabled = enabled
|
||||
}
|
||||
|
||||
val menu = binding.ActionMode.menu
|
||||
val pinned = menu.add(R.string.pin, R.drawable.pin) {}
|
||||
val share = menu.add(R.string.share, R.drawable.share) { share() }
|
||||
val labels = menu.add(R.string.labels, R.drawable.label) { label() }
|
||||
|
||||
val export = createExportMenu(menu)
|
||||
|
||||
val changeColor = menu.add(R.string.change_color, R.drawable.change_color) { changeColor() }
|
||||
val delete = menu.add(R.string.delete, R.drawable.delete) { moveNotes(Folder.DELETED) }
|
||||
val archive = menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
|
||||
val restore = menu.add(R.string.restore, R.drawable.restore) { moveNotes(Folder.NOTES) }
|
||||
val unarchive =
|
||||
menu.add(R.string.unarchive, R.drawable.unarchive) { moveNotes(Folder.NOTES) }
|
||||
val deleteForever = menu.add(R.string.delete_forever, R.drawable.delete) { deleteForever() }
|
||||
|
||||
model.actionMode.count.observe(this) { count ->
|
||||
if (count == 0) {
|
||||
menu.forEach { item -> item.setVisible(false) }
|
||||
} else {
|
||||
binding.ActionMode.title = count.toString()
|
||||
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
if (count == 1) {
|
||||
if (baseNote.pinned) {
|
||||
pinned.setTitle(R.string.unpin)
|
||||
pinned.setIcon(R.drawable.unpin)
|
||||
} else {
|
||||
pinned.setTitle(R.string.pin)
|
||||
pinned.setIcon(R.drawable.pin)
|
||||
}
|
||||
pinned.onClick { model.pinBaseNote(!baseNote.pinned) }
|
||||
}
|
||||
|
||||
pinned.setVisible(count == 1)
|
||||
share.setVisible(count == 1)
|
||||
labels.setVisible(count == 1)
|
||||
export.setVisible(count == 1)
|
||||
changeColor.setVisible(true)
|
||||
|
||||
val folder = baseNote.folder
|
||||
delete.setVisible(folder == Folder.NOTES || folder == Folder.ARCHIVED)
|
||||
archive.setVisible(folder == Folder.NOTES)
|
||||
restore.setVisible(folder == Folder.DELETED)
|
||||
unarchive.setVisible(folder == Folder.ARCHIVED)
|
||||
deleteForever.setVisible(folder == Folder.DELETED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExportMenu(menu: Menu): MenuItem {
|
||||
return menu
|
||||
.addSubMenu(R.string.export)
|
||||
.apply {
|
||||
setIcon(R.drawable.export)
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
||||
add("PDF").onClick { exportToPDF() }
|
||||
add("TXT").onClick { exportToTXT() }
|
||||
add("JSON").onClick { exportToJSON() }
|
||||
add("HTML").onClick { exportToHTML() }
|
||||
}
|
||||
.item
|
||||
}
|
||||
|
||||
fun MenuItem.onClick(function: () -> Unit) {
|
||||
setOnMenuItemClickListener {
|
||||
function()
|
||||
return@setOnMenuItemClickListener false
|
||||
baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel))
|
||||
baseModel.actionMode.loading.observe(this@MainActivity) { loading ->
|
||||
menu.setGroupEnabled(Menu.NONE, !loading)
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveNotes(folderTo: Folder) {
|
||||
val folderFrom = model.actionMode.getFirstNote().folder
|
||||
val ids = model.moveBaseNotes(folderTo)
|
||||
if (baseModel.actionMode.loading.value || baseModel.actionMode.isEmpty()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
baseModel.actionMode.loading.value = true
|
||||
val folderFrom = baseModel.actionMode.getFirstNote().folder
|
||||
val ids = baseModel.moveBaseNotes(folderTo)
|
||||
Snackbar.make(
|
||||
findViewById(R.id.DrawerLayout),
|
||||
getQuantityString(folderTo.movedToResId(), ids.size),
|
||||
Snackbar.LENGTH_SHORT,
|
||||
)
|
||||
.apply { setAction(R.string.undo) { model.moveBaseNotes(ids, folderFrom) } }
|
||||
.apply { setAction(R.string.undo) { baseModel.moveBaseNotes(ids, folderFrom) } }
|
||||
.show()
|
||||
} finally {
|
||||
baseModel.actionMode.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun share() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
val body =
|
||||
when (baseNote.type) {
|
||||
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
|
||||
Type.LIST -> Operations.getBody(baseNote.items)
|
||||
}
|
||||
Operations.shareNote(this, baseNote.title, body)
|
||||
}
|
||||
|
||||
private fun changeColor() {
|
||||
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
|
||||
|
||||
val colorAdapter =
|
||||
ColorAdapter(
|
||||
object : ListItemListener {
|
||||
override fun onClick(position: Int) {
|
||||
dialog.dismiss()
|
||||
val color = Color.entries[position]
|
||||
model.colorBaseNote(color)
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {}
|
||||
}
|
||||
)
|
||||
|
||||
DialogColorBinding.inflate(layoutInflater).apply {
|
||||
RecyclerView.adapter = colorAdapter
|
||||
dialog.setView(root)
|
||||
dialog.show()
|
||||
}
|
||||
val baseNote = baseModel.actionMode.getFirstNote()
|
||||
this.shareNote(baseNote)
|
||||
}
|
||||
|
||||
private fun deleteForever() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.delete_selected_notes)
|
||||
.setPositiveButton(R.string.delete) { _, _ -> model.deleteSelectedBaseNotes() }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.delete) { _, _ -> baseModel.deleteSelectedBaseNotes() }
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun label() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
val baseNotes = baseModel.actionMode.selectedNotes.values
|
||||
lifecycleScope.launch {
|
||||
val labels = model.getAllLabels()
|
||||
val labels = baseModel.getAllLabels()
|
||||
if (labels.isNotEmpty()) {
|
||||
displaySelectLabelsDialog(labels, baseNote)
|
||||
displaySelectLabelsDialog(labels, baseNotes)
|
||||
} else {
|
||||
model.actionMode.close(true)
|
||||
baseModel.actionMode.close(true)
|
||||
navigateWithAnimation(R.id.Labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displaySelectLabelsDialog(labels: Array<String>, baseNote: BaseNote) {
|
||||
private fun displaySelectLabelsDialog(labels: Array<String>, baseNotes: Collection<BaseNote>) {
|
||||
val checkedPositions =
|
||||
BooleanArray(labels.size) { index -> baseNote.labels.contains(labels[index]) }
|
||||
labels
|
||||
.map { label ->
|
||||
if (baseNotes.all { it.labels.contains(label) }) {
|
||||
TriStateCheckBox.State.CHECKED
|
||||
} else if (baseNotes.any { it.labels.contains(label) }) {
|
||||
TriStateCheckBox.State.PARTIALLY_CHECKED
|
||||
} else {
|
||||
TriStateCheckBox.State.UNCHECKED
|
||||
}
|
||||
}
|
||||
.toTypedArray()
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.labels)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setMultiChoiceItems(labels, checkedPositions) { _, which, isChecked ->
|
||||
checkedPositions[which] = isChecked
|
||||
.setCancelButton()
|
||||
.setMultiChoiceTriStateItems(this, labels, checkedPositions) { idx, state ->
|
||||
checkedPositions[idx] = state
|
||||
}
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val new = ArrayList<String>()
|
||||
checkedPositions.forEachIndexed { index, checked ->
|
||||
if (checked) {
|
||||
val label = labels[index]
|
||||
new.add(label)
|
||||
val checkedLabels =
|
||||
checkedPositions.mapIndexedNotNull { index, checked ->
|
||||
if (checked == TriStateCheckBox.State.CHECKED) {
|
||||
labels[index]
|
||||
} else null
|
||||
}
|
||||
val uncheckedLabels =
|
||||
checkedPositions.mapIndexedNotNull { index, checked ->
|
||||
if (checked == TriStateCheckBox.State.UNCHECKED) {
|
||||
labels[index]
|
||||
} else null
|
||||
}
|
||||
val updatedBaseNotesLabels =
|
||||
baseNotes.map { baseNote ->
|
||||
val noteLabels = baseNote.labels.toMutableList()
|
||||
checkedLabels.forEach { checkedLabel ->
|
||||
if (!noteLabels.contains(checkedLabel)) {
|
||||
noteLabels.add(checkedLabel)
|
||||
}
|
||||
}
|
||||
model.updateBaseNoteLabels(new, baseNote.id)
|
||||
uncheckedLabels.forEach { uncheckedLabel ->
|
||||
if (noteLabels.contains(uncheckedLabel)) {
|
||||
noteLabels.remove(uncheckedLabel)
|
||||
}
|
||||
}
|
||||
noteLabels
|
||||
}
|
||||
baseNotes.zip(updatedBaseNotesLabels).forEach { (baseNote, updatedLabels) ->
|
||||
baseModel.updateBaseNoteLabels(updatedLabels, baseNote.id)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun exportToPDF() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
model.getPDFFile(
|
||||
baseNote,
|
||||
object : PostPDFGenerator.OnResult {
|
||||
|
||||
override fun onSuccess(file: File) {
|
||||
showFileOptionsDialog(file, "application/pdf")
|
||||
}
|
||||
|
||||
override fun onFailure(message: CharSequence?) {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
R.string.something_went_wrong,
|
||||
Toast.LENGTH_SHORT,
|
||||
private fun exportSelectedNotes(mimeType: ExportMimeType) {
|
||||
exportNotes(
|
||||
mimeType,
|
||||
baseModel.actionMode.selectedNotes.values,
|
||||
exportFileActivityResultLauncher,
|
||||
exportNotesActivityResultLauncher,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportToTXT() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
lifecycleScope.launch {
|
||||
val file = model.getTXTFile(baseNote)
|
||||
showFileOptionsDialog(file, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportToJSON() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
lifecycleScope.launch {
|
||||
val file = model.getJSONFile(baseNote)
|
||||
showFileOptionsDialog(file, "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportToHTML() {
|
||||
val baseNote = model.actionMode.getFirstNote()
|
||||
lifecycleScope.launch {
|
||||
val file = model.getHTMLFile(baseNote)
|
||||
showFileOptionsDialog(file, "text/html")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFileOptionsDialog(file: File, mimeType: String) {
|
||||
val uri = FileProvider.getUriForFile(this, "${packageName}.provider", file)
|
||||
|
||||
MenuDialog(this)
|
||||
.add(R.string.share) { shareFile(uri, mimeType) }
|
||||
.add(R.string.view_file) { viewFile(uri, mimeType) }
|
||||
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun viewFile(uri: Uri, mimeType: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
||||
val chooser = Intent.createChooser(intent, getString(R.string.view_note))
|
||||
startActivity(chooser)
|
||||
}
|
||||
|
||||
private fun shareFile(uri: Uri, mimeType: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
|
||||
val chooser = Intent.createChooser(intent, null)
|
||||
startActivity(chooser)
|
||||
}
|
||||
|
||||
private fun saveFileToDevice(file: File, mimeType: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
type = mimeType
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension)
|
||||
}
|
||||
|
||||
model.currentFile = file
|
||||
startActivityForResult(intent, REQUEST_EXPORT_FILE)
|
||||
}
|
||||
|
||||
private fun setupNavigation() {
|
||||
|
@ -420,35 +435,47 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
}
|
||||
)
|
||||
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
navController.addOnDestinationChangedListener { _, destination, bundle ->
|
||||
fragmentIdToLoad = destination.id
|
||||
when (fragmentIdToLoad) {
|
||||
R.id.DisplayLabel ->
|
||||
bundle?.getString(EXTRA_DISPLAYED_LABEL)?.let {
|
||||
baseModel.currentLabel = it
|
||||
binding.NavigationView.menu.children
|
||||
.find { menuItem -> menuItem.title == it }
|
||||
?.let { menuItem -> menuItem.isChecked = true }
|
||||
}
|
||||
R.id.Unlabeled -> {
|
||||
baseModel.currentLabel = CURRENT_LABEL_NONE
|
||||
binding.NavigationView.setCheckedItem(destination.id)
|
||||
}
|
||||
else -> {
|
||||
baseModel.currentLabel = CURRENT_LABEL_EMPTY
|
||||
binding.NavigationView.setCheckedItem(destination.id)
|
||||
handleDestinationChange(destination)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDestinationChange(destination: NavDestination) {
|
||||
if (destination.id == R.id.Notes) {
|
||||
when (destination.id) {
|
||||
R.id.Notes,
|
||||
R.id.DisplayLabel,
|
||||
R.id.Unlabeled -> {
|
||||
binding.TakeNote.show()
|
||||
binding.MakeList.show()
|
||||
} else {
|
||||
}
|
||||
|
||||
else -> {
|
||||
binding.TakeNote.hide()
|
||||
binding.MakeList.hide()
|
||||
}
|
||||
}
|
||||
isStartViewFragment = isStartViewFragment(destination.id, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
val inputManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
if (destination.id == R.id.Search) {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
visibility = View.VISIBLE
|
||||
requestFocus()
|
||||
inputManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
} else {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
visibility = View.GONE
|
||||
inputManager.hideSoftInputFromWindow(this.windowToken, 0)
|
||||
}
|
||||
}
|
||||
private fun isStartViewFragment(id: Int, bundle: Bundle?): Boolean {
|
||||
val (startViewId, startViewBundle) = getStartViewNavigation()
|
||||
return startViewId == id &&
|
||||
startViewBundle.getString(EXTRA_DISPLAYED_LABEL) ==
|
||||
bundle?.getString(EXTRA_DISPLAYED_LABEL)
|
||||
}
|
||||
|
||||
private fun navigateWithAnimation(id: Int) {
|
||||
|
@ -465,14 +492,196 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
navController.navigate(id, null, options)
|
||||
}
|
||||
|
||||
private fun setupSearch() {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
setText(model.keyword)
|
||||
doAfterTextChanged { text -> model.keyword = requireNotNull(text).trim().toString() }
|
||||
private fun setupActivityResultLaunchers() {
|
||||
exportFileActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> baseModel.exportSelectedFileToUri(uri) }
|
||||
}
|
||||
}
|
||||
exportNotesActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> baseModel.exportSelectedNotesToFolder(uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ModelFolderObserver(
|
||||
private val menu: Menu,
|
||||
private val model: BaseNoteModel,
|
||||
) : Observer<Folder> {
|
||||
override fun onChanged(value: Folder) {
|
||||
menu.clear()
|
||||
model.actionMode.count.removeObservers(this@MainActivity)
|
||||
|
||||
menu.add(
|
||||
R.string.select_all,
|
||||
R.drawable.select_all,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_ALWAYS,
|
||||
) {
|
||||
getCurrentFragmentNotes?.invoke()?.let { model.actionMode.add(it) }
|
||||
}
|
||||
when (value) {
|
||||
Folder.NOTES -> {
|
||||
val pinned = menu.addPinned(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
menu.addLabels(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
|
||||
menu.addChangeColor()
|
||||
val share = menu.addShare()
|
||||
menu.addExportMenu()
|
||||
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
|
||||
}
|
||||
|
||||
Folder.ARCHIVED -> {
|
||||
menu.add(
|
||||
R.string.unarchive,
|
||||
R.drawable.unarchive,
|
||||
MenuItem.SHOW_AS_ACTION_ALWAYS,
|
||||
) {
|
||||
moveNotes(Folder.NOTES)
|
||||
}
|
||||
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
menu.addExportMenu(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
val pinned = menu.addPinned()
|
||||
menu.addLabels()
|
||||
menu.addChangeColor()
|
||||
val share = menu.addShare()
|
||||
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
|
||||
}
|
||||
|
||||
Folder.DELETED -> {
|
||||
menu.add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
|
||||
moveNotes(Folder.NOTES)
|
||||
}
|
||||
menu.add(
|
||||
R.string.delete_forever,
|
||||
R.drawable.delete,
|
||||
MenuItem.SHOW_AS_ACTION_ALWAYS,
|
||||
) {
|
||||
deleteForever()
|
||||
}
|
||||
menu.addExportMenu()
|
||||
menu.addChangeColor()
|
||||
val share = menu.add(R.string.share, R.drawable.share) { share() }
|
||||
model.actionMode.count.observeCount(this@MainActivity, share)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Menu.addPinned(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
|
||||
return add(R.string.pin, R.drawable.pin, showAsAction) {}
|
||||
}
|
||||
|
||||
private fun Menu.addLabels(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
|
||||
return add(R.string.labels, R.drawable.label, showAsAction) { label() }
|
||||
}
|
||||
|
||||
private fun Menu.addChangeColor(
|
||||
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
|
||||
): MenuItem {
|
||||
return add(R.string.change_color, R.drawable.change_color, showAsAction) {
|
||||
lifecycleScope.launch {
|
||||
val colors =
|
||||
withContext(Dispatchers.IO) {
|
||||
NotallyDatabase.getDatabase(
|
||||
this@MainActivity,
|
||||
observePreferences = false,
|
||||
)
|
||||
.value
|
||||
.getBaseNoteDao()
|
||||
.getAllColors()
|
||||
}
|
||||
// Show color as selected only if all selected notes have the same color
|
||||
val currentColor =
|
||||
model.actionMode.selectedNotes.values
|
||||
.map { it.color }
|
||||
.distinct()
|
||||
.takeIf { it.size == 1 }
|
||||
?.firstOrNull()
|
||||
showColorSelectDialog(
|
||||
colors,
|
||||
currentColor,
|
||||
null,
|
||||
{ selectedColor, oldColor ->
|
||||
if (oldColor != null) {
|
||||
model.changeColor(oldColor, selectedColor)
|
||||
}
|
||||
model.colorBaseNote(selectedColor)
|
||||
},
|
||||
) { colorToDelete, newColor ->
|
||||
model.changeColor(colorToDelete, newColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Menu.addDelete(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
|
||||
return add(R.string.delete, R.drawable.delete, showAsAction) {
|
||||
moveNotes(Folder.DELETED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Menu.addShare(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
|
||||
return add(R.string.share, R.drawable.share, showAsAction) { share() }
|
||||
}
|
||||
|
||||
private fun Menu.addExportMenu(
|
||||
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
|
||||
): MenuItem {
|
||||
return addSubMenu(R.string.export)
|
||||
.apply {
|
||||
setIcon(R.drawable.export)
|
||||
item.setShowAsAction(showAsAction)
|
||||
ExportMimeType.entries.forEach {
|
||||
add(it.name).onClick { exportSelectedNotes(it) }
|
||||
}
|
||||
}
|
||||
.item
|
||||
}
|
||||
|
||||
fun MenuItem.onClick(function: () -> Unit) {
|
||||
setOnMenuItemClickListener {
|
||||
function()
|
||||
return@setOnMenuItemClickListener false
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotNullLiveData<Int>.observeCount(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
share: MenuItem,
|
||||
onCountChange: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
observe(lifecycleOwner) { count ->
|
||||
binding.ActionMode.title = count.toString()
|
||||
onCountChange?.invoke(count)
|
||||
share.setVisible(count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotNullLiveData<Int>.observeCountAndPinned(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
share: MenuItem,
|
||||
pinned: MenuItem,
|
||||
) {
|
||||
observeCount(lifecycleOwner, share) {
|
||||
val baseNotes = model.actionMode.selectedNotes.values
|
||||
if (baseNotes.any { !it.pinned }) {
|
||||
pinned.setTitle(R.string.pin).setIcon(R.drawable.pin).onClick {
|
||||
model.pinBaseNotes(true)
|
||||
}
|
||||
} else {
|
||||
pinned.setTitle(R.string.unpin).setIcon(R.drawable.unpin).onClick {
|
||||
model.pinBaseNotes(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_EXPORT_FILE = 10
|
||||
const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN"
|
||||
const val EXTRA_SKIP_START_VIEW_ON_BACK = "notallyx.intent.extra.SKIP_START_VIEW_ON_BACK"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
|
||||
class ArchivedFragment : NotallyFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
model.folder.value = Folder.ARCHIVED
|
||||
}
|
||||
|
||||
override fun getBackground() = R.drawable.archive
|
||||
|
||||
override fun getObservable() = model.archivedNotes!!
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.View
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
|
||||
class DeletedFragment : NotallyFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
model.folder.value = Folder.DELETED
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
menu.add(R.string.delete_all, R.drawable.delete_all) { deleteAllNotes() }
|
||||
}
|
||||
|
@ -16,7 +25,7 @@ class DeletedFragment : NotallyFragment() {
|
|||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.delete_all_notes)
|
||||
.setPositiveButton(R.string.delete) { _, _ -> model.deleteAllTrashedBaseNotes() }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,34 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.Item
|
||||
import com.philkes.notallyx.presentation.view.Constants
|
||||
|
||||
class DisplayLabelFragment : NotallyFragment() {
|
||||
|
||||
private lateinit var label: String
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
model.folder.value = Folder.NOTES
|
||||
}
|
||||
|
||||
override fun getBackground() = R.drawable.label
|
||||
|
||||
override fun getObservable(): LiveData<List<Item>> {
|
||||
val label = requireNotNull(requireArguments().getString(Constants.SelectedLabel))
|
||||
label = requireNotNull(requireArguments().getString(EXTRA_DISPLAYED_LABEL))
|
||||
return model.getNotesByLabel(label)
|
||||
}
|
||||
|
||||
override fun prepareNewNoteIntent(intent: Intent): Intent {
|
||||
return intent.putExtra(EXTRA_DISPLAYED_LABEL, label)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_DISPLAYED_LABEL = "notallyx.intent.extra.DISPLAYED_LABEL"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,28 +6,29 @@ import android.view.Menu
|
|||
import android.view.MenuInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Label
|
||||
import com.philkes.notallyx.databinding.DialogInputBinding
|
||||
import com.philkes.notallyx.databinding.FragmentNotesBinding
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.displayEditLabelDialog
|
||||
import com.philkes.notallyx.presentation.initListView
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.view.Constants
|
||||
import com.philkes.notallyx.presentation.view.main.LabelAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.main.label.LabelAdapter
|
||||
import com.philkes.notallyx.presentation.view.main.label.LabelData
|
||||
import com.philkes.notallyx.presentation.view.main.label.LabelListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
|
||||
class LabelsFragment : Fragment(), ListItemListener {
|
||||
class LabelsFragment : Fragment(), LabelListener {
|
||||
|
||||
private var labelAdapter: LabelAdapter? = null
|
||||
private var binding: FragmentNotesBinding? = null
|
||||
|
@ -43,13 +44,9 @@ class LabelsFragment : Fragment(), ListItemListener {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
labelAdapter = LabelAdapter(this)
|
||||
|
||||
binding?.RecyclerView?.apply {
|
||||
setHasFixedSize(true)
|
||||
binding?.MainListView?.apply {
|
||||
initListView(requireContext())
|
||||
adapter = labelAdapter
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
val itemDecoration = DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
|
||||
addItemDecoration(itemDecoration)
|
||||
setPadding(0, 0, 0, 0)
|
||||
binding?.ImageView?.setImageResource(R.drawable.label)
|
||||
}
|
||||
|
||||
|
@ -71,25 +68,45 @@ class LabelsFragment : Fragment(), ListItemListener {
|
|||
}
|
||||
|
||||
override fun onClick(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { value ->
|
||||
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
|
||||
val bundle = Bundle()
|
||||
bundle.putString(Constants.SelectedLabel, value)
|
||||
bundle.putString(EXTRA_DISPLAYED_LABEL, label)
|
||||
findNavController().navigate(R.id.LabelsToDisplayLabel, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {
|
||||
override fun onEdit(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
|
||||
displayEditLabelDialog(label, model)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDelete(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { (label, _) -> confirmDeletion(label) }
|
||||
}
|
||||
|
||||
override fun onToggleVisibility(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { value ->
|
||||
MenuDialog(requireContext())
|
||||
.add(R.string.edit) { displayEditLabelDialog(value) }
|
||||
.add(R.string.delete) { confirmDeletion(value) }
|
||||
.show()
|
||||
val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet()
|
||||
if (value.visibleInNavigation) {
|
||||
hiddenLabels.add(value.label)
|
||||
} else {
|
||||
hiddenLabels.remove(value.label)
|
||||
}
|
||||
model.savePreference(model.preferences.labelsHidden, hiddenLabels)
|
||||
|
||||
val currentList = labelAdapter!!.currentList.toMutableList()
|
||||
currentList[position] =
|
||||
currentList[position].copy(visibleInNavigation = !value.visibleInNavigation)
|
||||
labelAdapter!!.submitList(currentList)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupObserver() {
|
||||
model.labels.observe(viewLifecycleOwner) { labels ->
|
||||
labelAdapter?.submitList(labels)
|
||||
val hiddenLabels = model.preferences.labelsHidden.value
|
||||
val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
|
||||
labelAdapter?.submitList(labelsData)
|
||||
binding?.ImageView?.isVisible = labels.isEmpty()
|
||||
}
|
||||
}
|
||||
|
@ -101,7 +118,7 @@ class LabelsFragment : Fragment(), ListItemListener {
|
|||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.add_label)
|
||||
.setView(dialogBinding.root)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val value = dialogBinding.EditText.text.toString().trim()
|
||||
if (value.isNotEmpty()) {
|
||||
|
@ -109,12 +126,18 @@ class LabelsFragment : Fragment(), ListItemListener {
|
|||
model.insertLabel(label) { success: Boolean ->
|
||||
if (success) {
|
||||
dialog.dismiss()
|
||||
} else
|
||||
Toast.makeText(context, R.string.label_exists, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
showToast(R.string.label_exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
.showAndFocus(dialogBinding.EditText)
|
||||
}
|
||||
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
|
||||
dialogBinding.EditText.doAfterTextChanged { text ->
|
||||
positiveButton.isEnabled = !text.isNullOrEmpty()
|
||||
}
|
||||
positiveButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmDeletion(value: String) {
|
||||
|
@ -122,35 +145,7 @@ class LabelsFragment : Fragment(), ListItemListener {
|
|||
.setTitle(R.string.delete_label)
|
||||
.setMessage(R.string.your_notes_associated)
|
||||
.setPositiveButton(R.string.delete) { _, _ -> model.deleteLabel(value) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displayEditLabelDialog(oldValue: String) {
|
||||
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
|
||||
|
||||
dialogBinding.EditText.setText(oldValue)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(dialogBinding.root)
|
||||
.setTitle(R.string.edit_label)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val value = dialogBinding.EditText.text.toString().trim()
|
||||
if (value.isNotEmpty()) {
|
||||
model.updateLabel(oldValue, value) { success ->
|
||||
if (success) {
|
||||
dialog.dismiss()
|
||||
} else
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.label_exists,
|
||||
Toast.LENGTH_LONG,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.showAndFocus(dialogBinding.EditText)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,14 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
|
@ -20,22 +24,31 @@ import com.philkes.notallyx.data.model.Folder
|
|||
import com.philkes.notallyx.data.model.Item
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.databinding.FragmentNotesBinding
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.FOLDER_FROM
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.FOLDER_TO
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
|
||||
import com.philkes.notallyx.presentation.activity.note.EditListActivity
|
||||
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.hideKeyboard
|
||||
import com.philkes.notallyx.presentation.movedToResId
|
||||
import com.philkes.notallyx.presentation.view.Constants
|
||||
import com.philkes.notallyx.presentation.showKeyboard
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.View as ViewPref
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
|
||||
|
||||
abstract class NotallyFragment : Fragment(), ListItemListener {
|
||||
abstract class NotallyFragment : Fragment(), ItemListener {
|
||||
|
||||
private var notesAdapter: BaseNoteAdapter? = null
|
||||
private lateinit var openNoteActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private var lastSelectedNotePosition = -1
|
||||
|
||||
internal var binding: FragmentNotesBinding? = null
|
||||
|
||||
internal val model: BaseNoteModel by activityViewModels()
|
||||
|
@ -46,12 +59,67 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
notesAdapter = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
|
||||
if (layoutManager != null) {
|
||||
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
|
||||
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
|
||||
val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition)
|
||||
val offset = firstVisibleView?.top ?: 0
|
||||
outState.putInt(EXTRA_SCROLL_POS, firstVisiblePosition)
|
||||
outState.putInt(EXTRA_SCROLL_OFFSET, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding?.ImageView?.setImageResource(getBackground())
|
||||
|
||||
setupAdapter()
|
||||
setupRecyclerView()
|
||||
setupObserver()
|
||||
setupSearch()
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
|
||||
savedInstanceState?.let { bundle ->
|
||||
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
|
||||
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
|
||||
if (scrollPosition > -1) {
|
||||
binding?.MainListView?.post {
|
||||
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
|
||||
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
openNoteActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
// If a note has been moved inside of EditActivity
|
||||
// present snackbar to undo it
|
||||
val data = result.data
|
||||
val id = data?.getLongExtra(EXTRA_NOTE_ID, -1)
|
||||
if (id != null) {
|
||||
val folderFrom = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_FROM)!!)
|
||||
val folderTo = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_TO)!!)
|
||||
Snackbar.make(
|
||||
binding!!.root,
|
||||
requireContext().getQuantityString(folderTo.movedToResId(), 1),
|
||||
Snackbar.LENGTH_SHORT,
|
||||
)
|
||||
.apply {
|
||||
setAction(R.string.undo) {
|
||||
model.moveBaseNotes(longArrayOf(id), folderFrom)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
|
@ -84,6 +152,22 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
|
||||
override fun onLongClick(position: Int) {
|
||||
if (position != -1) {
|
||||
if (model.actionMode.selectedNotes.isNotEmpty()) {
|
||||
if (lastSelectedNotePosition > position) {
|
||||
position..lastSelectedNotePosition
|
||||
} else {
|
||||
lastSelectedNotePosition..position
|
||||
}
|
||||
.forEach { pos ->
|
||||
notesAdapter!!.getItem(pos)?.let { item ->
|
||||
if (item is BaseNote) {
|
||||
if (!model.actionMode.selectedNotes.contains(item.id)) {
|
||||
handleNoteSelection(item.id, pos, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notesAdapter?.getItem(position)?.let { item ->
|
||||
if (item is BaseNote) {
|
||||
handleNoteSelection(item.id, position, item)
|
||||
|
@ -91,28 +175,40 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (resultCode == RESULT_OK) {
|
||||
if (requestCode == REQUEST_NOTE_EDIT) {
|
||||
// If a note has been moved inside of EditActivity
|
||||
// present snackbar to undo it
|
||||
val id = data?.getLongExtra(NOTE_ID, -1)
|
||||
if (id != null) {
|
||||
val folderFrom = Folder.valueOf(data.getStringExtra(FOLDER_FROM)!!)
|
||||
val folderTo = Folder.valueOf(data.getStringExtra(FOLDER_TO)!!)
|
||||
Snackbar.make(
|
||||
binding!!.root,
|
||||
requireContext().getQuantityString(folderTo.movedToResId(), 1),
|
||||
Snackbar.LENGTH_SHORT,
|
||||
private fun setupSearch() {
|
||||
binding?.EnterSearchKeyword?.apply {
|
||||
setText(model.keyword)
|
||||
val navController = findNavController()
|
||||
navController.addOnDestinationChangedListener { controller, destination, arguments ->
|
||||
if (destination.id == R.id.Search) {
|
||||
// setText("")
|
||||
visibility = View.VISIBLE
|
||||
requestFocus()
|
||||
activity?.showKeyboard(this)
|
||||
} else {
|
||||
// visibility = View.GONE
|
||||
setText("")
|
||||
clearFocus()
|
||||
activity?.hideKeyboard(this)
|
||||
}
|
||||
}
|
||||
doAfterTextChanged { text ->
|
||||
val isSearchFragment = navController.currentDestination?.id == R.id.Search
|
||||
if (isSearchFragment) {
|
||||
model.keyword = requireNotNull(text).trim().toString()
|
||||
}
|
||||
if (text?.isNotEmpty() == true && !isSearchFragment) {
|
||||
setText("")
|
||||
model.keyword = text.trim().toString()
|
||||
navController.navigate(
|
||||
R.id.Search,
|
||||
Bundle().apply {
|
||||
putSerializable(EXTRA_INITIAL_FOLDER, model.folder.value)
|
||||
putSerializable(EXTRA_INITIAL_LABEL, model.currentLabel)
|
||||
},
|
||||
)
|
||||
.apply {
|
||||
setAction(R.string.undo) {
|
||||
model.moveBaseNotes(longArrayOf(id), folderFrom)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,22 +217,28 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) {
|
||||
if (model.actionMode.selectedNotes.contains(id)) {
|
||||
model.actionMode.remove(id)
|
||||
} else model.actionMode.add(id, baseNote)
|
||||
} else {
|
||||
model.actionMode.add(id, baseNote)
|
||||
lastSelectedNotePosition = position
|
||||
}
|
||||
notesAdapter?.notifyItemChanged(position, 0)
|
||||
}
|
||||
|
||||
private fun setupAdapter() {
|
||||
|
||||
notesAdapter =
|
||||
with(model.preferences) {
|
||||
BaseNoteAdapter(
|
||||
model.actionMode.selectedIds,
|
||||
dateFormat.value,
|
||||
notesSorting.value.first,
|
||||
notesSorting.value,
|
||||
BaseNoteVHPreferences(
|
||||
textSize.value,
|
||||
maxItems,
|
||||
maxLines,
|
||||
maxTitle,
|
||||
maxItems.value,
|
||||
maxLines.value,
|
||||
maxTitle.value,
|
||||
labelTagsHiddenInOverview.value,
|
||||
imagesHiddenInOverview.value,
|
||||
),
|
||||
model.imageRoot,
|
||||
this@NotallyFragment,
|
||||
)
|
||||
|
@ -146,14 +248,20 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (itemCount > 0) {
|
||||
binding?.RecyclerView?.scrollToPosition(positionStart)
|
||||
binding?.MainListView?.scrollToPosition(positionStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
binding?.RecyclerView?.apply {
|
||||
binding?.MainListView?.apply {
|
||||
adapter = notesAdapter
|
||||
setHasFixedSize(true)
|
||||
setHasFixedSize(false)
|
||||
}
|
||||
model.actionMode.addListener = { notesAdapter?.notifyDataSetChanged() }
|
||||
if (activity is MainActivity) {
|
||||
(activity as MainActivity).getCurrentFragmentNotes = {
|
||||
notesAdapter?.currentList?.filterIsInstance<BaseNote>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,8 +271,8 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
binding?.ImageView?.isVisible = list.isEmpty()
|
||||
}
|
||||
|
||||
model.preferences.notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) ->
|
||||
notesAdapter?.setSorting(sortBy, sortDirection)
|
||||
model.preferences.notesSorting.observe(viewLifecycleOwner) { notesSort ->
|
||||
notesAdapter?.setNotesSort(notesSort)
|
||||
}
|
||||
|
||||
model.actionMode.closeListener.observe(viewLifecycleOwner) { event ->
|
||||
|
@ -179,23 +287,28 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
|
|||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding?.RecyclerView?.layoutManager =
|
||||
if (model.preferences.view.value == ViewPref.grid) {
|
||||
binding?.MainListView?.layoutManager =
|
||||
if (model.preferences.notesView.value == NotesView.GRID) {
|
||||
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
|
||||
} else LinearLayoutManager(requireContext())
|
||||
}
|
||||
|
||||
private fun goToActivity(activity: Class<*>, baseNote: BaseNote) {
|
||||
val intent = Intent(requireContext(), activity)
|
||||
intent.putExtra(Constants.SelectedBaseNote, baseNote.id)
|
||||
startActivityForResult(intent, REQUEST_NOTE_EDIT)
|
||||
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, baseNote.id)
|
||||
openNoteActivityResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
abstract fun getBackground(): Int
|
||||
|
||||
abstract fun getObservable(): LiveData<List<Item>>
|
||||
|
||||
open fun prepareNewNoteIntent(intent: Intent): Intent {
|
||||
return intent
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_NOTE_EDIT = 11
|
||||
private const val EXTRA_SCROLL_POS = "notallyx.intent.extra.SCROLL_POS"
|
||||
private const val EXTRA_SCROLL_OFFSET = "notallyx.intent.extra.SCROLL_OFFSET"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
|
||||
class NotesFragment : NotallyFragment() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
menu.add(R.string.search, R.drawable.search) {
|
||||
findNavController().navigate(R.id.NotesToSearch)
|
||||
}
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
model.folder.value = Folder.NOTES
|
||||
}
|
||||
|
||||
override fun getObservable() = model.baseNotes!!
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.dao.NoteReminder
|
||||
import com.philkes.notallyx.data.model.hasAnyUpcomingNotifications
|
||||
import com.philkes.notallyx.databinding.FragmentRemindersBinding
|
||||
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
|
||||
import com.philkes.notallyx.presentation.initListView
|
||||
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderAdapter
|
||||
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.utils.getOpenNoteIntent
|
||||
|
||||
class RemindersFragment : Fragment(), NoteReminderListener {
|
||||
|
||||
private var reminderAdapter: NoteReminderAdapter? = null
|
||||
private var binding: FragmentRemindersBinding? = null
|
||||
private lateinit var allReminders: List<NoteReminder>
|
||||
|
||||
private val model: BaseNoteModel by activityViewModels()
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
binding = null
|
||||
reminderAdapter = null
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
reminderAdapter = NoteReminderAdapter(this)
|
||||
|
||||
binding?.MainListView?.apply {
|
||||
initListView(requireContext())
|
||||
adapter = reminderAdapter
|
||||
binding?.ImageView?.setImageResource(R.drawable.notifications)
|
||||
}
|
||||
binding?.ChipGroup?.setOnCheckedStateChangeListener { _, _ -> updateList() }
|
||||
|
||||
model.reminders.observe(viewLifecycleOwner) { reminders ->
|
||||
allReminders = reminders.sortedBy { it.title }
|
||||
updateList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View? {
|
||||
setHasOptionsMenu(true)
|
||||
binding = FragmentRemindersBinding.inflate(inflater)
|
||||
return binding?.root
|
||||
}
|
||||
|
||||
private fun updateList() {
|
||||
val list =
|
||||
when (binding?.ChipGroup?.checkedChipId) {
|
||||
R.id.Upcoming -> allReminders.filter { it.reminders.hasAnyUpcomingNotifications() }
|
||||
R.id.Past -> allReminders.filter { !it.reminders.hasAnyUpcomingNotifications() }
|
||||
else -> allReminders
|
||||
}
|
||||
reminderAdapter?.submitList(list)
|
||||
binding?.ImageView?.isVisible = list.isEmpty()
|
||||
}
|
||||
|
||||
override fun openReminder(reminder: NoteReminder) {
|
||||
val intent =
|
||||
Intent(requireContext(), RemindersActivity::class.java).apply {
|
||||
putExtra(RemindersActivity.NOTE_ID, reminder.id)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun openNote(reminder: NoteReminder) {
|
||||
startActivity(requireContext().getOpenNoteIntent(reminder.id, reminder.type))
|
||||
}
|
||||
}
|
|
@ -3,38 +3,59 @@ package com.philkes.notallyx.presentation.activity.main.fragment
|
|||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
|
||||
class SearchFragment : NotallyFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
// TODO: autofocus and show keyboard
|
||||
val initialFolder =
|
||||
arguments?.let {
|
||||
BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java)
|
||||
}
|
||||
binding?.ChipGroup?.visibility = View.VISIBLE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding?.RecyclerView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
|
||||
binding?.MainListView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
|
||||
}
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val initialLabel = arguments?.getString(EXTRA_INITIAL_LABEL)
|
||||
model.currentLabel = initialLabel
|
||||
if (initialLabel?.isEmpty() == true) {
|
||||
val checked =
|
||||
when (model.folder) {
|
||||
when (initialFolder ?: model.folder.value) {
|
||||
Folder.NOTES -> R.id.Notes
|
||||
Folder.DELETED -> R.id.Deleted
|
||||
Folder.ARCHIVED -> R.id.Archived
|
||||
}
|
||||
|
||||
binding?.ChipGroup?.apply {
|
||||
setOnCheckedChangeListener { _, checkedId ->
|
||||
when (checkedId) {
|
||||
R.id.Notes -> model.folder = Folder.NOTES
|
||||
R.id.Deleted -> model.folder = Folder.DELETED
|
||||
R.id.Archived -> model.folder = Folder.ARCHIVED
|
||||
setOnCheckedStateChangeListener { _, checkedId ->
|
||||
when (checkedId.first()) {
|
||||
R.id.Notes -> model.folder.value = Folder.NOTES
|
||||
R.id.Deleted -> model.folder.value = Folder.DELETED
|
||||
R.id.Archived -> model.folder.value = Folder.ARCHIVED
|
||||
}
|
||||
}
|
||||
check(checked)
|
||||
isVisible = true
|
||||
}
|
||||
} else binding?.ChipGroup?.isVisible = false
|
||||
getObservable().observe(viewLifecycleOwner) { items ->
|
||||
model.actionMode.updateSelected(items?.filterIsInstance<BaseNote>()?.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBackground() = R.drawable.search
|
||||
|
||||
override fun getObservable() = model.searchResults!!
|
||||
|
||||
companion object {
|
||||
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER"
|
||||
const val EXTRA_INITIAL_LABEL = "notallyx.intent.extra.INITIAL_LABEL"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,654 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||
import com.philkes.notallyx.NotallyXApplication
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.imports.ImportSource
|
||||
import com.philkes.notallyx.databinding.ChoiceItemBinding
|
||||
import com.philkes.notallyx.databinding.FragmentSettingsBinding
|
||||
import com.philkes.notallyx.databinding.NotesSortDialogBinding
|
||||
import com.philkes.notallyx.databinding.PreferenceBinding
|
||||
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
|
||||
import com.philkes.notallyx.databinding.TextInputDialogBinding
|
||||
import com.philkes.notallyx.presentation.canAuthenticateWithBiometrics
|
||||
import com.philkes.notallyx.presentation.checkedTag
|
||||
import com.philkes.notallyx.presentation.setupImportProgressDialog
|
||||
import com.philkes.notallyx.presentation.setupProgressDialog
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackup
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
|
||||
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
|
||||
import com.philkes.notallyx.presentation.view.misc.BackupPassword
|
||||
import com.philkes.notallyx.presentation.view.misc.BackupPassword.emptyPassword
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock.disabled
|
||||
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
|
||||
import com.philkes.notallyx.presentation.view.misc.DateFormat
|
||||
import com.philkes.notallyx.presentation.view.misc.ListInfo
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxItems
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxLines
|
||||
import com.philkes.notallyx.presentation.view.misc.MaxTitle
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting
|
||||
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.view.misc.TextSize
|
||||
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.Theme
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import com.philkes.notallyx.utils.backup.Export.scheduleAutoBackup
|
||||
import com.philkes.notallyx.utils.security.decryptDatabase
|
||||
import com.philkes.notallyx.utils.security.encryptDatabase
|
||||
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private val model: BaseNoteModel by activityViewModels()
|
||||
|
||||
private lateinit var selectedImportSource: ImportSource
|
||||
|
||||
private fun setupBinding(binding: FragmentSettingsBinding) {
|
||||
model.preferences.apply {
|
||||
view.observe(viewLifecycleOwner) { value ->
|
||||
binding.View.setup(com.philkes.notallyx.presentation.view.misc.View, value)
|
||||
}
|
||||
|
||||
theme.observe(viewLifecycleOwner) { value -> binding.Theme.setup(Theme, value) }
|
||||
|
||||
dateFormat.observe(viewLifecycleOwner) { value ->
|
||||
binding.DateFormat.setup(DateFormat, value)
|
||||
}
|
||||
|
||||
textSize.observe(viewLifecycleOwner) { value ->
|
||||
binding.TextSize.setup(TextSize, value)
|
||||
}
|
||||
|
||||
notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) ->
|
||||
binding.NotesSortOrder.setup(NotesSorting, sortBy, sortDirection)
|
||||
}
|
||||
|
||||
// TODO: Hide for now until checked auto-sort is working reliably
|
||||
// listItemSorting.observe(viewLifecycleOwner) { value ->
|
||||
// binding.CheckedListItemSorting.setup(ListItemSorting, value)
|
||||
// }
|
||||
|
||||
binding.MaxItems.setup(MaxItems, maxItems)
|
||||
|
||||
binding.MaxLines.setup(MaxLines, maxLines)
|
||||
|
||||
binding.MaxTitle.setup(MaxTitle, maxTitle)
|
||||
|
||||
binding.AutoBackupMax.setup(AutoBackupMax, autoBackupMax)
|
||||
|
||||
autoBackupPath.observe(viewLifecycleOwner) { value ->
|
||||
binding.AutoBackup.setup(AutoBackup, value)
|
||||
}
|
||||
|
||||
autoBackupPeriodDays.observe(viewLifecycleOwner) { value ->
|
||||
binding.AutoBackupPeriodDays.setup(AutoBackupPeriodDays, value)
|
||||
scheduleAutoBackup(value.toLong(), requireContext())
|
||||
}
|
||||
|
||||
backupPassword.observe(viewLifecycleOwner) { value ->
|
||||
binding.BackupPassword.setup(BackupPassword, value)
|
||||
}
|
||||
|
||||
biometricLock.observe(viewLifecycleOwner) { value ->
|
||||
binding.BiometricLock.setup(BiometricLock, value)
|
||||
}
|
||||
}
|
||||
|
||||
binding.ImportBackup.setOnClickListener { importBackup() }
|
||||
binding.ImportOther.setOnClickListener { importOther() }
|
||||
|
||||
binding.ExportBackup.setOnClickListener { exportBackup() }
|
||||
|
||||
binding.ClearData.setOnClickListener { clearData() }
|
||||
|
||||
model.exportProgress.setupProgressDialog(this, R.string.exporting_backup)
|
||||
model.importProgress.setupImportProgressDialog(this, R.string.importing_backup)
|
||||
model.deletionProgress.setupProgressDialog(this, R.string.deleting_files)
|
||||
|
||||
binding.GitHub.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
|
||||
|
||||
binding.Libraries.setOnClickListener { displayLibraries() }
|
||||
|
||||
binding.Rate.setOnClickListener {
|
||||
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
|
||||
}
|
||||
|
||||
binding.SendFeedback.setOnClickListener { sendFeedback() }
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val binding = FragmentSettingsBinding.inflate(inflater)
|
||||
setupBinding(binding)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
intent?.data?.let { uri ->
|
||||
when (requestCode) {
|
||||
REQUEST_IMPORT_BACKUP -> importBackup(uri)
|
||||
REQUEST_EXPORT_BACKUP -> model.exportBackup(uri)
|
||||
REQUEST_CHOOSE_FOLDER -> model.setAutoBackupPath(uri)
|
||||
REQUEST_IMPORT_OTHER -> model.importFromOtherApp(uri, selectedImportSource)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
when (requestCode) {
|
||||
REQUEST_SETUP_LOCK -> showEnableBiometricLock()
|
||||
REQUEST_DISABLE_LOCK -> showDisableBiometricLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importBackup(uri: Uri) {
|
||||
when (requireContext().contentResolver.getType(uri)) {
|
||||
"text/xml" -> {
|
||||
model.importXmlBackup(uri)
|
||||
}
|
||||
|
||||
"application/zip" -> {
|
||||
val layout = TextInputDialogBinding.inflate(layoutInflater, null, false)
|
||||
val password = model.preferences.backupPassword.value
|
||||
layout.InputText.apply {
|
||||
if (password != emptyPassword) {
|
||||
setText(password)
|
||||
}
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
|
||||
layout.Message.apply {
|
||||
setText(R.string.import_backup_password_hint)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.backup_password)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.import_backup) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val usedPassword = layout.InputText.text.toString()
|
||||
model.importZipBackup(uri, usedPassword)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportBackup() {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
type = "application/zip"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_EXPORT_BACKUP)
|
||||
}
|
||||
|
||||
private fun importBackup() {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/zip", "text/xml"))
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_IMPORT_BACKUP)
|
||||
}
|
||||
|
||||
private fun clearData() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.clear_data_message)
|
||||
.setPositiveButton(R.string.delete_all) { _, _ -> model.deleteAllBaseNotes() }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun importOther() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.choose_other_app)
|
||||
.setAdapter(
|
||||
TextWithIconAdapter(
|
||||
requireContext(),
|
||||
ImportSource.entries.toMutableList(),
|
||||
{ item -> getString(item.displayNameResId) },
|
||||
ImportSource::iconResId,
|
||||
)
|
||||
) { _, which ->
|
||||
selectedImportSource = ImportSource.entries[which]
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(selectedImportSource.helpTextResId)
|
||||
.setPositiveButton(R.string.import_action) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
type = "ap/*"
|
||||
putExtra(
|
||||
Intent.EXTRA_MIME_TYPES,
|
||||
arrayOf(selectedImportSource.mimeType),
|
||||
)
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_IMPORT_OTHER)
|
||||
}
|
||||
.setNegativeButton(R.string.help) { _, _ ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(selectedImportSource.documentationUrl)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNeutralButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun sendFeedback() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
val options =
|
||||
arrayOf(getString(R.string.report_bug), getString(R.string.make_feature_request))
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.send_feedback)
|
||||
.setItems(options) { _, which ->
|
||||
val intent =
|
||||
when (which) {
|
||||
0 -> {
|
||||
val app = requireContext().applicationContext as Application
|
||||
val logs = Operations.getLastExceptionLog(app)
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(
|
||||
"https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml&logs=$logs"
|
||||
.take(2000)
|
||||
),
|
||||
)
|
||||
}
|
||||
else ->
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(
|
||||
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
|
||||
),
|
||||
)
|
||||
}
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displayLibraries() {
|
||||
val libraries =
|
||||
arrayOf(
|
||||
"Glide",
|
||||
"Pretty Time",
|
||||
"Swipe Layout",
|
||||
"Work Manager",
|
||||
"Subsampling Scale ImageView",
|
||||
"Material Components for Android",
|
||||
"SQLCipher",
|
||||
"Zip4J",
|
||||
)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.libraries)
|
||||
.setItems(libraries) { _, which ->
|
||||
when (which) {
|
||||
0 -> openLink("https://github.com/bumptech/glide")
|
||||
1 -> openLink("https://github.com/ocpsoft/prettytime")
|
||||
2 -> openLink("https://github.com/zerobranch/SwipeLayout")
|
||||
3 -> openLink("https://developer.android.com/jetpack/androidx/releases/work")
|
||||
4 -> openLink("https://github.com/davemorrissey/subsampling-scale-image-view")
|
||||
5 ->
|
||||
openLink(
|
||||
"https://github.com/material-components/material-components-android"
|
||||
)
|
||||
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
|
||||
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displayChooseFolderDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.notes_will_be)
|
||||
.setPositiveButton(R.string.choose_folder) { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(intent, REQUEST_CHOOSE_FOLDER)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun PreferenceBinding.setup(info: ListInfo, value: String) {
|
||||
Title.setText(info.title)
|
||||
|
||||
val entries = info.getEntries(requireContext())
|
||||
val entryValues = info.getEntryValues()
|
||||
|
||||
val checked = entryValues.indexOf(value)
|
||||
val displayValue = entries[checked]
|
||||
|
||||
Value.text = displayValue
|
||||
|
||||
root.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(info.title)
|
||||
.setSingleChoiceItems(entries, checked) { dialog, which ->
|
||||
dialog.cancel()
|
||||
val newValue = entryValues[which]
|
||||
model.savePreference(info, newValue)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceBinding.setup(
|
||||
info: NotesSorting,
|
||||
sortBy: String,
|
||||
sortDirection: SortDirection,
|
||||
) {
|
||||
Title.setText(info.title)
|
||||
|
||||
val entries = info.getEntries(requireContext())
|
||||
val entryValues = info.getEntryValues()
|
||||
|
||||
val checked = entryValues.indexOf(sortBy)
|
||||
val displayValue = entries[checked]
|
||||
|
||||
Value.text = "$displayValue (${requireContext().getString(sortDirection.textResId)})"
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = NotesSortDialogBinding.inflate(layoutInflater, null, false)
|
||||
entries.zip(entryValues).forEachIndexed { idx, (choiceText, sortByValue) ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = choiceText
|
||||
tag = sortByValue
|
||||
layout.NotesSortByRadioGroup.addView(this)
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
NotesSorting.getSortIconResId(sortByValue),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if (sortByValue == sortBy) {
|
||||
layout.NotesSortByRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SortDirection.entries.forEachIndexed { idx, sortDir ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = requireContext().getString(sortDir.textResId)
|
||||
tag = sortDir
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
|
||||
layout.NotesSortDirectionRadioGroup.addView(this)
|
||||
if (sortDir == sortDirection) {
|
||||
layout.NotesSortDirectionRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(info.title)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as String
|
||||
val newSortDirection =
|
||||
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
|
||||
model.preferences.savePreference(info, newSortBy, newSortDirection)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceBinding.setup(info: BackupPassword, password: String) {
|
||||
Title.setText(info.title)
|
||||
|
||||
Value.transformationMethod =
|
||||
if (password != emptyPassword) PasswordTransformationMethod.getInstance() else null
|
||||
Value.text = if (password != emptyPassword) password else getText(R.string.tap_to_set_up)
|
||||
root.setOnClickListener {
|
||||
val layout = TextInputDialogBinding.inflate(layoutInflater, null, false)
|
||||
layout.InputText.apply {
|
||||
if (password != emptyPassword) {
|
||||
setText(password)
|
||||
}
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
|
||||
layout.Message.apply {
|
||||
setText(R.string.backup_password_hint)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(info.title)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val updatedPassword = layout.InputText.text.toString()
|
||||
model.preferences.savePreference(info, updatedPassword)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setNeutralButton(R.string.clear) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
model.preferences.savePreference(info, emptyPassword)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceBinding.setup(info: BiometricLock, value: String) {
|
||||
Title.setText(info.title)
|
||||
|
||||
val entries = info.getEntries(requireContext())
|
||||
val entryValues = info.getEntryValues()
|
||||
|
||||
val checked = entryValues.indexOf(value)
|
||||
val displayValue = entries[checked]
|
||||
|
||||
Value.text = displayValue
|
||||
|
||||
root.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(info.title)
|
||||
.setSingleChoiceItems(entries, checked) { dialog, which ->
|
||||
dialog.cancel()
|
||||
val newValue = entryValues[which]
|
||||
if (newValue == enabled) {
|
||||
when (requireContext().canAuthenticateWithBiometrics()) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> showEnableBiometricLock()
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
|
||||
showNoBiometricsSupportToast()
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
|
||||
showBiometricsNotSetupDialog()
|
||||
}
|
||||
} else {
|
||||
when (requireContext().canAuthenticateWithBiometrics()) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> showDisableBiometricLock()
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
|
||||
showNoBiometricsSupportToast()
|
||||
model.preferences.biometricLock.value = disabled
|
||||
}
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
|
||||
showBiometricsNotSetupDialog()
|
||||
model.preferences.biometricLock.value = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEnableBiometricLock() {
|
||||
showBiometricOrPinPrompt(
|
||||
false,
|
||||
REQUEST_SETUP_LOCK,
|
||||
R.string.enable_lock_title,
|
||||
R.string.enable_lock_description,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
model.preferences.iv = cipher.iv
|
||||
val passphrase = model.preferences.generatePassphrase(cipher)
|
||||
encryptDatabase(requireContext(), passphrase)
|
||||
model.savePreference(BiometricLock, enabled)
|
||||
}
|
||||
(activity?.application as NotallyXApplication).isLocked = false
|
||||
showBiometricsEnabledToast()
|
||||
},
|
||||
) {
|
||||
showBiometricsNotSetupDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDisableBiometricLock() {
|
||||
showBiometricOrPinPrompt(
|
||||
true,
|
||||
REQUEST_DISABLE_LOCK,
|
||||
R.string.disable_lock_title,
|
||||
R.string.disable_lock_description,
|
||||
model.preferences.iv!!,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val encryptedPassphrase = model.preferences.getDatabasePassphrase()
|
||||
val passphrase = cipher.doFinal(encryptedPassphrase)
|
||||
model.closeDatabase()
|
||||
decryptDatabase(requireContext(), passphrase)
|
||||
model.savePreference(BiometricLock, disabled)
|
||||
}
|
||||
showBiometricsDisabledToast()
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
private fun showNoBiometricsSupportToast() {
|
||||
ContextCompat.getMainExecutor(requireContext()).execute {
|
||||
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricsEnabledToast() {
|
||||
ContextCompat.getMainExecutor(requireContext()).execute {
|
||||
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricsDisabledToast() {
|
||||
ContextCompat.getMainExecutor(requireContext()).execute {
|
||||
Toast.makeText(requireContext(), R.string.biometrics_disable_success, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricsNotSetupDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.biometrics_not_setup)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setPositiveButton(R.string.tap_to_set_up) { _, _ ->
|
||||
val intent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
|
||||
} else {
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_SETUP_LOCK)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun PreferenceBinding.setup(info: AutoBackup, value: String) {
|
||||
Title.setText(info.title)
|
||||
|
||||
if (value == info.emptyPath) {
|
||||
Value.setText(R.string.tap_to_set_up)
|
||||
|
||||
root.setOnClickListener { displayChooseFolderDialog() }
|
||||
} else {
|
||||
val uri = Uri.parse(value)
|
||||
val folder = requireNotNull(DocumentFile.fromTreeUri(requireContext(), uri))
|
||||
if (folder.exists()) {
|
||||
Value.text = folder.name
|
||||
} else Value.setText(R.string.cant_find_folder)
|
||||
|
||||
root.setOnClickListener {
|
||||
MenuDialog(requireContext())
|
||||
.add(R.string.disable_auto_backup) { model.disableAutoBackup() }
|
||||
.add(R.string.choose_another_folder) { displayChooseFolderDialog() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceSeekbarBinding.setup(info: SeekbarInfo, initialValue: Int) {
|
||||
Title.setText(info.title)
|
||||
|
||||
Slider.apply {
|
||||
valueTo = info.max.toFloat()
|
||||
valueFrom = info.min.toFloat()
|
||||
value = initialValue.toFloat()
|
||||
addOnChangeListener { _, value, _ -> model.savePreference(info, value.toInt()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLink(link: String) {
|
||||
val uri = Uri.parse(link)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_IMPORT_BACKUP = 20
|
||||
private const val REQUEST_EXPORT_BACKUP = 21
|
||||
private const val REQUEST_CHOOSE_FOLDER = 22
|
||||
private const val REQUEST_SETUP_LOCK = 23
|
||||
private const val REQUEST_DISABLE_LOCK = 24
|
||||
private const val REQUEST_IMPORT_OTHER = 25
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.Item
|
||||
|
||||
class UnlabeledFragment : NotallyFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
model.folder.value = Folder.NOTES
|
||||
}
|
||||
|
||||
override fun getBackground() = R.drawable.label_off
|
||||
|
||||
override fun getObservable(): LiveData<List<Item>> {
|
||||
return model.getNotesWithoutLabel()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,559 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.net.Uri
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.databinding.ChoiceItemBinding
|
||||
import com.philkes.notallyx.databinding.DialogNotesSortBinding
|
||||
import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding
|
||||
import com.philkes.notallyx.databinding.DialogPreferenceEnumWithToggleBinding
|
||||
import com.philkes.notallyx.databinding.DialogSelectionBoxBinding
|
||||
import com.philkes.notallyx.databinding.DialogTextInputBinding
|
||||
import com.philkes.notallyx.databinding.PreferenceBinding
|
||||
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
|
||||
import com.philkes.notallyx.presentation.checkedTag
|
||||
import com.philkes.notallyx.presentation.select
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BooleanPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.EnumPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
|
||||
import com.philkes.notallyx.utils.canAuthenticateWithBiometrics
|
||||
import com.philkes.notallyx.utils.toReadablePath
|
||||
|
||||
inline fun <reified T> PreferenceBinding.setup(
|
||||
enumPreference: EnumPreference<T>,
|
||||
value: T,
|
||||
context: Context,
|
||||
crossinline onSave: (newValue: T) -> Unit,
|
||||
) where T : Enum<T>, T : TextProvider {
|
||||
Title.setText(enumPreference.titleResId!!)
|
||||
Value.text = value.getText(context)
|
||||
val enumEntries = T::class.java.enumConstants!!.toList()
|
||||
val entries = enumEntries.map { it.getText(context) }.toTypedArray()
|
||||
val checked = enumEntries.indexOfFirst { it == value }
|
||||
root.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(enumPreference.titleResId)
|
||||
.setSingleChoiceItems(entries, checked) { dialog, which ->
|
||||
dialog.cancel()
|
||||
val newValue = enumEntries[which]
|
||||
onSave(newValue)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setup(
|
||||
preference: EnumPreference<BiometricLock>,
|
||||
value: BiometricLock,
|
||||
context: Context,
|
||||
model: BaseNoteModel,
|
||||
onEnableSuccess: () -> Unit,
|
||||
onDisableSuccess: () -> Unit,
|
||||
onNotSetup: () -> Unit,
|
||||
) {
|
||||
Title.setText(preference.titleResId!!)
|
||||
|
||||
Value.text = value.getText(context)
|
||||
val enumEntries = BiometricLock.entries
|
||||
val entries = enumEntries.map { context.getString(it.textResId) }.toTypedArray()
|
||||
val checked = enumEntries.indexOfFirst { it == value }
|
||||
|
||||
root.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(preference.titleResId)
|
||||
.setSingleChoiceItems(entries, checked) { dialog, which ->
|
||||
dialog.cancel()
|
||||
val newValue = enumEntries[which]
|
||||
if (newValue == value) {
|
||||
return@setSingleChoiceItems
|
||||
}
|
||||
if (newValue == BiometricLock.ENABLED) {
|
||||
when (context.canAuthenticateWithBiometrics()) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> onEnableSuccess()
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
|
||||
context.showToast(R.string.biometrics_no_support)
|
||||
}
|
||||
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> onNotSetup()
|
||||
}
|
||||
} else {
|
||||
when (context.canAuthenticateWithBiometrics()) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> onDisableSuccess()
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
|
||||
context.showToast(R.string.biometrics_no_support)
|
||||
model.savePreference(
|
||||
model.preferences.biometricLock,
|
||||
BiometricLock.DISABLED,
|
||||
)
|
||||
}
|
||||
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
|
||||
onNotSetup()
|
||||
model.savePreference(
|
||||
model.preferences.biometricLock,
|
||||
BiometricLock.DISABLED,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setup(
|
||||
preference: NotesSortPreference,
|
||||
value: NotesSort,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
model: BaseNoteModel,
|
||||
) {
|
||||
Title.setText(preference.titleResId!!)
|
||||
|
||||
Value.text = value.getText(context)
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = DialogNotesSortBinding.inflate(layoutInflater, null, false)
|
||||
NotesSortBy.entries.forEachIndexed { idx, notesSortBy ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = context.getString(notesSortBy.textResId)
|
||||
tag = notesSortBy
|
||||
layout.NotesSortByRadioGroup.addView(this)
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(notesSortBy.iconResId, 0, 0, 0)
|
||||
if (notesSortBy == value.sortedBy) {
|
||||
layout.NotesSortByRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SortDirection.entries.forEachIndexed { idx, sortDir ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = context.getString(sortDir.textResId)
|
||||
tag = sortDir
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
|
||||
layout.NotesSortDirectionRadioGroup.addView(this)
|
||||
if (sortDir == value.sortDirection) {
|
||||
layout.NotesSortDirectionRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(preference.titleResId)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as NotesSortBy
|
||||
val newSortDirection =
|
||||
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
|
||||
model.savePreference(
|
||||
model.preferences.notesSorting,
|
||||
NotesSort(newSortBy, newSortDirection),
|
||||
)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setup(
|
||||
dateFormatPreference: EnumPreference<DateFormat>,
|
||||
dateFormatValue: DateFormat,
|
||||
applyToNoteViewValue: Boolean,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
onSave: (dateFormat: DateFormat, applyToEditMode: Boolean) -> Unit,
|
||||
) {
|
||||
Title.setText(dateFormatPreference.titleResId!!)
|
||||
|
||||
Value.text = dateFormatValue.getText(context)
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
|
||||
layout.EnumHint.apply {
|
||||
setText(R.string.date_format_hint)
|
||||
isVisible = true
|
||||
}
|
||||
DateFormat.entries.forEachIndexed { idx, dateFormat ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = dateFormat.getText(context)
|
||||
tag = dateFormat
|
||||
layout.EnumRadioGroup.addView(this)
|
||||
if (dateFormat == dateFormatValue) {
|
||||
layout.EnumRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout.Toggle.apply {
|
||||
setText(R.string.date_format_apply_in_note_view)
|
||||
isChecked = applyToNoteViewValue
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(dateFormatPreference.titleResId)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat
|
||||
val applyToNoteView = layout.Toggle.isChecked
|
||||
onSave(dateFormat, applyToNoteView)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setup(
|
||||
themePreference: EnumPreference<Theme>,
|
||||
themeValue: Theme,
|
||||
useDynamicColorsValue: Boolean,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
onSave: (theme: Theme, useDynamicColors: Boolean) -> Unit,
|
||||
) {
|
||||
Title.setText(themePreference.titleResId!!)
|
||||
|
||||
Value.text = themeValue.getText(context)
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
|
||||
Theme.entries.forEachIndexed { idx, theme ->
|
||||
ChoiceItemBinding.inflate(layoutInflater).root.apply {
|
||||
id = idx
|
||||
text = theme.getText(context)
|
||||
tag = theme
|
||||
layout.EnumRadioGroup.addView(this)
|
||||
if (theme == themeValue) {
|
||||
layout.EnumRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout.Toggle.apply {
|
||||
isVisible = DynamicColors.isDynamicColorAvailable()
|
||||
setText(R.string.theme_use_dynamic_colors)
|
||||
isChecked = useDynamicColorsValue
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(themePreference.titleResId)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val theme = layout.EnumRadioGroup.checkedTag() as Theme
|
||||
val useDynamicColors = layout.Toggle.isChecked
|
||||
onSave(theme, useDynamicColors)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setup(
|
||||
preference: BooleanPreference,
|
||||
value: Boolean,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
messageResId: Int? = null,
|
||||
enabled: Boolean = true,
|
||||
disabledTextResId: Int? = null,
|
||||
onSave: (newValue: Boolean) -> Unit,
|
||||
) {
|
||||
Title.setText(preference.titleResId!!)
|
||||
|
||||
if (enabled) {
|
||||
Value.setText(if (value) R.string.enabled else R.string.disabled)
|
||||
} else {
|
||||
disabledTextResId?.let { Value.setText(it) }
|
||||
}
|
||||
root.isEnabled = enabled
|
||||
root.setOnClickListener {
|
||||
val layout =
|
||||
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
|
||||
Title.setText(preference.titleResId)
|
||||
messageResId?.let { Message.setText(it) }
|
||||
if (value) {
|
||||
EnabledButton.isChecked = true
|
||||
} else {
|
||||
DisabledButton.isChecked = true
|
||||
}
|
||||
}
|
||||
val dialog =
|
||||
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
|
||||
layout.apply {
|
||||
EnabledButton.setOnClickListener {
|
||||
dialog.cancel()
|
||||
if (!value) {
|
||||
onSave.invoke(true)
|
||||
}
|
||||
}
|
||||
DisabledButton.setOnClickListener {
|
||||
dialog.cancel()
|
||||
if (value) {
|
||||
onSave.invoke(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setupPeriodicBackup(
|
||||
value: Boolean,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
enabled: Boolean,
|
||||
onSave: (newValue: Boolean) -> Unit,
|
||||
) {
|
||||
Title.setText(R.string.backup_periodic)
|
||||
val enabledText = context.getString(R.string.enabled)
|
||||
val disabledText = context.getString(R.string.disabled)
|
||||
val text =
|
||||
if (enabled) {
|
||||
if (value) enabledText else disabledText
|
||||
} else context.getString(R.string.auto_backups_folder_set)
|
||||
Value.text = text
|
||||
root.isEnabled = enabled
|
||||
root.setOnClickListener {
|
||||
val layout =
|
||||
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
|
||||
Title.setText(R.string.backup_periodic)
|
||||
Message.setText(R.string.backup_periodic_hint)
|
||||
if (value) {
|
||||
EnabledButton.isChecked = true
|
||||
} else {
|
||||
DisabledButton.isChecked = true
|
||||
}
|
||||
}
|
||||
val dialog =
|
||||
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
|
||||
layout.apply {
|
||||
EnabledButton.setOnClickListener {
|
||||
dialog.cancel()
|
||||
if (!value) {
|
||||
onSave.invoke(true)
|
||||
}
|
||||
}
|
||||
DisabledButton.setOnClickListener {
|
||||
dialog.cancel()
|
||||
if (value) {
|
||||
onSave.invoke(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setupBackupPassword(
|
||||
preference: StringPreference,
|
||||
password: String,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
onSave: (newValue: String) -> Unit,
|
||||
) {
|
||||
Title.setText(preference.titleResId!!)
|
||||
|
||||
Value.transformationMethod =
|
||||
if (password != PASSWORD_EMPTY) PasswordTransformationMethod.getInstance() else null
|
||||
Value.text =
|
||||
if (password != PASSWORD_EMPTY) password else context.getText(R.string.tap_to_set_up)
|
||||
root.setOnClickListener {
|
||||
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
|
||||
layout.InputText.apply {
|
||||
if (password != PASSWORD_EMPTY) {
|
||||
setText(password)
|
||||
}
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
|
||||
layout.Message.apply {
|
||||
setText(R.string.backup_password_hint)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(preference.titleResId)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val updatedPassword = layout.InputText.text.toString()
|
||||
onSave(updatedPassword)
|
||||
}
|
||||
.setCancelButton()
|
||||
.setNeutralButton(R.string.clear) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
onSave(PASSWORD_EMPTY)
|
||||
}
|
||||
.showAndFocus(allowFullSize = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setupBackupsFolder(
|
||||
value: String,
|
||||
context: Context,
|
||||
chooseBackupFolder: () -> Unit,
|
||||
onDisable: () -> Unit,
|
||||
) {
|
||||
Title.setText(R.string.auto_backups_folder)
|
||||
|
||||
if (value == EMPTY_PATH) {
|
||||
Value.setText(R.string.tap_to_set_up)
|
||||
|
||||
root.setOnClickListener { chooseBackupFolder() }
|
||||
} else {
|
||||
val uri = Uri.parse(value)
|
||||
val folder = requireNotNull(DocumentFile.fromTreeUri(context, uri))
|
||||
if (folder.exists()) {
|
||||
val path = uri.toReadablePath()
|
||||
Value.text = path
|
||||
} else Value.setText(R.string.cant_find_folder)
|
||||
|
||||
root.setOnClickListener {
|
||||
MenuDialog(context)
|
||||
.add(R.string.clear) { onDisable() }
|
||||
.add(R.string.choose_another_folder) { chooseBackupFolder() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceSeekbarBinding.setup(
|
||||
value: Int,
|
||||
titleResId: Int,
|
||||
min: Int,
|
||||
max: Int,
|
||||
context: Context,
|
||||
enabled: Boolean = true,
|
||||
onChange: (newValue: Int) -> Unit,
|
||||
) {
|
||||
Title.setText(titleResId)
|
||||
val valueInBoundaries = (if (value < min) min else if (value > max) max else value).toFloat()
|
||||
Slider.apply {
|
||||
isEnabled = enabled
|
||||
valueTo = max.toFloat()
|
||||
valueFrom = min.toFloat()
|
||||
this@apply.value = valueInBoundaries
|
||||
clearOnSliderTouchListeners()
|
||||
addOnSliderTouchListener(
|
||||
object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) {}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
onChange(slider.value.toInt())
|
||||
}
|
||||
}
|
||||
)
|
||||
contentDescription = context.getString(titleResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceSeekbarBinding.setup(
|
||||
preference: IntPreference,
|
||||
context: Context,
|
||||
value: Int = preference.value,
|
||||
onChange: (newValue: Int) -> Unit,
|
||||
) {
|
||||
setup(value, preference.titleResId!!, preference.min, preference.max, context) { newValue ->
|
||||
onChange(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceSeekbarBinding.setupAutoSaveIdleTime(
|
||||
preference: IntPreference,
|
||||
context: Context,
|
||||
value: Int = preference.value,
|
||||
onChange: (newValue: Int) -> Unit,
|
||||
) {
|
||||
Slider.apply {
|
||||
setLabelFormatter { sliderValue ->
|
||||
if (sliderValue == -1f) {
|
||||
context.getString(R.string.disabled)
|
||||
} else "${sliderValue.toInt()}s"
|
||||
}
|
||||
addOnChangeListener { _, value, _ ->
|
||||
if (value == -1f) {
|
||||
setAlpha(0.6f) // Reduce opacity to make it look disabled
|
||||
} else {
|
||||
setAlpha(1f) // Restore normal appearance
|
||||
}
|
||||
}
|
||||
}
|
||||
setup(preference, context, value, onChange)
|
||||
}
|
||||
|
||||
fun PreferenceBinding.setupStartView(
|
||||
preference: StringPreference,
|
||||
value: String,
|
||||
labels: List<String>?,
|
||||
context: Context,
|
||||
layoutInflater: LayoutInflater,
|
||||
onSave: (value: String) -> Unit,
|
||||
) {
|
||||
Title.setText(preference.titleResId!!)
|
||||
|
||||
val notesText = "${context.getText(R.string.notes)} (${context.getText(R.string.text_default)})"
|
||||
val unlabeledText = context.getText(R.string.unlabeled).toString()
|
||||
val textValue =
|
||||
when (value) {
|
||||
START_VIEW_DEFAULT -> notesText
|
||||
START_VIEW_UNLABELED -> unlabeledText
|
||||
else -> value
|
||||
}
|
||||
Value.text = textValue
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = DialogSelectionBoxBinding.inflate(layoutInflater, null, false)
|
||||
layout.Message.setText(R.string.start_view_hint)
|
||||
val values =
|
||||
mutableListOf(notesText to START_VIEW_DEFAULT, unlabeledText to START_VIEW_UNLABELED)
|
||||
.apply { labels?.forEach { add(it to it) } }
|
||||
var selected = -1
|
||||
layout.SelectionBox.apply {
|
||||
setSimpleItems(values.map { it.first }.toTypedArray())
|
||||
select(textValue)
|
||||
setOnItemClickListener { _, _, position, _ -> selected = position }
|
||||
}
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(preference.titleResId)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val newValue = values[selected].second
|
||||
onSave(newValue)
|
||||
}
|
||||
.setCancelButton()
|
||||
.showAndFocus(allowFullSize = true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,876 @@
|
|||
package com.philkes.notallyx.presentation.activity.main.fragment.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_OPEN_DOCUMENT_TREE
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||
import com.philkes.notallyx.NotallyXApplication
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.imports.FOLDER_OR_FILE_MIMETYPE
|
||||
import com.philkes.notallyx.data.imports.ImportSource
|
||||
import com.philkes.notallyx.data.imports.txt.APPLICATION_TEXT_MIME_TYPES
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.databinding.DialogTextInputBinding
|
||||
import com.philkes.notallyx.databinding.FragmentSettingsBinding
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.setEnabledSecureFlag
|
||||
import com.philkes.notallyx.presentation.setupImportProgressDialog
|
||||
import com.philkes.notallyx.presentation.setupProgressDialog
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.showDialog
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_MAX_MIN
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_JSON
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
|
||||
import com.philkes.notallyx.utils.backup.exportPreferences
|
||||
import com.philkes.notallyx.utils.catchNoBrowserInstalled
|
||||
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
|
||||
import com.philkes.notallyx.utils.getLastExceptionLog
|
||||
import com.philkes.notallyx.utils.getLogFile
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.reportBug
|
||||
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.util.Date
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private val model: BaseNoteModel by activityViewModels()
|
||||
|
||||
private lateinit var importBackupActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var importOtherActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportBackupActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var chooseBackupFolderActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var setupLockActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var disableLockActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var importSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private lateinit var selectedImportSource: ImportSource
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val binding = FragmentSettingsBinding.inflate(inflater)
|
||||
model.preferences.apply {
|
||||
setupAppearance(binding)
|
||||
setupContentDensity(binding)
|
||||
setupBackup(binding)
|
||||
setupAutoBackups(binding)
|
||||
setupSecurity(binding)
|
||||
setupSettings(binding)
|
||||
}
|
||||
setupAbout(binding)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupActivityResultLaunchers()
|
||||
val showImportBackupsFolder =
|
||||
getExtraBooleanFromBundleOrIntent(
|
||||
savedInstanceState,
|
||||
EXTRA_SHOW_IMPORT_BACKUPS_FOLDER,
|
||||
false,
|
||||
)
|
||||
showImportBackupsFolder.let {
|
||||
if (it) {
|
||||
model.refreshBackupsFolder(
|
||||
requireContext(),
|
||||
askForUriPermissions = ::askForUriPermissions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
if (model.showRefreshBackupsFolderAfterThemeChange) {
|
||||
outState.putBoolean(EXTRA_SHOW_IMPORT_BACKUPS_FOLDER, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
importBackupActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { importBackup(it) }
|
||||
}
|
||||
}
|
||||
importOtherActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
model.importFromOtherApp(uri, selectedImportSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
exportBackupActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> model.exportBackup(uri) }
|
||||
}
|
||||
}
|
||||
chooseBackupFolderActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
model.setupBackupsFolder(uri)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity?.let {
|
||||
val permission = Manifest.permission.POST_NOTIFICATIONS
|
||||
if (
|
||||
it.checkSelfPermission(permission) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
MaterialAlertDialogBuilder(it)
|
||||
.setMessage(
|
||||
R.string.please_grant_notally_notification_auto_backup
|
||||
)
|
||||
.setNegativeButton(R.string.skip, null)
|
||||
.setPositiveButton(R.string.continue_) { _, _ ->
|
||||
it.requestPermissions(arrayOf(permission), 0)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setupLockActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
showEnableBiometricLock()
|
||||
}
|
||||
disableLockActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
showDisableBiometricLock()
|
||||
}
|
||||
exportSettingsActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
if (requireContext().exportPreferences(model.preferences, uri)) {
|
||||
showToast(R.string.export_settings_success)
|
||||
} else {
|
||||
showToast(R.string.export_settings_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
importSettingsActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
model.importPreferences(
|
||||
requireContext(),
|
||||
uri,
|
||||
::askForUriPermissions,
|
||||
{ showToast(R.string.import_settings_success) },
|
||||
) {
|
||||
showToast(R.string.import_settings_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importBackup(uri: Uri) {
|
||||
when (requireContext().contentResolver.getType(uri)) {
|
||||
"text/xml" -> {
|
||||
model.importXmlBackup(uri)
|
||||
}
|
||||
|
||||
MIME_TYPE_ZIP -> {
|
||||
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
|
||||
val password = model.preferences.backupPassword.value
|
||||
layout.InputText.apply {
|
||||
if (password != PASSWORD_EMPTY) {
|
||||
setText(password)
|
||||
}
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
|
||||
layout.Message.apply {
|
||||
setText(R.string.import_backup_password_hint)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.backup_password)
|
||||
.setView(layout.root)
|
||||
.setPositiveButton(R.string.import_backup) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
val usedPassword = layout.InputText.text.toString()
|
||||
model.importZipBackup(uri, usedPassword)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupAppearance(binding: FragmentSettingsBinding) {
|
||||
notesView.observe(viewLifecycleOwner) { value ->
|
||||
binding.View.setup(notesView, value, requireContext()) { newValue ->
|
||||
model.savePreference(notesView, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
theme.merge(useDynamicColors).observe(viewLifecycleOwner) {
|
||||
(themeValue, useDynamicColorsValue) ->
|
||||
binding.Theme.setup(
|
||||
theme,
|
||||
themeValue,
|
||||
useDynamicColorsValue,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
) { newThemeValue, newUseDynamicColorsValue ->
|
||||
model.savePreference(theme, newThemeValue)
|
||||
model.savePreference(useDynamicColors, newUseDynamicColorsValue)
|
||||
val packageManager = requireContext().packageManager
|
||||
val intent = packageManager.getLaunchIntentForPackage(requireContext().packageName)
|
||||
val componentName = intent!!.component
|
||||
val mainIntent =
|
||||
Intent.makeRestartActivityTask(componentName).apply {
|
||||
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
|
||||
}
|
||||
mainIntent.setPackage(requireContext().packageName)
|
||||
requireContext().startActivity(mainIntent)
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
dateFormat.merge(applyDateFormatInNoteView).observe(viewLifecycleOwner) {
|
||||
(dateFormatValue, applyDateFormatInEditNoteValue) ->
|
||||
binding.DateFormat.setup(
|
||||
dateFormat,
|
||||
dateFormatValue,
|
||||
applyDateFormatInEditNoteValue,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
) { newDateFormatValue, newApplyDateFormatInEditNote ->
|
||||
model.savePreference(dateFormat, newDateFormatValue)
|
||||
model.savePreference(applyDateFormatInNoteView, newApplyDateFormatInEditNote)
|
||||
}
|
||||
}
|
||||
|
||||
textSize.observe(viewLifecycleOwner) { value ->
|
||||
binding.TextSize.setup(textSize, value, requireContext()) { newValue ->
|
||||
model.savePreference(textSize, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
notesSorting.observe(viewLifecycleOwner) { notesSort ->
|
||||
binding.NotesSortOrder.setup(
|
||||
notesSorting,
|
||||
notesSort,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
listItemSorting.observe(viewLifecycleOwner) { value ->
|
||||
binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) {
|
||||
newValue ->
|
||||
model.savePreference(listItemSorting, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
|
||||
model.savePreference(maxLabels, newValue)
|
||||
}
|
||||
|
||||
startView.merge(model.labels).observe(viewLifecycleOwner) { (startViewValue, labelsValue) ->
|
||||
binding.StartView.setupStartView(
|
||||
startView,
|
||||
startViewValue,
|
||||
labelsValue,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
) { newValue ->
|
||||
model.savePreference(startView, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
|
||||
binding.apply {
|
||||
MaxTitle.setup(maxTitle, requireContext()) { newValue ->
|
||||
model.savePreference(maxTitle, newValue)
|
||||
}
|
||||
MaxItems.setup(maxItems, requireContext()) { newValue ->
|
||||
model.savePreference(maxItems, newValue)
|
||||
}
|
||||
|
||||
MaxLines.setup(maxLines, requireContext()) { newValue ->
|
||||
model.savePreference(maxLines, newValue)
|
||||
}
|
||||
MaxLabels.setup(maxLabels, requireContext()) { newValue ->
|
||||
model.savePreference(maxLabels, newValue)
|
||||
}
|
||||
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
|
||||
binding.LabelsHiddenInOverview.setup(
|
||||
labelTagsHiddenInOverview,
|
||||
value,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
R.string.labels_hidden_in_overview,
|
||||
) { enabled ->
|
||||
model.savePreference(labelTagsHiddenInOverview, enabled)
|
||||
}
|
||||
}
|
||||
imagesHiddenInOverview.observe(viewLifecycleOwner) { value ->
|
||||
binding.ImagesHiddenInOverview.setup(
|
||||
imagesHiddenInOverview,
|
||||
value,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
R.string.images_hidden_in_overview,
|
||||
) { enabled ->
|
||||
model.savePreference(imagesHiddenInOverview, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupBackup(binding: FragmentSettingsBinding) {
|
||||
binding.apply {
|
||||
ImportBackup.setOnClickListener {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.apply {
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(MIME_TYPE_ZIP, "text/xml"))
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
importBackupActivityResultLauncher.launch(intent)
|
||||
}
|
||||
ImportOther.setOnClickListener { importFromOtherApp() }
|
||||
ExportBackup.setOnClickListener {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = MIME_TYPE_ZIP
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
exportBackupActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
model.exportProgress.setupProgressDialog(this@SettingsFragment, R.string.exporting_backup)
|
||||
model.importProgress.setupImportProgressDialog(
|
||||
this@SettingsFragment,
|
||||
R.string.importing_backup,
|
||||
)
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupAutoBackups(binding: FragmentSettingsBinding) {
|
||||
backupsFolder.observe(viewLifecycleOwner) { value ->
|
||||
binding.BackupsFolder.setupBackupsFolder(
|
||||
value,
|
||||
requireContext(),
|
||||
::displayChooseBackupFolderDialog,
|
||||
) {
|
||||
model.disableBackups()
|
||||
}
|
||||
}
|
||||
backupOnSave.merge(backupsFolder).observe(viewLifecycleOwner) { (onSave, backupFolder) ->
|
||||
binding.BackupOnSave.setup(
|
||||
backupOnSave,
|
||||
onSave,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
messageResId = R.string.auto_backup_on_save,
|
||||
enabled = backupFolder != EMPTY_PATH,
|
||||
disabledTextResId = R.string.auto_backups_folder_set,
|
||||
) { enabled ->
|
||||
model.savePreference(backupOnSave, enabled)
|
||||
}
|
||||
}
|
||||
periodicBackups.merge(backupsFolder).observe(viewLifecycleOwner) {
|
||||
(periodicBackup, backupFolder) ->
|
||||
setupPeriodicBackup(
|
||||
binding,
|
||||
periodicBackup,
|
||||
backupFolder,
|
||||
periodicBackups,
|
||||
periodicBackupLastExecution,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importFromOtherApp() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.choose_other_app)
|
||||
.setAdapter(
|
||||
TextWithIconAdapter(
|
||||
requireContext(),
|
||||
ImportSource.entries.toMutableList(),
|
||||
{ item -> getString(item.displayNameResId) },
|
||||
ImportSource::iconResId,
|
||||
)
|
||||
) { _, which ->
|
||||
selectedImportSource = ImportSource.entries[which]
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(selectedImportSource.helpTextResId)
|
||||
.setPositiveButton(R.string.import_action) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
when (selectedImportSource.mimeType) {
|
||||
FOLDER_OR_FILE_MIMETYPE ->
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(selectedImportSource.displayNameResId)
|
||||
.setItems(
|
||||
arrayOf(
|
||||
getString(R.string.folder),
|
||||
getString(R.string.single_file),
|
||||
)
|
||||
) { _, which ->
|
||||
when (which) {
|
||||
0 ->
|
||||
importOtherActivityResultLauncher.launch(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.apply {
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
)
|
||||
1 ->
|
||||
importOtherActivityResultLauncher.launch(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.apply {
|
||||
type = "text/*"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(
|
||||
Intent.EXTRA_MIME_TYPES,
|
||||
arrayOf("text/*") +
|
||||
APPLICATION_TEXT_MIME_TYPES,
|
||||
)
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
)
|
||||
}
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
else ->
|
||||
importOtherActivityResultLauncher.launch(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.apply {
|
||||
type = "application/*"
|
||||
putExtra(
|
||||
Intent.EXTRA_MIME_TYPES,
|
||||
arrayOf(selectedImportSource.mimeType),
|
||||
)
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
)
|
||||
}
|
||||
}
|
||||
.also {
|
||||
selectedImportSource.documentationUrl?.let<String, Unit> { docUrl ->
|
||||
it.setNegativeButton(R.string.help) { _, _ ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.apply { data = Uri.parse(docUrl) }
|
||||
.wrapWithChooser(requireContext())
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNeutralButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
.showAndFocus(allowFullSize = true)
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setupPeriodicBackup(
|
||||
binding: FragmentSettingsBinding,
|
||||
value: PeriodicBackup,
|
||||
backupFolder: String,
|
||||
preference: PeriodicBackupsPreference,
|
||||
lastExecutionPreference: LongPreference,
|
||||
) {
|
||||
val periodicBackupsEnabled = value.periodInDays > 0 && backupFolder != EMPTY_PATH
|
||||
binding.PeriodicBackups.setupPeriodicBackup(
|
||||
periodicBackupsEnabled,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
enabled = backupFolder != EMPTY_PATH,
|
||||
) { enabled ->
|
||||
if (enabled) {
|
||||
val periodInDays =
|
||||
preference.value.periodInDays.let {
|
||||
if (it >= BACKUP_PERIOD_DAYS_MIN) it else BACKUP_PERIOD_DAYS_MIN
|
||||
}
|
||||
val maxBackups =
|
||||
preference.value.maxBackups.let {
|
||||
if (it >= BACKUP_MAX_MIN) it else BACKUP_MAX_MIN
|
||||
}
|
||||
model.savePreference(
|
||||
preference,
|
||||
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
|
||||
)
|
||||
} else {
|
||||
model.savePreference(preference, preference.value.copy(periodInDays = 0))
|
||||
}
|
||||
}
|
||||
lastExecutionPreference.observe(viewLifecycleOwner) { time ->
|
||||
binding.PeriodicBackupLastExecution.apply {
|
||||
if (time != -1L) {
|
||||
isVisible = true
|
||||
text =
|
||||
"${requireContext().getString(R.string.auto_backup_last)}: ${Date(time).toText()}"
|
||||
} else isVisible = false
|
||||
}
|
||||
}
|
||||
binding.PeriodicBackupsPeriodInDays.setup(
|
||||
value.periodInDays,
|
||||
R.string.backup_period_days,
|
||||
PeriodicBackup.BACKUP_PERIOD_DAYS_MIN,
|
||||
PeriodicBackup.BACKUP_PERIOD_DAYS_MAX,
|
||||
requireContext(),
|
||||
enabled = periodicBackupsEnabled,
|
||||
) { newValue ->
|
||||
model.savePreference(preference, preference.value.copy(periodInDays = newValue))
|
||||
}
|
||||
binding.PeriodicBackupsMax.setup(
|
||||
value.maxBackups,
|
||||
R.string.max_backups,
|
||||
PeriodicBackup.BACKUP_MAX_MIN,
|
||||
PeriodicBackup.BACKUP_MAX_MAX,
|
||||
requireContext(),
|
||||
enabled = periodicBackupsEnabled,
|
||||
) { newValue: Int ->
|
||||
model.savePreference(preference, preference.value.copy(maxBackups = newValue))
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupSecurity(binding: FragmentSettingsBinding) {
|
||||
biometricLock.observe(viewLifecycleOwner) { value ->
|
||||
binding.BiometricLock.setup(
|
||||
biometricLock,
|
||||
value,
|
||||
requireContext(),
|
||||
model,
|
||||
::showEnableBiometricLock,
|
||||
::showDisableBiometricLock,
|
||||
::showBiometricsNotSetupDialog,
|
||||
)
|
||||
}
|
||||
|
||||
backupPassword.observe(viewLifecycleOwner) { value ->
|
||||
binding.BackupPassword.setupBackupPassword(
|
||||
backupPassword,
|
||||
value,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
) { newValue ->
|
||||
model.savePreference(backupPassword, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
secureFlag.observe(viewLifecycleOwner) { value ->
|
||||
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
|
||||
->
|
||||
model.savePreference(secureFlag, newValue)
|
||||
activity?.setEnabledSecureFlag(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
|
||||
binding.apply {
|
||||
ImportSettings.setOnClickListener {
|
||||
showDialog(R.string.import_settings_message, R.string.import_action) { _, _ ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.apply {
|
||||
type = MIME_TYPE_JSON
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
importSettingsActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
ExportSettings.setOnClickListener {
|
||||
showDialog(R.string.export_settings_message, R.string.export) { _, _ ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = MIME_TYPE_JSON
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
exportSettingsActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
ResetSettings.setOnClickListener {
|
||||
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
|
||||
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
|
||||
}
|
||||
}
|
||||
dataInPublicFolder.observe(viewLifecycleOwner) { value ->
|
||||
binding.DataInPublicFolder.setup(
|
||||
dataInPublicFolder,
|
||||
value,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
R.string.data_in_public_message,
|
||||
) { enabled ->
|
||||
if (enabled) {
|
||||
model.enableDataInPublic()
|
||||
} else {
|
||||
model.disableDataInPublic()
|
||||
}
|
||||
}
|
||||
}
|
||||
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
|
||||
newValue ->
|
||||
model.savePreference(autoSaveAfterIdleTime, newValue)
|
||||
}
|
||||
|
||||
ClearData.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.clear_data_message)
|
||||
.setPositiveButton(R.string.delete_all) { _, _ -> model.deleteAll() }
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
model.deletionProgress.setupProgressDialog(this@SettingsFragment, R.string.deleting_files)
|
||||
}
|
||||
|
||||
private fun setupAbout(binding: FragmentSettingsBinding) {
|
||||
binding.apply {
|
||||
SendFeedback.setOnClickListener {
|
||||
val options =
|
||||
arrayOf(
|
||||
getString(R.string.report_bug),
|
||||
getString(R.string.make_feature_request),
|
||||
getString(R.string.send_feedback),
|
||||
)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.send_feedback)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
val app = requireContext().applicationContext as Application
|
||||
val logs = app.getLastExceptionLog()
|
||||
reportBug(logs)
|
||||
}
|
||||
|
||||
1 ->
|
||||
requireContext().catchNoBrowserInstalled {
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(
|
||||
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
|
||||
),
|
||||
)
|
||||
.wrapWithChooser(requireContext())
|
||||
)
|
||||
}
|
||||
2 -> {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.apply {
|
||||
selector =
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
|
||||
putExtra(
|
||||
Intent.EXTRA_EMAIL,
|
||||
arrayOf("notallyx@yahoo.com"),
|
||||
)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "NotallyX [Feedback]")
|
||||
val app =
|
||||
requireContext().applicationContext as Application
|
||||
val log = app.getLogFile()
|
||||
if (log.exists()) {
|
||||
val uri = app.getUriForFile(log)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
}
|
||||
.wrapWithChooser(requireContext())
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
showToast(R.string.install_an_email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
Rate.setOnClickListener {
|
||||
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
|
||||
}
|
||||
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
|
||||
Libraries.setOnClickListener {
|
||||
val libraries =
|
||||
arrayOf(
|
||||
"Glide",
|
||||
"Pretty Time",
|
||||
"SwipeDrawer",
|
||||
"Work Manager",
|
||||
"Subsampling Scale ImageView",
|
||||
"Material Components for Android",
|
||||
"SQLCipher",
|
||||
"Zip4J",
|
||||
"AndroidFastScroll",
|
||||
"ColorPickerView",
|
||||
)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.libraries)
|
||||
.setItems(libraries) { _, which ->
|
||||
when (which) {
|
||||
0 -> openLink("https://github.com/bumptech/glide")
|
||||
1 -> openLink("https://github.com/ocpsoft/prettytime")
|
||||
2 -> openLink("https://leaqi.github.io/SwipeDrawer_en")
|
||||
3 ->
|
||||
openLink(
|
||||
"https://developer.android.com/jetpack/androidx/releases/work"
|
||||
)
|
||||
4 ->
|
||||
openLink(
|
||||
"https://github.com/davemorrissey/subsampling-scale-image-view"
|
||||
)
|
||||
5 ->
|
||||
openLink(
|
||||
"https://github.com/material-components/material-components-android"
|
||||
)
|
||||
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
|
||||
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
|
||||
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
|
||||
9 -> openLink("https://github.com/skydoves/ColorPickerView")
|
||||
}
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
|
||||
|
||||
try {
|
||||
val pInfo =
|
||||
requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
|
||||
val version = pInfo.versionName
|
||||
VersionText.text = "v$version"
|
||||
} catch (_: PackageManager.NameNotFoundException) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayChooseBackupFolderDialog() {
|
||||
showDialog(R.string.auto_backups_folder_hint, R.string.choose_folder) { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).wrapWithChooser(requireContext())
|
||||
chooseBackupFolderActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEnableBiometricLock() {
|
||||
showBiometricOrPinPrompt(
|
||||
false,
|
||||
setupLockActivityResultLauncher,
|
||||
R.string.enable_lock_title,
|
||||
R.string.enable_lock_description,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
model.enableBiometricLock(cipher)
|
||||
}
|
||||
val app = (activity?.application as NotallyXApplication)
|
||||
app.locked.value = false
|
||||
showToast(R.string.biometrics_setup_success)
|
||||
},
|
||||
) {
|
||||
showBiometricsNotSetupDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDisableBiometricLock() {
|
||||
showBiometricOrPinPrompt(
|
||||
true,
|
||||
disableLockActivityResultLauncher,
|
||||
R.string.disable_lock_title,
|
||||
R.string.disable_lock_description,
|
||||
model.preferences.iv.value!!,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
model.disableBiometricLock(cipher)
|
||||
}
|
||||
showToast(R.string.biometrics_disable_success)
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
private fun showBiometricsNotSetupDialog() {
|
||||
showDialog(R.string.biometrics_not_setup, R.string.tap_to_set_up) { _, _ ->
|
||||
val intent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
|
||||
} else {
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
}
|
||||
setupLockActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLink(link: String) {
|
||||
val uri = Uri.parse(link)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(requireContext())
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun askForUriPermissions(uri: Uri) {
|
||||
chooseBackupFolderActivityResultLauncher.launch(
|
||||
Intent(ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SHOW_IMPORT_BACKUPS_FOLDER =
|
||||
"notallyx.intent.extra.SHOW_IMPORT_BACKUPS_FOLDER"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,80 +1,223 @@
|
|||
package com.philkes.notallyx.presentation.activity.note
|
||||
|
||||
import android.os.Build
|
||||
import android.view.MenuItem
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.philkes.notallyx.Preferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.addIconButton
|
||||
import com.philkes.notallyx.presentation.hideKeyboardOnFocusedItem
|
||||
import com.philkes.notallyx.presentation.setOnNextAction
|
||||
import com.philkes.notallyx.presentation.view.misc.ListItemSorting
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemAdapter
|
||||
import com.philkes.notallyx.presentation.showKeyboardOnFocusedItem
|
||||
import com.philkes.notallyx.presentation.view.note.action.MoreListActions
|
||||
import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemNoSortCallback
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedByCheckedCallback
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toMutableList
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeHistory
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.CheckedListItemAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.init
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.setItems
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemParentSortCallback
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.splitByChecked
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.toMutableList
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
|
||||
import com.philkes.notallyx.utils.findAllOccurrences
|
||||
import com.philkes.notallyx.utils.indices
|
||||
import com.philkes.notallyx.utils.mapIndexed
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class EditListActivity : EditActivity(Type.LIST) {
|
||||
class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
||||
|
||||
private lateinit var adapter: ListItemAdapter
|
||||
private lateinit var items: ListItemSortedList
|
||||
private var adapter: ListItemAdapter? = null
|
||||
private var adapterChecked: CheckedListItemAdapter? = null
|
||||
private val items: MutableList<ListItem>
|
||||
get() = adapter!!.items
|
||||
|
||||
private var itemsChecked: SortedItemsList? = null
|
||||
private lateinit var listManager: ListManager
|
||||
|
||||
override suspend fun saveNote() {
|
||||
super.saveNote()
|
||||
model.saveNote(items.toMutableList())
|
||||
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
|
||||
override fun finish() {
|
||||
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun setupToolbar() {
|
||||
super.setupToolbar()
|
||||
binding.Toolbar.menu.apply {
|
||||
add(
|
||||
1,
|
||||
R.string.delete_checked_items,
|
||||
R.drawable.delete_all,
|
||||
MenuItem.SHOW_AS_ACTION_IF_ROOM,
|
||||
) {
|
||||
override fun updateModel() {
|
||||
super.updateModel()
|
||||
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
updateModel()
|
||||
binding.MainListView.focusedChild?.let { focusedChild ->
|
||||
val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild)
|
||||
if (viewHolder is ListItemVH) {
|
||||
val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild)
|
||||
if (itemPos > -1) {
|
||||
val (selectionStart, selectionEnd) = viewHolder.getSelection()
|
||||
outState.apply {
|
||||
putInt(EXTRA_ITEM_POS, itemPos)
|
||||
putInt(EXTRA_SELECTION_START, selectionStart)
|
||||
putInt(EXTRA_SELECTION_END, selectionEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun toggleCanEdit(mode: NoteViewMode) {
|
||||
super.toggleCanEdit(mode)
|
||||
when (mode) {
|
||||
NoteViewMode.EDIT -> binding.MainListView.showKeyboardOnFocusedItem()
|
||||
NoteViewMode.READ_ONLY -> binding.MainListView.hideKeyboardOnFocusedItem()
|
||||
}
|
||||
adapter?.viewMode = mode
|
||||
adapterChecked?.viewMode = mode
|
||||
binding.AddItem.visibility =
|
||||
when (mode) {
|
||||
NoteViewMode.EDIT -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteChecked() {
|
||||
listManager.deleteCheckedItems()
|
||||
}
|
||||
add(
|
||||
1,
|
||||
R.string.check_all_items,
|
||||
R.drawable.checkbox_fill,
|
||||
MenuItem.SHOW_AS_ACTION_IF_ROOM,
|
||||
) {
|
||||
|
||||
override fun checkAll() {
|
||||
listManager.changeCheckedForAll(true)
|
||||
}
|
||||
add(
|
||||
1,
|
||||
R.string.uncheck_all_items,
|
||||
R.drawable.checkbox,
|
||||
MenuItem.SHOW_AS_ACTION_IF_ROOM,
|
||||
) {
|
||||
|
||||
override fun uncheckAll() {
|
||||
listManager.changeCheckedForAll(false)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setGroupDividerEnabled(true)
|
||||
|
||||
override fun initBottomMenu() {
|
||||
super.initBottomMenu()
|
||||
binding.BottomAppBarRight.apply {
|
||||
removeAllViews()
|
||||
addToggleViewMode()
|
||||
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
|
||||
MoreListBottomSheet(
|
||||
this@EditListActivity,
|
||||
createNoteTypeActions() + createFolderActions(),
|
||||
colorInt,
|
||||
)
|
||||
.show(supportFragmentManager, MoreListBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
setBottomAppBarColor(colorInt)
|
||||
}
|
||||
|
||||
private fun SortedList<ListItem>.highlightSearch(
|
||||
search: String,
|
||||
adapter: HighlightText?,
|
||||
resultPosCounter: AtomicInteger,
|
||||
alreadyNotifiedItemPos: MutableSet<Int>,
|
||||
): Int {
|
||||
return mapIndexed { idx, item ->
|
||||
val occurrences = item.body.findAllOccurrences(search)
|
||||
occurrences.onEach { (startIdx, endIdx) ->
|
||||
adapter?.highlightText(
|
||||
ListItemHighlight(
|
||||
idx,
|
||||
resultPosCounter.getAndIncrement(),
|
||||
startIdx,
|
||||
endIdx,
|
||||
false,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (occurrences.isNotEmpty()) {
|
||||
alreadyNotifiedItemPos.add(idx)
|
||||
}
|
||||
occurrences.size
|
||||
}
|
||||
.sum()
|
||||
}
|
||||
|
||||
private fun List<ListItem>.highlightSearch(
|
||||
search: String,
|
||||
adapter: ListItemAdapter?,
|
||||
resultPosCounter: AtomicInteger,
|
||||
alreadyNotifiedItemPos: MutableSet<Int>,
|
||||
): Int {
|
||||
return mapIndexed { idx, item ->
|
||||
val occurrences = item.body.findAllOccurrences(search)
|
||||
occurrences.onEach { (startIdx, endIdx) ->
|
||||
adapter?.highlightText(
|
||||
ListItemHighlight(
|
||||
idx,
|
||||
resultPosCounter.getAndIncrement(),
|
||||
startIdx,
|
||||
endIdx,
|
||||
false,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (occurrences.isNotEmpty()) {
|
||||
alreadyNotifiedItemPos.add(idx)
|
||||
}
|
||||
occurrences.size
|
||||
}
|
||||
.sum()
|
||||
}
|
||||
|
||||
override fun highlightSearchResults(search: String): Int {
|
||||
val resultPosCounter = AtomicInteger(0)
|
||||
val alreadyNotifiedItemPos = mutableSetOf<Int>()
|
||||
adapter?.clearHighlights()
|
||||
adapterChecked?.clearHighlights()
|
||||
val amount =
|
||||
items.highlightSearch(search, adapter, resultPosCounter, alreadyNotifiedItemPos) +
|
||||
(itemsChecked?.highlightSearch(
|
||||
search,
|
||||
adapterChecked,
|
||||
resultPosCounter,
|
||||
alreadyNotifiedItemPos,
|
||||
) ?: 0)
|
||||
items.indices
|
||||
.filter { !alreadyNotifiedItemPos.contains(it) }
|
||||
.forEach { adapter?.notifyItemChanged(it) }
|
||||
itemsChecked
|
||||
?.indices
|
||||
?.filter { !alreadyNotifiedItemPos.contains(it) }
|
||||
?.forEach { adapter?.notifyItemChanged(it) }
|
||||
return amount
|
||||
}
|
||||
|
||||
override fun selectSearchResult(resultPos: Int) {
|
||||
var selectedItemPos = adapter!!.selectHighlight(resultPos)
|
||||
if (selectedItemPos == -1 && adapterChecked != null) {
|
||||
selectedItemPos = adapterChecked!!.selectHighlight(resultPos)
|
||||
if (selectedItemPos != -1) {
|
||||
binding.CheckedListView.scrollToItemPosition(selectedItemPos)
|
||||
}
|
||||
} else if (selectedItemPos != -1) {
|
||||
binding.MainListView.scrollToItemPosition(selectedItemPos)
|
||||
}
|
||||
}
|
||||
|
||||
override fun initActionManager(undo: MenuItem, redo: MenuItem) {
|
||||
changeHistory = ChangeHistory {
|
||||
undo.isEnabled = changeHistory.canUndo()
|
||||
redo.isEnabled = changeHistory.canRedo()
|
||||
private fun RecyclerView.scrollToItemPosition(position: Int) {
|
||||
post {
|
||||
findViewHolderForAdapterPosition(position)?.itemView?.let {
|
||||
binding.ScrollView.scrollTo(0, top + it.top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureUI() {
|
||||
binding.EnterTitle.setOnNextAction { listManager.moveFocusToNext(-1) }
|
||||
|
||||
if (model.isNewNote || model.items.isEmpty()) {
|
||||
if (notallyModel.isNewNote || notallyModel.items.isEmpty()) {
|
||||
listManager.add(pushChange = false)
|
||||
}
|
||||
}
|
||||
|
@ -84,36 +227,92 @@ class EditListActivity : EditActivity(Type.LIST) {
|
|||
binding.AddItem.setOnClickListener { listManager.add() }
|
||||
}
|
||||
|
||||
override fun setStateFromModel() {
|
||||
super.setStateFromModel()
|
||||
override fun setStateFromModel(savedInstanceState: Bundle?) {
|
||||
super.setStateFromModel(savedInstanceState)
|
||||
val elevation = resources.displayMetrics.density * 2
|
||||
listManager =
|
||||
ListManager(
|
||||
binding.RecyclerView,
|
||||
binding.MainListView,
|
||||
changeHistory,
|
||||
preferences,
|
||||
getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager,
|
||||
)
|
||||
inputMethodManager,
|
||||
{
|
||||
if (isInSearchMode()) {
|
||||
endSearch()
|
||||
}
|
||||
},
|
||||
) { _ ->
|
||||
if (isInSearchMode() && search.results.value > 0) {
|
||||
updateSearchResults(search.query)
|
||||
}
|
||||
}
|
||||
adapter =
|
||||
ListItemAdapter(
|
||||
model.textSize,
|
||||
colorInt,
|
||||
notallyModel.textSize,
|
||||
elevation,
|
||||
Preferences.getInstance(application),
|
||||
NotallyXPreferences.getInstance(application),
|
||||
listManager,
|
||||
false,
|
||||
binding.ScrollView,
|
||||
)
|
||||
val sortCallback =
|
||||
when (preferences.listItemSorting.value) {
|
||||
ListItemSorting.autoSortByChecked -> ListItemSortedByCheckedCallback(adapter)
|
||||
else -> ListItemNoSortCallback(adapter)
|
||||
val initializedItems = notallyModel.items.init(true)
|
||||
if (preferences.autoSortByCheckedEnabled) {
|
||||
val (checkedItems, uncheckedItems) = initializedItems.splitByChecked()
|
||||
adapter?.submitList(uncheckedItems.toMutableList())
|
||||
adapterChecked =
|
||||
CheckedListItemAdapter(
|
||||
colorInt,
|
||||
notallyModel.textSize,
|
||||
elevation,
|
||||
NotallyXPreferences.getInstance(application),
|
||||
listManager,
|
||||
true,
|
||||
binding.ScrollView,
|
||||
)
|
||||
itemsChecked =
|
||||
SortedItemsList(ListItemParentSortCallback(adapterChecked!!)).apply {
|
||||
setItems(checkedItems.toMutableList())
|
||||
}
|
||||
items = ListItemSortedList(sortCallback)
|
||||
if (sortCallback is ListItemSortedByCheckedCallback) {
|
||||
sortCallback.setList(items)
|
||||
adapterChecked?.setList(itemsChecked!!)
|
||||
binding.CheckedListView.adapter = adapterChecked
|
||||
} else {
|
||||
adapter?.submitList(initializedItems.toMutableList())
|
||||
}
|
||||
items.init(model.items)
|
||||
adapter.setList(items)
|
||||
binding.RecyclerView.adapter = adapter
|
||||
listManager.adapter = adapter
|
||||
listManager.initList(items)
|
||||
listManager.init(adapter!!, itemsChecked, adapterChecked)
|
||||
binding.MainListView.adapter = adapter
|
||||
|
||||
savedInstanceState?.let {
|
||||
val itemPos = it.getInt(EXTRA_ITEM_POS, -1)
|
||||
if (itemPos > -1) {
|
||||
binding.MainListView.apply {
|
||||
post {
|
||||
scrollToPosition(itemPos)
|
||||
val viewHolder = findViewHolderForLayoutPosition(itemPos)
|
||||
if (viewHolder is ListItemVH) {
|
||||
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
|
||||
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
|
||||
viewHolder.focusEditText(
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
inputMethodManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setColor() {
|
||||
super.setColor()
|
||||
adapter?.setBackgroundColor(colorInt)
|
||||
adapterChecked?.setBackgroundColor(colorInt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ITEM_POS = "notallyx.intent.extra.ITEM_POS"
|
||||
private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
|
||||
private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,114 +1,192 @@
|
|||
package com.philkes.notallyx.presentation.activity.note
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.text.Spanned
|
||||
import android.os.Bundle
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.view.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.createNoteUrl
|
||||
import com.philkes.notallyx.data.model.getNoteIdFromUrl
|
||||
import com.philkes.notallyx.data.model.getNoteTypeFromUrl
|
||||
import com.philkes.notallyx.data.model.isNoteUrl
|
||||
import com.philkes.notallyx.data.model.isWebUrl
|
||||
import com.philkes.notallyx.databinding.TextInputDialog2Binding
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXCLUDE_NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.PICKED_NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.PICKED_NOTE_TITLE
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.PICKED_NOTE_TYPE
|
||||
import com.philkes.notallyx.databinding.BottomTextFormattingMenuBinding
|
||||
import com.philkes.notallyx.databinding.RecyclerToggleBinding
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_EXCLUDE_NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_ID
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TITLE
|
||||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.copyToClipBoard
|
||||
import com.philkes.notallyx.presentation.getLatestText
|
||||
import com.philkes.notallyx.presentation.addIconButton
|
||||
import com.philkes.notallyx.presentation.createBoldSpan
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.presentation.hideKeyboard
|
||||
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
|
||||
import com.philkes.notallyx.presentation.setOnNextAction
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.showKeyboard
|
||||
import com.philkes.notallyx.presentation.view.Constants
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.note.TextFormattingAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.action.AddNoteActions
|
||||
import com.philkes.notallyx.presentation.view.note.action.AddNoteBottomSheet
|
||||
import com.philkes.notallyx.utils.LinkMovementMethod
|
||||
import com.philkes.notallyx.utils.copyToClipBoard
|
||||
import com.philkes.notallyx.utils.findAllOccurrences
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
|
||||
private const val UNNAMED_NOTE_PLACEHOLDER = "Unnamed Note"
|
||||
|
||||
class EditNoteActivity : EditActivity(Type.NOTE) {
|
||||
class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
||||
|
||||
private lateinit var selectedSpan: URLSpan
|
||||
private lateinit var pickNoteNewActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var pickNoteUpdateActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var textFormatMenu: View
|
||||
|
||||
override suspend fun saveNote() {
|
||||
super.saveNote()
|
||||
model.saveNote()
|
||||
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
|
||||
}
|
||||
private var textFormattingAdapter: TextFormattingAdapter? = null
|
||||
|
||||
private var searchResultIndices: List<Pair<Int, Int>>? = null
|
||||
|
||||
override fun configureUI() {
|
||||
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
|
||||
|
||||
setupEditor()
|
||||
|
||||
if (model.isNewNote) {
|
||||
if (notallyModel.isNewNote) {
|
||||
binding.EnterBody.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (resultCode == RESULT_OK) {
|
||||
when (requestCode) {
|
||||
REQUEST_CODE_PICK_NOTE_NEW -> {
|
||||
val noteId = data?.getLongExtra(PICKED_NOTE_ID, -1L)!!
|
||||
if (noteId == -1L) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
}
|
||||
|
||||
override fun toggleCanEdit(mode: NoteViewMode) {
|
||||
super.toggleCanEdit(mode)
|
||||
textFormatMenu.isVisible = mode == NoteViewMode.EDIT
|
||||
when {
|
||||
mode == NoteViewMode.EDIT -> showKeyboard(binding.EnterBody)
|
||||
binding.EnterBody.isFocused -> hideKeyboard(binding.EnterBody)
|
||||
}
|
||||
binding.EnterBody.setCanEdit(mode == NoteViewMode.EDIT)
|
||||
setupEditor()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.apply {
|
||||
putInt(EXTRA_SELECTION_START, binding.EnterBody.selectionStart)
|
||||
putInt(EXTRA_SELECTION_END, binding.EnterBody.selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
pickNoteNewActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
try {
|
||||
val (title, url, emptyTitle) = result.data.getPickedNoteData()
|
||||
if (emptyTitle) {
|
||||
binding.EnterBody.showAddLinkDialog(
|
||||
this,
|
||||
presetDisplayText = title,
|
||||
presetUrl = url,
|
||||
isNewUnnamedLink = true,
|
||||
)
|
||||
} else {
|
||||
binding.EnterBody.addSpans(title, listOf(UnderlineSpan(), URLSpan(url)))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
}
|
||||
pickNoteUpdateActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
try {
|
||||
val (title, url, emptyTitle) = result.data.getPickedNoteData()
|
||||
val newSpan = URLSpan(url)
|
||||
binding.EnterBody.updateSpan(selectedSpan, newSpan, title)
|
||||
if (emptyTitle) {
|
||||
binding.EnterBody.showEditDialog(newSpan, isNewUnnamedLink = true)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun highlightSearchResults(search: String): Int {
|
||||
binding.EnterBody.clearHighlights()
|
||||
if (search.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
searchResultIndices =
|
||||
notallyModel.body.toString().findAllOccurrences(search).onEach { (startIdx, endIdx) ->
|
||||
binding.EnterBody.highlight(startIdx, endIdx, false)
|
||||
}
|
||||
return searchResultIndices!!.size
|
||||
}
|
||||
|
||||
override fun selectSearchResult(resultPos: Int) {
|
||||
if (resultPos < 0) {
|
||||
binding.EnterBody.unselectHighlight()
|
||||
return
|
||||
}
|
||||
val noteTitle = data.getStringExtra(PICKED_NOTE_TITLE)!!
|
||||
val noteType = Type.valueOf(data.getStringExtra(PICKED_NOTE_TYPE)!!)
|
||||
binding.EnterBody.addSpan(noteTitle, URLSpan(noteId.createNoteUrl(noteType)))
|
||||
}
|
||||
REQUEST_CODE_PICK_NOTE_UPDATE -> {
|
||||
val noteId = data?.getLongExtra(PICKED_NOTE_ID, -1L)!!
|
||||
if (noteId == -1L) {
|
||||
return
|
||||
}
|
||||
// TODO: If the linked note title changes the link display text does not change
|
||||
val noteTitle =
|
||||
data.getStringExtra(PICKED_NOTE_TITLE)!!.ifEmpty {
|
||||
UNNAMED_NOTE_PLACEHOLDER
|
||||
}
|
||||
val noteType = Type.valueOf(data.getStringExtra(PICKED_NOTE_TYPE)!!)
|
||||
val noteUrl = noteId.createNoteUrl(noteType)
|
||||
binding.EnterBody.updateSpan(selectedSpan, URLSpan(noteUrl), noteTitle)
|
||||
}
|
||||
}
|
||||
searchResultIndices?.get(resultPos)?.let { (startIdx, endIdx) ->
|
||||
val selectedLineTop = binding.EnterBody.highlight(startIdx, endIdx, true)
|
||||
selectedLineTop?.let { binding.ScrollView.scrollTo(0, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupListeners() {
|
||||
super.setupListeners()
|
||||
binding.EnterBody.initHistory(changeHistory) { text -> model.body = text }
|
||||
binding.EnterBody.initHistory(changeHistory) { text ->
|
||||
val textChanged = !notallyModel.body.toString().contentEquals(text)
|
||||
notallyModel.body = text
|
||||
if (textChanged) {
|
||||
updateSearchResults(search.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setStateFromModel() {
|
||||
super.setStateFromModel()
|
||||
override fun setStateFromModel(savedInstanceState: Bundle?) {
|
||||
super.setStateFromModel(savedInstanceState)
|
||||
updateEditText()
|
||||
savedInstanceState?.let {
|
||||
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
|
||||
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
|
||||
if (selectionStart > -1) {
|
||||
binding.EnterBody.focusAndSelect(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEditText() {
|
||||
binding.EnterBody.text = model.body
|
||||
binding.EnterBody.text = notallyModel.body
|
||||
}
|
||||
|
||||
private fun setupEditor() {
|
||||
setupMovementMethod()
|
||||
|
||||
binding.EnterBody.customSelectionActionModeCallback =
|
||||
if (canEdit) {
|
||||
object : ActionMode.Callback {
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
|
||||
|
||||
|
@ -120,24 +198,53 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
// ActionMode implementation
|
||||
try {
|
||||
menu?.apply {
|
||||
add(R.string.bold, 0) {
|
||||
binding.EnterBody.applySpan(StyleSpan(Typeface.BOLD))
|
||||
add(
|
||||
R.string.link,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.showAddLinkDialog(
|
||||
this@EditNoteActivity,
|
||||
mode = mode,
|
||||
)
|
||||
}
|
||||
add(
|
||||
R.string.bold,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(createBoldSpan())
|
||||
mode?.finish()
|
||||
}
|
||||
add(R.string.link, 0) { showAddLinkDialog(mode) }
|
||||
add(R.string.italic, 0) {
|
||||
add(
|
||||
R.string.italic,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
|
||||
mode?.finish()
|
||||
}
|
||||
add(R.string.monospace, 0) {
|
||||
add(
|
||||
R.string.monospace,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
|
||||
mode?.finish()
|
||||
}
|
||||
add(R.string.strikethrough, 0) {
|
||||
add(
|
||||
R.string.strikethrough,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(StrikethroughSpan())
|
||||
mode?.finish()
|
||||
}
|
||||
add(R.string.clear_formatting, 0) {
|
||||
add(
|
||||
R.string.clear_formatting,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.clearFormatting()
|
||||
mode?.finish()
|
||||
}
|
||||
|
@ -148,54 +255,15 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
return true
|
||||
}
|
||||
|
||||
private fun showAddLinkDialog(mode: ActionMode?) {
|
||||
val urlFromClipboard =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
baseContext
|
||||
.getSystemService(ClipboardManager::class.java)!!
|
||||
.getLatestText()
|
||||
.let { if (it.isWebUrl()) it.toString() else "" }
|
||||
} else ""
|
||||
val displayTextBefore = binding.EnterBody.getSelectionText()!!
|
||||
this@EditNoteActivity.showEditLinkDialog(urlFromClipboard, displayTextBefore) {
|
||||
urlAfter,
|
||||
displayTextAfter ->
|
||||
if (displayTextAfter == displayTextBefore) {
|
||||
binding.EnterBody.applySpan(URLSpan(urlAfter))
|
||||
} else {
|
||||
binding.EnterBody.changeTextWithHistory { text ->
|
||||
val start = binding.EnterBody.selectionStart
|
||||
text.replace(
|
||||
start,
|
||||
binding.EnterBody.selectionEnd,
|
||||
displayTextAfter,
|
||||
)
|
||||
text.setSpan(
|
||||
URLSpan(urlAfter),
|
||||
start,
|
||||
start + displayTextAfter.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
mode?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
binding.EnterBody.isActionModeOn = false
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
binding.ContentLayout.setOnClickListener {
|
||||
binding.EnterBody.apply {
|
||||
requestFocus()
|
||||
setSelection(length())
|
||||
showKeyboard(this)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.EnterBody.customInsertionActionModeCallback =
|
||||
if (canEdit) {
|
||||
object : ActionMode.Callback {
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
|
||||
|
||||
|
@ -203,12 +271,17 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
binding.EnterBody.isActionModeOn = true
|
||||
// Try block is there because this will crash on MiUI as Xiaomi has a broken
|
||||
// Try block is there because this will crash on MiUI as Xiaomi has a
|
||||
// broken
|
||||
// ActionMode implementation
|
||||
try {
|
||||
menu?.apply {
|
||||
add(R.string.link_note, 0) {
|
||||
startPickNote(REQUEST_CODE_PICK_NOTE_NEW)
|
||||
add(
|
||||
R.string.link_note,
|
||||
0,
|
||||
order = Menu.CATEGORY_CONTAINER + 1,
|
||||
) {
|
||||
linkNote(pickNoteNewActivityResultLauncher)
|
||||
mode?.finish()
|
||||
}
|
||||
}
|
||||
|
@ -222,38 +295,126 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
binding.EnterBody.isActionModeOn = false
|
||||
}
|
||||
}
|
||||
} else null
|
||||
}
|
||||
if (canEdit) {
|
||||
binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
|
||||
if (selEnd - selStart > 0) {
|
||||
if (!textFormatMenu.isEnabled) {
|
||||
initBottomTextFormattingMenu()
|
||||
}
|
||||
textFormatMenu.isEnabled = true
|
||||
textFormattingAdapter?.updateTextFormattingToggles(selStart, selEnd)
|
||||
} else {
|
||||
if (textFormatMenu.isEnabled) {
|
||||
initBottomMenu()
|
||||
}
|
||||
textFormatMenu.isEnabled = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.EnterBody.setOnSelectionChange { _, _ -> }
|
||||
}
|
||||
binding.ContentLayout.setOnClickListener {
|
||||
binding.EnterBody.apply {
|
||||
requestFocus()
|
||||
if (canEdit) {
|
||||
setSelection(length())
|
||||
showKeyboard(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun EditNoteActivity.startPickNote(requestCode: Int) {
|
||||
override fun initBottomMenu() {
|
||||
super.initBottomMenu()
|
||||
binding.BottomAppBarCenter.visibility = VISIBLE
|
||||
binding.BottomAppBarLeft.apply {
|
||||
removeAllViews()
|
||||
addIconButton(R.string.add_item, R.drawable.add, marginStart = 0) {
|
||||
AddNoteBottomSheet(this@EditNoteActivity, colorInt)
|
||||
.show(supportFragmentManager, AddNoteBottomSheet.TAG)
|
||||
}
|
||||
updateLayoutParams<ConstraintLayout.LayoutParams> { endToStart = -1 }
|
||||
textFormatMenu =
|
||||
addIconButton(R.string.edit, R.drawable.text_format) {
|
||||
initBottomTextFormattingMenu()
|
||||
}
|
||||
.apply { isEnabled = binding.EnterBody.isActionModeOn }
|
||||
}
|
||||
setBottomAppBarColor(colorInt)
|
||||
}
|
||||
|
||||
private fun initBottomTextFormattingMenu() {
|
||||
binding.BottomAppBarCenter.visibility = GONE
|
||||
val extractColor = colorInt
|
||||
binding.BottomAppBarRight.apply {
|
||||
removeAllViews()
|
||||
addView(
|
||||
RecyclerToggleBinding.inflate(layoutInflater, this, false).root.apply {
|
||||
setIconResource(R.drawable.close)
|
||||
contentDescription = context.getString(R.string.cancel)
|
||||
setOnClickListener { initBottomMenu() }
|
||||
|
||||
updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
marginEnd = 0
|
||||
marginStart = 10.dp
|
||||
}
|
||||
setControlsContrastColorForAllViews(extractColor)
|
||||
setBackgroundColor(0)
|
||||
}
|
||||
)
|
||||
}
|
||||
binding.BottomAppBarLeft.apply {
|
||||
removeAllViews()
|
||||
updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
endToStart = R.id.BottomAppBarRight
|
||||
}
|
||||
requestLayout()
|
||||
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
|
||||
layout.MainListView.apply {
|
||||
textFormattingAdapter =
|
||||
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
|
||||
adapter = textFormattingAdapter
|
||||
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
}
|
||||
addView(layout.root)
|
||||
}
|
||||
}
|
||||
|
||||
override fun linkNote() {
|
||||
linkNote(pickNoteNewActivityResultLauncher)
|
||||
}
|
||||
|
||||
fun linkNote(activityResultLauncher: ActivityResultLauncher<Intent>) {
|
||||
val intent =
|
||||
Intent(this, PickNoteActivity::class.java).apply { putExtra(EXCLUDE_NOTE_ID, model.id) }
|
||||
startActivityForResult(intent, requestCode)
|
||||
Intent(this, PickNoteActivity::class.java).apply {
|
||||
putExtra(EXTRA_EXCLUDE_NOTE_ID, notallyModel.id)
|
||||
}
|
||||
activityResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun setupMovementMethod() {
|
||||
val movementMethod = LinkMovementMethod { span ->
|
||||
val items =
|
||||
if (span.url.isNoteUrl()) {
|
||||
if (canEdit) {
|
||||
arrayOf(
|
||||
getString(R.string.open_note),
|
||||
getString(R.string.remove_link),
|
||||
getString(R.string.change_note),
|
||||
getString(R.string.open_note),
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
getString(R.string.remove_link),
|
||||
getString(R.string.copy),
|
||||
getString(R.string.edit),
|
||||
getString(R.string.open_link),
|
||||
)
|
||||
} else arrayOf(getString(R.string.open_note))
|
||||
} else {
|
||||
if (canEdit) {
|
||||
arrayOf(
|
||||
getString(R.string.open_link),
|
||||
getString(R.string.copy),
|
||||
getString(R.string.remove_link),
|
||||
getString(R.string.edit),
|
||||
)
|
||||
} else arrayOf(getString(R.string.open_link), getString(R.string.copy))
|
||||
}
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(
|
||||
|
@ -265,31 +426,16 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
)
|
||||
.setItems(items) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
binding.EnterBody.removeSpan(span, true)
|
||||
}
|
||||
0 -> openLink(span)
|
||||
1 ->
|
||||
if (span.url.isNoteUrl()) {
|
||||
selectedSpan = span
|
||||
startPickNote(REQUEST_CODE_PICK_NOTE_UPDATE)
|
||||
} else {
|
||||
copyToClipBoard(span.url)
|
||||
Toast.makeText(this, R.string.copied_link, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
2 -> {
|
||||
span.url?.let {
|
||||
if (it.isNoteUrl()) {
|
||||
span.navigateToNote()
|
||||
} else {
|
||||
span.showEditDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3 -> {
|
||||
openLink(span.url)
|
||||
}
|
||||
removeLink(span)
|
||||
} else copyLink(span)
|
||||
2 ->
|
||||
if (span.url.isNoteUrl()) {
|
||||
changeNoteLink(span)
|
||||
} else removeLink(span)
|
||||
3 -> editLink(span)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
@ -297,13 +443,44 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
binding.EnterBody.movementMethod = movementMethod
|
||||
}
|
||||
|
||||
private fun openLink(span: URLSpan) {
|
||||
span.url?.let {
|
||||
if (it.isNoteUrl()) {
|
||||
span.navigateToNote()
|
||||
} else {
|
||||
openLink(span.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun editLink(span: URLSpan) {
|
||||
binding.EnterBody.showEditDialog(span)
|
||||
}
|
||||
|
||||
private fun changeNoteLink(span: URLSpan) {
|
||||
selectedSpan = span
|
||||
linkNote(pickNoteUpdateActivityResultLauncher)
|
||||
}
|
||||
|
||||
private fun copyLink(span: URLSpan) {
|
||||
copyToClipBoard(span.url)
|
||||
showToast(R.string.copied_link)
|
||||
}
|
||||
|
||||
private fun removeLink(span: URLSpan) {
|
||||
binding.EnterBody.removeSpanWithHistory(
|
||||
span,
|
||||
span.url.isNoteUrl() || span.url == binding.EnterBody.getSpanText(span),
|
||||
)
|
||||
}
|
||||
|
||||
private fun openLink(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this)
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (exception: Exception) {
|
||||
Toast.makeText(this, R.string.cant_open_link, Toast.LENGTH_LONG).show()
|
||||
showToast(R.string.cant_open_link)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -316,56 +493,30 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun URLSpan.showEditDialog() {
|
||||
val displayTextBefore = binding.EnterBody.getSpanText(this)
|
||||
showEditLinkDialog(url, displayTextBefore) { urlAfter, displayTextAfter ->
|
||||
if (urlAfter != null) {
|
||||
binding.EnterBody.updateSpan(
|
||||
this,
|
||||
URLSpan(urlAfter),
|
||||
if (displayTextAfter == displayTextBefore) null else displayTextAfter,
|
||||
)
|
||||
} else {
|
||||
binding.EnterBody.removeSpan(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToActivity(activity: Class<out Activity>, noteId: Long) {
|
||||
val intent = Intent(this, activity)
|
||||
intent.putExtra(Constants.SelectedBaseNote, noteId)
|
||||
startActivityForResult(intent, -1)
|
||||
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun showEditLinkDialog(
|
||||
urlBefore: String,
|
||||
displayTextBefore: String,
|
||||
onSuccess: (urlAfter: String?, displayTextAfter: String) -> Unit,
|
||||
) {
|
||||
val layout = TextInputDialog2Binding.inflate(layoutInflater)
|
||||
layout.InputText1.apply { setText(displayTextBefore) }
|
||||
layout.InputTextLayout1.setHint(R.string.display_text)
|
||||
layout.InputText2.apply { setText(urlBefore) }
|
||||
|
||||
layout.InputTextLayout2.setHint(R.string.link)
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setView(layout.root)
|
||||
.setTitle(R.string.edit_link)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val displayTextAfter = layout.InputText1.text.toString()
|
||||
val urlAfter = layout.InputText2.text.toString()
|
||||
onSuccess.invoke(urlAfter, displayTextAfter)
|
||||
private fun Intent?.getPickedNoteData(): Triple<String, String, Boolean> {
|
||||
val noteId = this?.getLongExtra(EXTRA_PICKED_NOTE_ID, -1L)!!
|
||||
if (noteId == -1L) {
|
||||
throw IllegalArgumentException("Invalid note picked!")
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
.setNeutralButton(R.string.clear) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
onSuccess.invoke(null, displayTextBefore)
|
||||
var emptyTitle = false
|
||||
val noteTitle =
|
||||
this.getStringExtra(EXTRA_PICKED_NOTE_TITLE)!!.ifEmpty {
|
||||
emptyTitle = true
|
||||
this@EditNoteActivity.getString(R.string.note)
|
||||
}
|
||||
.showAndFocus(layout.InputText2)
|
||||
val noteType = Type.valueOf(this.getStringExtra(EXTRA_PICKED_NOTE_TYPE)!!)
|
||||
val noteUrl = noteId.createNoteUrl(noteType)
|
||||
return Triple(noteTitle, noteUrl, emptyTitle)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_CODE_PICK_NOTE_NEW = 50
|
||||
const val REQUEST_CODE_PICK_NOTE_UPDATE = 51
|
||||
private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
|
||||
private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.philkes.notallyx.Preferences
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
|
@ -14,20 +13,22 @@ import com.philkes.notallyx.data.model.Header
|
|||
import com.philkes.notallyx.databinding.ActivityPickNoteBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.View
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
|
||||
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
|
||||
import com.philkes.notallyx.utils.getExternalImagesDirectory
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListItemListener {
|
||||
open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemListener {
|
||||
|
||||
protected lateinit var adapter: BaseNoteAdapter
|
||||
|
||||
private val excludedNoteId by lazy { intent.getLongExtra(EXCLUDE_NOTE_ID, -1L) }
|
||||
private val excludedNoteId by lazy { intent.getLongExtra(EXTRA_EXCLUDE_NOTE_ID, -1L) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -37,28 +38,32 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
|
|||
val result = Intent()
|
||||
setResult(RESULT_CANCELED, result)
|
||||
|
||||
val preferences = Preferences.getInstance(application)
|
||||
val preferences = NotallyXPreferences.getInstance(application)
|
||||
|
||||
adapter =
|
||||
with(preferences) {
|
||||
BaseNoteAdapter(
|
||||
Collections.emptySet(),
|
||||
dateFormat.value,
|
||||
notesSorting.value.first,
|
||||
notesSorting.value,
|
||||
BaseNoteVHPreferences(
|
||||
textSize.value,
|
||||
maxItems,
|
||||
maxLines,
|
||||
maxTitle,
|
||||
maxItems.value,
|
||||
maxLines.value,
|
||||
maxTitle.value,
|
||||
labelTagsHiddenInOverview.value,
|
||||
imagesHiddenInOverview.value,
|
||||
),
|
||||
application.getExternalImagesDirectory(),
|
||||
this@PickNoteActivity,
|
||||
)
|
||||
}
|
||||
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
adapter = this@PickNoteActivity.adapter
|
||||
setHasFixedSize(true)
|
||||
layoutManager =
|
||||
if (preferences.view.value == View.grid) {
|
||||
if (preferences.notesView.value == NotesView.GRID) {
|
||||
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
|
||||
} else LinearLayoutManager(this@PickNoteActivity)
|
||||
}
|
||||
|
@ -67,6 +72,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
|
|||
|
||||
val pinned = Header(getString(R.string.pinned))
|
||||
val others = Header(getString(R.string.others))
|
||||
val archived = Header(getString(R.string.archived))
|
||||
|
||||
database.observe(this) {
|
||||
lifecycleScope.launch {
|
||||
|
@ -74,7 +80,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
|
|||
withContext(Dispatchers.IO) {
|
||||
val raw =
|
||||
it.getBaseNoteDao().getAllNotes().filter { it.id != excludedNoteId }
|
||||
BaseNoteModel.transform(raw, pinned, others)
|
||||
BaseNoteModel.transform(raw, pinned, others, archived)
|
||||
}
|
||||
adapter.submitList(notes)
|
||||
binding.EmptyView.visibility =
|
||||
|
@ -87,9 +93,9 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
|
|||
if (position != -1) {
|
||||
val note = (adapter.getItem(position) as BaseNote)
|
||||
val success = Intent()
|
||||
success.putExtra(PICKED_NOTE_ID, note.id)
|
||||
success.putExtra(PICKED_NOTE_TITLE, note.title)
|
||||
success.putExtra(PICKED_NOTE_TYPE, note.type.name)
|
||||
success.putExtra(EXTRA_PICKED_NOTE_ID, note.id)
|
||||
success.putExtra(EXTRA_PICKED_NOTE_TITLE, note.title)
|
||||
success.putExtra(EXTRA_PICKED_NOTE_TYPE, note.type.name)
|
||||
setResult(RESULT_OK, success)
|
||||
finish()
|
||||
}
|
||||
|
@ -98,10 +104,10 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
|
|||
override fun onLongClick(position: Int) {}
|
||||
|
||||
companion object {
|
||||
const val EXCLUDE_NOTE_ID = "EXCLUDE_NOTE_ID"
|
||||
const val EXTRA_EXCLUDE_NOTE_ID = "notallyx.intent.extra.EXCLUDE_NOTE_ID"
|
||||
|
||||
const val PICKED_NOTE_ID = "PICKED_NOTE_ID"
|
||||
const val PICKED_NOTE_TITLE = "PICKED_NOTE_TITLE"
|
||||
const val PICKED_NOTE_TYPE = "PICKED_NOTE_TYPE"
|
||||
const val EXTRA_PICKED_NOTE_ID = "notallyx.intent.extra.PICKED_NOTE_ID"
|
||||
const val EXTRA_PICKED_NOTE_TITLE = "notallyx.intent.extra.PICKED_NOTE_TITLE"
|
||||
const val EXTRA_PICKED_NOTE_TYPE = "notallyx.intent.extra.PICKED_NOTE_TYPE"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,9 @@ import android.net.Uri
|
|||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
|
@ -15,9 +17,12 @@ import com.philkes.notallyx.data.model.Audio
|
|||
import com.philkes.notallyx.databinding.ActivityPlayAudioBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.utils.IO.getExternalAudioDirectory
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.utils.audio.AudioPlayService
|
||||
import com.philkes.notallyx.utils.audio.LocalBinder
|
||||
import com.philkes.notallyx.utils.getExternalAudioDirectory
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
@ -30,6 +35,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
|
||||
private var service: AudioPlayService? = null
|
||||
private lateinit var connection: ServiceConnection
|
||||
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private lateinit var audio: Audio
|
||||
|
||||
|
@ -38,7 +44,10 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
binding = ActivityPlayAudioBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
audio = requireNotNull(intent.getParcelableExtra(AUDIO))
|
||||
audio =
|
||||
requireNotNull(
|
||||
intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_AUDIO, Audio::class.java) }
|
||||
)
|
||||
binding.AudioControlView.setDuration(audio.duration)
|
||||
|
||||
val intent = Intent(this, AudioPlayService::class.java)
|
||||
|
@ -69,6 +78,13 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
}
|
||||
|
||||
setupToolbar(binding)
|
||||
|
||||
exportFileActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> writeAudioToUri(uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -84,13 +100,6 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
|
||||
data?.data?.let { uri -> writeAudioToUri(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupToolbar(binding: ActivityPlayAudioBinding) {
|
||||
binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
|
||||
|
||||
|
@ -105,26 +114,25 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
val audioRoot = application.getExternalAudioDirectory()
|
||||
val file = if (audioRoot != null) File(audioRoot, audio.name) else null
|
||||
if (file != null && file.exists()) {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
|
||||
|
||||
val uri = getUriForFile(file)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.apply {
|
||||
type = "audio/mp4"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
|
||||
val chooser = Intent.createChooser(intent, null)
|
||||
startActivity(chooser)
|
||||
.wrapWithChooser(this@PlayAudioActivity)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.delete_audio_recording_forever)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
val intent = Intent()
|
||||
intent.putExtra(AUDIO, audio)
|
||||
intent.putExtra(EXTRA_AUDIO, audio)
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
@ -136,16 +144,18 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
val file = if (audioRoot != null) File(audioRoot, audio.name) else null
|
||||
if (file != null && file.exists()) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = "audio/mp4"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
.wrapWithChooser(this)
|
||||
|
||||
val formatter = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT)
|
||||
val title = formatter.format(audio.timestamp)
|
||||
|
||||
intent.putExtra(Intent.EXTRA_TITLE, title)
|
||||
startActivityForResult(intent, REQUEST_EXPORT_FILE)
|
||||
exportFileActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -193,7 +203,6 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val AUDIO = "AUDIO"
|
||||
private const val REQUEST_EXPORT_FILE = 50
|
||||
const val EXTRA_AUDIO = "notallyx.intent.extra.AUDIO"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,21 +5,25 @@ import android.content.Intent
|
|||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.lifecycle.Observer
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.databinding.ActivityRecordAudioBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.utils.IO.getTempAudioFile
|
||||
import com.philkes.notallyx.utils.audio.AudioRecordService
|
||||
import com.philkes.notallyx.utils.audio.LocalBinder
|
||||
import com.philkes.notallyx.utils.audio.Status
|
||||
import com.philkes.notallyx.utils.getTempAudioFile
|
||||
|
||||
@RequiresApi(24)
|
||||
class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
||||
|
||||
private var service: AudioRecordService? = null
|
||||
private lateinit var connection: ServiceConnection
|
||||
private lateinit var serviceStatusObserver: Observer<Status>
|
||||
private lateinit var cancelRecordCallback: OnBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -33,7 +37,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
|||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
service = (binder as LocalBinder<AudioRecordService>).getService()
|
||||
updateUI(binding, requireNotNull(service))
|
||||
service?.status?.observe(this@RecordAudioActivity, serviceStatusObserver)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {}
|
||||
|
@ -44,12 +48,11 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
|||
binding.Main.setOnClickListener {
|
||||
val service = this.service
|
||||
if (service != null) {
|
||||
when (service.status) {
|
||||
when (service.status.value) {
|
||||
Status.PAUSED -> service.resume()
|
||||
Status.READY -> service.start()
|
||||
Status.RECORDING -> service.pause()
|
||||
}
|
||||
updateUI(binding, service)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,13 +63,30 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
|
||||
binding.Toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
|
||||
|
||||
cancelRecordCallback =
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
MaterialAlertDialogBuilder(this@RecordAudioActivity)
|
||||
.setMessage(R.string.save_recording)
|
||||
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service!!) }
|
||||
.setNegativeButton(R.string.discard) { _, _ -> discard(service!!) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(cancelRecordCallback)
|
||||
serviceStatusObserver = Observer { status ->
|
||||
updateUI(binding, service!!)
|
||||
cancelRecordCallback.isEnabled = status != Status.READY
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (service != null) {
|
||||
service?.let {
|
||||
unbindService(connection)
|
||||
it.status.removeObserver(serviceStatusObserver)
|
||||
service = null
|
||||
}
|
||||
if (isFinishing) {
|
||||
|
@ -75,19 +95,6 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
val service = this.service
|
||||
if (service != null) {
|
||||
if (service.status != Status.READY) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.save_recording)
|
||||
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service) }
|
||||
.setNegativeButton(R.string.discard) { _, _ -> discard(service) }
|
||||
.show()
|
||||
} else super.onBackPressed()
|
||||
} else super.onBackPressed()
|
||||
}
|
||||
|
||||
private fun discard(service: AudioRecordService) {
|
||||
service.stop()
|
||||
getTempAudioFile().delete()
|
||||
|
@ -102,7 +109,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
|
|||
|
||||
private fun updateUI(binding: ActivityRecordAudioBinding, service: AudioRecordService) {
|
||||
binding.Timer.base = service.getBase()
|
||||
when (service.status) {
|
||||
when (service.status.value) {
|
||||
Status.READY -> {
|
||||
binding.Stop.isEnabled = false
|
||||
binding.Main.setText(R.string.start)
|
||||
|
|
|
@ -3,8 +3,6 @@ package com.philkes.notallyx.presentation.activity.note
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
@ -14,14 +12,13 @@ import com.philkes.notallyx.databinding.ActivityLabelBinding
|
|||
import com.philkes.notallyx.databinding.DialogInputBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.view.main.SelectableLabelAdapter
|
||||
import com.philkes.notallyx.presentation.viewmodel.LabelModel
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter
|
||||
|
||||
class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
||||
|
||||
private val model: LabelModel by viewModels()
|
||||
|
||||
private lateinit var selectedLabels: ArrayList<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -29,12 +26,12 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
binding = ActivityLabelBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val savedList = savedInstanceState?.getStringArrayList(SELECTED_LABELS)
|
||||
val passedList = requireNotNull(intent.getStringArrayListExtra(SELECTED_LABELS))
|
||||
val savedList = savedInstanceState?.getStringArrayList(EXTRA_SELECTED_LABELS)
|
||||
val passedList = requireNotNull(intent.getStringArrayListExtra(EXTRA_SELECTED_LABELS))
|
||||
selectedLabels = savedList ?: passedList
|
||||
|
||||
val result = Intent()
|
||||
result.putExtra(SELECTED_LABELS, selectedLabels)
|
||||
result.putExtra(EXTRA_SELECTED_LABELS, selectedLabels)
|
||||
setResult(RESULT_OK, result)
|
||||
|
||||
setupToolbar()
|
||||
|
@ -43,7 +40,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(SELECTED_LABELS, selectedLabels)
|
||||
outState.putStringArrayList(EXTRA_SELECTED_LABELS, selectedLabels)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
|
@ -59,19 +56,19 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.add_label)
|
||||
.setView(binding.root)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val value = binding.EditText.text.toString().trim()
|
||||
if (value.isNotEmpty()) {
|
||||
val label = Label(value)
|
||||
model.insertLabel(label) { success ->
|
||||
baseModel.insertLabel(label) { success ->
|
||||
if (success) {
|
||||
dialog.dismiss()
|
||||
} else Toast.makeText(this, R.string.label_exists, Toast.LENGTH_LONG).show()
|
||||
} else showToast(R.string.label_exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
.showAndFocus(binding.EditText)
|
||||
.showAndFocus(binding.EditText, allowFullSize = true)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
|
@ -87,7 +84,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
setHasFixedSize(true)
|
||||
adapter = labelAdapter
|
||||
addItemDecoration(
|
||||
|
@ -95,7 +92,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
)
|
||||
}
|
||||
|
||||
model.labels.observe(this) { labels ->
|
||||
baseModel.labels.observe(this) { labels ->
|
||||
labelAdapter.submitList(labels)
|
||||
if (labels.isEmpty()) {
|
||||
binding.EmptyState.visibility = View.VISIBLE
|
||||
|
@ -104,6 +101,6 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val SELECTED_LABELS = "SELECTED_LABELS"
|
||||
const val EXTRA_SELECTED_LABELS = "notallyx.intent.extra.SELECTED_LABELS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@ import android.content.Intent
|
|||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.PagerSnapHelper
|
||||
|
@ -17,10 +19,13 @@ import com.philkes.notallyx.data.model.Converters
|
|||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.databinding.ActivityViewImageBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.view.Constants
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.view.note.image.ImageAdapter
|
||||
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.utils.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
@ -32,36 +37,47 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
|
||||
private var currentImage: FileAttachment? = null
|
||||
private lateinit var deletedImages: ArrayList<FileAttachment>
|
||||
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityViewImageBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val savedList = savedInstanceState?.getParcelableArrayList<FileAttachment>(DELETED_IMAGES)
|
||||
val savedList =
|
||||
savedInstanceState?.let {
|
||||
BundleCompat.getParcelableArrayList(
|
||||
it,
|
||||
EXTRA_DELETED_IMAGES,
|
||||
FileAttachment::class.java,
|
||||
)
|
||||
}
|
||||
deletedImages = savedList ?: ArrayList()
|
||||
|
||||
val result = Intent()
|
||||
result.putExtra(DELETED_IMAGES, deletedImages)
|
||||
setResult(RESULT_OK, result)
|
||||
val resultIntent = Intent()
|
||||
resultIntent.putExtra(EXTRA_DELETED_IMAGES, deletedImages)
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
|
||||
val savedImage = savedInstanceState?.getParcelable<FileAttachment>(CURRENT_IMAGE)
|
||||
val savedImage =
|
||||
savedInstanceState?.let {
|
||||
BundleCompat.getParcelable(it, CURRENT_IMAGE, FileAttachment::class.java)
|
||||
}
|
||||
if (savedImage != null) {
|
||||
currentImage = savedImage
|
||||
}
|
||||
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
setHasFixedSize(true)
|
||||
layoutManager =
|
||||
LinearLayoutManager(this@ViewImageActivity, RecyclerView.HORIZONTAL, false)
|
||||
PagerSnapHelper().attachToRecyclerView(binding.RecyclerView)
|
||||
PagerSnapHelper().attachToRecyclerView(binding.MainListView)
|
||||
}
|
||||
|
||||
val initial = intent.getIntExtra(POSITION, 0)
|
||||
binding.RecyclerView.scrollToPosition(initial)
|
||||
val initial = intent.getIntExtra(EXTRA_POSITION, 0)
|
||||
binding.MainListView.scrollToPosition(initial)
|
||||
|
||||
val database = NotallyDatabase.getDatabase(application)
|
||||
val id = intent.getLongExtra(Constants.SelectedBaseNote, 0)
|
||||
val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
|
||||
|
||||
database.observe(this@ViewImageActivity) {
|
||||
lifecycleScope.launch {
|
||||
|
@ -72,31 +88,31 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
|
||||
val mediaRoot = application.getExternalImagesDirectory()
|
||||
val adapter = ImageAdapter(mediaRoot, images)
|
||||
binding.RecyclerView.adapter = adapter
|
||||
binding.MainListView.adapter = adapter
|
||||
setupToolbar(binding, adapter)
|
||||
}
|
||||
}
|
||||
|
||||
exportFileActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> writeImageToUri(uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.apply {
|
||||
putParcelable(CURRENT_IMAGE, currentImage)
|
||||
putParcelableArrayList(DELETED_IMAGES, deletedImages)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
|
||||
data?.data?.let { uri -> writeImageToUri(uri) }
|
||||
putParcelableArrayList(EXTRA_DELETED_IMAGES, deletedImages)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupToolbar(binding: ActivityViewImageBinding, adapter: ImageAdapter) {
|
||||
binding.Toolbar.setNavigationOnClickListener { finish() }
|
||||
|
||||
val layoutManager = binding.RecyclerView.layoutManager as LinearLayoutManager
|
||||
val layoutManager = binding.MainListView.layoutManager as LinearLayoutManager
|
||||
adapter.registerAdapterDataObserver(
|
||||
object : RecyclerView.AdapterDataObserver() {
|
||||
|
||||
|
@ -107,7 +123,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
}
|
||||
)
|
||||
|
||||
binding.RecyclerView.addOnScrollListener(
|
||||
binding.MainListView.addOnScrollListener(
|
||||
object : RecyclerView.OnScrollListener() {
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
|
@ -144,10 +160,10 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
val mediaRoot = application.getExternalImagesDirectory()
|
||||
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
|
||||
if (file != null && file.exists()) {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
|
||||
|
||||
val uri = getUriForFile(file)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.apply {
|
||||
type = image.mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
|
||||
|
@ -157,8 +173,8 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
clipData = ClipData.newRawUri(null, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, null)
|
||||
startActivity(chooser)
|
||||
.wrapWithChooser(this@ViewImageActivity)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -167,13 +183,15 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
|
||||
if (file != null && file.exists()) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = image.mimeType
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "NotallyX Image")
|
||||
}
|
||||
.wrapWithChooser(this)
|
||||
currentImage = image
|
||||
startActivityForResult(intent, REQUEST_EXPORT_FILE)
|
||||
exportFileActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -201,7 +219,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
private fun delete(position: Int, adapter: ImageAdapter) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.delete_image_forever)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelButton()
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
val image = adapter.items.removeAt(position)
|
||||
deletedImages.add(image)
|
||||
|
@ -214,9 +232,8 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val POSITION = "POSITION"
|
||||
const val EXTRA_POSITION = "notallyx.intent.extra.POSITION"
|
||||
const val CURRENT_IMAGE = "CURRENT_IMAGE"
|
||||
const val DELETED_IMAGES = "DELETED_IMAGES"
|
||||
private const val REQUEST_EXPORT_FILE = 40
|
||||
const val EXTRA_DELETED_IMAGES = "notallyx.intent.extra.DELETED_IMAGES"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package com.philkes.notallyx.presentation.activity.note.reminders
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.philkes.notallyx.utils.now
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
class DatePickerFragment(
|
||||
private val date: Date?,
|
||||
private val listener: DatePickerDialog.OnDateSetListener,
|
||||
) : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val now = now()
|
||||
val c = date?.let { Calendar.getInstance().apply { time = it } } ?: now
|
||||
val year = c.get(Calendar.YEAR)
|
||||
val month = c.get(Calendar.MONTH)
|
||||
val day = c.get(Calendar.DAY_OF_MONTH)
|
||||
return DatePickerDialog(requireContext(), listener, year, month, day)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
package com.philkes.notallyx.presentation.activity.note.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.Reminder
|
||||
import com.philkes.notallyx.utils.canScheduleAlarms
|
||||
import com.philkes.notallyx.utils.cancelReminder
|
||||
import com.philkes.notallyx.utils.createChannelIfNotExists
|
||||
import com.philkes.notallyx.utils.getOpenNotePendingIntent
|
||||
import com.philkes.notallyx.utils.scheduleReminder
|
||||
import com.philkes.notallyx.utils.truncate
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* [BroadcastReceiver] for sending notifications via [NotificationManager] for [Reminder]s.
|
||||
* Reschedules reminders on [Intent.ACTION_BOOT_COMPLETED] or if
|
||||
* [AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED] has changed and exact alarms
|
||||
* are allowed. For [Reminder] that have [Reminder.repetition] it automatically reschedules the next
|
||||
* alarm.
|
||||
*/
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.d(TAG, "onReceive: ${intent?.action}")
|
||||
if (intent == null || context == null) {
|
||||
return
|
||||
}
|
||||
val canScheduleExactAlarms = context.canScheduleAlarms()
|
||||
if (intent.action == null) {
|
||||
if (!canScheduleExactAlarms) {
|
||||
return
|
||||
}
|
||||
val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
|
||||
val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
|
||||
notify(context, noteId, reminderId)
|
||||
} else {
|
||||
when {
|
||||
canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED ->
|
||||
rescheduleAlarms(context)
|
||||
|
||||
intent.action ==
|
||||
AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
|
||||
if (canScheduleExactAlarms) {
|
||||
rescheduleAlarms(context)
|
||||
} else {
|
||||
cancelAlarms(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(context: Context, noteId: Long, reminderId: Long) {
|
||||
Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val database =
|
||||
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
|
||||
val manager = context.getSystemService<NotificationManager>()!!
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createChannelIfNotExists(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
importance = NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
}
|
||||
database.getBaseNoteDao().get(noteId)?.let { note ->
|
||||
val notification =
|
||||
NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.notebook)
|
||||
.setContentTitle(note.title) // Set title from intent
|
||||
.setContentText(note.body.truncate(200)) // Set content text from intent
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.addAction(
|
||||
R.drawable.visibility,
|
||||
context.getString(R.string.open_note),
|
||||
context.getOpenNotePendingIntent(note),
|
||||
)
|
||||
.build()
|
||||
note.reminders
|
||||
.find { it.id == reminderId }
|
||||
?.let { reminder: Reminder ->
|
||||
manager.notify(note.id.toString(), reminderId.toInt(), notification)
|
||||
context.scheduleReminder(note.id, reminder, forceRepetition = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rescheduleAlarms(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val database =
|
||||
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
|
||||
val now = Date()
|
||||
val noteReminders = database.getBaseNoteDao().getAllReminders()
|
||||
val noteRemindersWithFutureNotify =
|
||||
noteReminders.flatMap { (noteId, reminders) ->
|
||||
reminders
|
||||
.filter { reminder ->
|
||||
reminder.repetition != null || reminder.dateTime.after(now)
|
||||
}
|
||||
.map { reminder -> Pair(noteId, reminder) }
|
||||
}
|
||||
Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms")
|
||||
noteRemindersWithFutureNotify.forEach { (noteId, reminder) ->
|
||||
context.scheduleReminder(noteId, reminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelAlarms(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val database =
|
||||
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
|
||||
val noteReminders = database.getBaseNoteDao().getAllReminders()
|
||||
val noteRemindersWithFutureNotify =
|
||||
noteReminders.flatMap { (noteId, reminders) ->
|
||||
reminders.map { reminder -> Pair(noteId, reminder.id) }
|
||||
}
|
||||
Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms")
|
||||
noteRemindersWithFutureNotify.forEach { (noteId, reminderId) ->
|
||||
context.cancelReminder(noteId, reminderId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderReceiver"
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_ID = "Reminders"
|
||||
|
||||
const val EXTRA_REMINDER_ID = "notallyx.intent.extra.REMINDER_ID"
|
||||
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,328 @@
|
|||
package com.philkes.notallyx.presentation.activity.note.reminders
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.RadioButton
|
||||
import android.widget.TimePicker
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Reminder
|
||||
import com.philkes.notallyx.data.model.Repetition
|
||||
import com.philkes.notallyx.data.model.RepetitionTimeUnit
|
||||
import com.philkes.notallyx.data.model.toCalendar
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.databinding.ActivityRemindersBinding
|
||||
import com.philkes.notallyx.databinding.DialogReminderCustomRepetitionBinding
|
||||
import com.philkes.notallyx.databinding.DialogReminderRepetitionBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.add
|
||||
import com.philkes.notallyx.presentation.checkAlarmPermission
|
||||
import com.philkes.notallyx.presentation.checkNotificationPermission
|
||||
import com.philkes.notallyx.presentation.initListView
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.view.main.reminder.ReminderAdapter
|
||||
import com.philkes.notallyx.presentation.view.main.reminder.ReminderListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
|
||||
import com.philkes.notallyx.utils.canScheduleAlarms
|
||||
import com.philkes.notallyx.utils.now
|
||||
import java.util.Calendar
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderListener {
|
||||
|
||||
private lateinit var alarmPermissionActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private val model: NotallyModel by viewModels()
|
||||
private lateinit var reminderAdapter: ReminderAdapter
|
||||
private var selectedReminder: Reminder? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityRemindersBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
|
||||
alarmPermissionActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (canScheduleAlarms()) {
|
||||
showDatePickerDialog(selectedReminder)
|
||||
}
|
||||
}
|
||||
val noteId = intent.getLongExtra(NOTE_ID, 0L)
|
||||
lifecycleScope.launch {
|
||||
model.setState(noteId)
|
||||
if (model.reminders.value.isEmpty()) {
|
||||
showDatePickerDialog()
|
||||
} else if (!canScheduleAlarms()) {
|
||||
checkNotificationPermission(
|
||||
REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
alsoCheckAlarmPermission = true,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE -> {
|
||||
if (
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
checkAlarmPermission(alarmPermissionActivityResultLauncher) {
|
||||
showDatePickerDialog(selectedReminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
binding.Toolbar.apply {
|
||||
setNavigationOnClickListener { finish() }
|
||||
menu.add(R.string.add_reminder, R.drawable.add) { showDatePickerDialog() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
reminderAdapter = ReminderAdapter(this)
|
||||
binding.MainListView.apply {
|
||||
initListView(this@RemindersActivity)
|
||||
adapter = reminderAdapter
|
||||
}
|
||||
model.reminders.observe(this) { reminders ->
|
||||
reminderAdapter.submitList(reminders)
|
||||
if (reminders.isEmpty()) {
|
||||
binding.EmptyState.visibility = View.VISIBLE
|
||||
} else binding.EmptyState.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDatePickerDialog(reminder: Reminder? = null, calendar: Calendar? = null) {
|
||||
selectedReminder = reminder
|
||||
checkNotificationPermission(
|
||||
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE,
|
||||
alsoCheckAlarmPermission = true,
|
||||
alarmPermissionResultLauncher = alarmPermissionActivityResultLauncher,
|
||||
) {
|
||||
DatePickerFragment(calendar?.time ?: reminder?.dateTime) { _, year, month, day ->
|
||||
val usedCalendar = calendar ?: reminder?.dateTime?.toCalendar() ?: now()
|
||||
usedCalendar.set(year, month, day)
|
||||
showTimePickerDialog(reminder, usedCalendar)
|
||||
}
|
||||
.show(supportFragmentManager, "reminderDatePicker")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTimePickerDialog(reminder: Reminder? = null, calendar: Calendar) {
|
||||
TimePickerFragment(
|
||||
calendar,
|
||||
object : TimePickerListener {
|
||||
override fun onBack() {
|
||||
showDatePickerDialog(reminder, calendar)
|
||||
}
|
||||
|
||||
override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
|
||||
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
calendar.set(Calendar.MINUTE, minute)
|
||||
showRepetitionDialog(reminder, calendar) { updatedRepetition ->
|
||||
val updatedReminder =
|
||||
Reminder(
|
||||
reminder?.id ?: NEW_REMINDER_ID,
|
||||
calendar.time,
|
||||
updatedRepetition,
|
||||
)
|
||||
if (reminder != null) {
|
||||
lifecycleScope.launch { model.updateReminder(updatedReminder) }
|
||||
} else {
|
||||
lifecycleScope.launch { model.addReminder(updatedReminder) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.show(supportFragmentManager, "reminderTimePicker")
|
||||
}
|
||||
|
||||
private fun showRepetitionDialog(
|
||||
reminder: Reminder? = null,
|
||||
calendar: Calendar,
|
||||
fromCustomRepetitionDialog: Boolean = false,
|
||||
onRepetitionSelected: (Repetition?) -> Unit,
|
||||
) {
|
||||
val dialogView =
|
||||
DialogReminderRepetitionBinding.inflate(layoutInflater).apply {
|
||||
if (reminder == null && fromCustomRepetitionDialog) {
|
||||
None.isChecked = true
|
||||
} else {
|
||||
reminder?.repetition.apply {
|
||||
when {
|
||||
this == null -> None.isChecked = true
|
||||
value == 1 && unit == RepetitionTimeUnit.DAYS -> Daily.isChecked = true
|
||||
|
||||
value == 1 && unit == RepetitionTimeUnit.WEEKS ->
|
||||
Weekly.isChecked = true
|
||||
|
||||
value == 1 && unit == RepetitionTimeUnit.MONTHS ->
|
||||
Monthly.isChecked = true
|
||||
|
||||
value == 1 && unit == RepetitionTimeUnit.YEARS ->
|
||||
Yearly.isChecked = true
|
||||
|
||||
fromCustomRepetitionDialog -> Custom.isChecked = true
|
||||
else -> {
|
||||
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dialog =
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.repetition)
|
||||
.setView(dialogView.root)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val repetition =
|
||||
when (dialogView.RepetitionOptions.checkedRadioButtonId) {
|
||||
R.id.None -> null
|
||||
R.id.Daily -> Repetition(1, RepetitionTimeUnit.DAYS)
|
||||
R.id.Weekly -> Repetition(1, RepetitionTimeUnit.WEEKS)
|
||||
R.id.Monthly -> Repetition(1, RepetitionTimeUnit.MONTHS)
|
||||
R.id.Yearly -> Repetition(1, RepetitionTimeUnit.YEARS)
|
||||
R.id.Custom -> reminder?.repetition?.copy()
|
||||
else -> null
|
||||
}
|
||||
onRepetitionSelected(repetition)
|
||||
}
|
||||
.setNegativeButton(R.string.back) { _, _ ->
|
||||
showTimePickerDialog(reminder, calendar)
|
||||
}
|
||||
.show()
|
||||
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
dialogView.apply {
|
||||
Custom.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
|
||||
}
|
||||
None.setOnCheckedEnableButton(positiveButton)
|
||||
Daily.setOnCheckedEnableButton(positiveButton)
|
||||
Weekly.setOnCheckedEnableButton(positiveButton)
|
||||
Monthly.setOnCheckedEnableButton(positiveButton)
|
||||
Yearly.setOnCheckedEnableButton(positiveButton)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomRepetitionDialog(
|
||||
reminder: Reminder? = null,
|
||||
calendar: Calendar,
|
||||
onRepetitionSelected: (Repetition?) -> Unit,
|
||||
) {
|
||||
val dialogView =
|
||||
DialogReminderCustomRepetitionBinding.inflate(layoutInflater).apply {
|
||||
reminder?.repetition?.let {
|
||||
when (it.unit) {
|
||||
RepetitionTimeUnit.MINUTES -> Minutes
|
||||
RepetitionTimeUnit.HOURS -> Hours
|
||||
RepetitionTimeUnit.DAYS -> Days
|
||||
RepetitionTimeUnit.WEEKS -> Weeks
|
||||
RepetitionTimeUnit.MONTHS -> Months
|
||||
RepetitionTimeUnit.YEARS -> Years
|
||||
}.isChecked = true
|
||||
Value.setText(it.value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
val dialog =
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.repetition_custom)
|
||||
.setView(dialogView.root)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val value = dialogView.Value.text.toString().toIntOrNull() ?: 1
|
||||
val selectedTimeUnit =
|
||||
when (dialogView.TimeUnitGroup.checkedRadioButtonId) {
|
||||
R.id.Minutes -> RepetitionTimeUnit.MINUTES
|
||||
R.id.Hours -> RepetitionTimeUnit.HOURS
|
||||
R.id.Days -> RepetitionTimeUnit.DAYS
|
||||
R.id.Weeks -> RepetitionTimeUnit.WEEKS
|
||||
R.id.Months -> RepetitionTimeUnit.MONTHS
|
||||
R.id.Years -> RepetitionTimeUnit.YEARS
|
||||
else -> null
|
||||
}
|
||||
onRepetitionSelected(selectedTimeUnit?.let { Repetition(value, it) })
|
||||
}
|
||||
.setBackgroundInsetBottom(0)
|
||||
.setBackgroundInsetTop(0)
|
||||
.setNegativeButton(R.string.back) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
showRepetitionDialog(
|
||||
reminder,
|
||||
calendar,
|
||||
fromCustomRepetitionDialog = true,
|
||||
onRepetitionSelected,
|
||||
)
|
||||
}
|
||||
.showAndFocus(dialogView.Value, allowFullSize = true)
|
||||
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
dialogView.Value.doAfterTextChanged { text ->
|
||||
positiveButton.isEnabled = text.hasValueBiggerZero()
|
||||
}
|
||||
positiveButton.isEnabled = reminder?.repetition != null
|
||||
}
|
||||
|
||||
private fun RadioButton.setOnCheckedEnableButton(button: Button) {
|
||||
setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) {
|
||||
button.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Editable?.hasValueBiggerZero() =
|
||||
(!isNullOrEmpty() && toString().toIntOrNull()?.let { it > 0 } ?: false)
|
||||
|
||||
private fun confirmDeletion(reminder: Reminder) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_reminder)
|
||||
.setMessage(
|
||||
"${reminder.dateTime.toText()}\n${reminder.repetition?.toText(this) ?: getString(R.string.reminder_no_repetition)}"
|
||||
)
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
lifecycleScope.launch { model.removeReminder(reminder) }
|
||||
}
|
||||
.setCancelButton()
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun delete(reminder: Reminder) {
|
||||
confirmDeletion(reminder)
|
||||
}
|
||||
|
||||
override fun edit(reminder: Reminder) {
|
||||
showDatePickerDialog(reminder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NOTE_ID = "NOTE_ID"
|
||||
const val REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE = 101
|
||||
const val REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE = 102
|
||||
const val NEW_REMINDER_ID = -1L
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.philkes.notallyx.presentation.activity.note.reminders
|
||||
|
||||
import android.app.Dialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateFormat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.philkes.notallyx.R
|
||||
import java.util.Calendar
|
||||
|
||||
class TimePickerFragment(private val calendar: Calendar, private val listener: TimePickerListener) :
|
||||
DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val hour = calendar.get(Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(Calendar.MINUTE)
|
||||
val dialog =
|
||||
TimePickerDialog(activity, listener, hour, minute, DateFormat.is24HourFormat(activity))
|
||||
dialog.setButton(
|
||||
DialogInterface.BUTTON_NEGATIVE,
|
||||
requireContext().getText(R.string.back),
|
||||
) { _, _ ->
|
||||
listener.onBack()
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
|
||||
interface TimePickerListener : TimePickerDialog.OnTimeSetListener {
|
||||
fun onBack()
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view
|
||||
|
||||
object Constants {
|
||||
const val SelectedLabel = "SelectedLabel"
|
||||
const val SelectedBaseNote = "SelectedBaseNote"
|
||||
}
|
|
@ -10,29 +10,26 @@ import com.philkes.notallyx.data.model.Header
|
|||
import com.philkes.notallyx.data.model.Item
|
||||
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
|
||||
import com.philkes.notallyx.databinding.RecyclerHeaderBinding
|
||||
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteColorSort
|
||||
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteCreationDateSort
|
||||
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteModifiedDateSort
|
||||
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteTitleSort
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByModifiedDate
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByTitle
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
|
||||
import java.io.File
|
||||
|
||||
class BaseNoteAdapter(
|
||||
private val selectedIds: Set<Long>,
|
||||
private val dateFormat: String,
|
||||
private val sortedBy: String,
|
||||
private val textSize: String,
|
||||
private val maxItems: Int,
|
||||
private val maxLines: Int,
|
||||
private val maxTitle: Int,
|
||||
private val dateFormat: DateFormat,
|
||||
private var notesSort: NotesSort,
|
||||
private val preferences: BaseNoteVHPreferences,
|
||||
private val imageRoot: File?,
|
||||
private val listener: ListItemListener,
|
||||
private val listener: ItemListener,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var list =
|
||||
SortedList(Item::class.java, BaseNoteCreationDateSort(this, SortDirection.ASC))
|
||||
private var list = SortedList(Item::class.java, notesSort.createCallback())
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (list[position]) {
|
||||
|
@ -53,7 +50,7 @@ class BaseNoteAdapter(
|
|||
item,
|
||||
imageRoot,
|
||||
selectedIds.contains(item.id),
|
||||
sortedBy,
|
||||
notesSort.sortedBy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -77,19 +74,14 @@ class BaseNoteAdapter(
|
|||
}
|
||||
else -> {
|
||||
val binding = RecyclerBaseNoteBinding.inflate(inflater, parent, false)
|
||||
BaseNoteVH(binding, dateFormat, textSize, maxItems, maxLines, maxTitle, listener)
|
||||
BaseNoteVH(binding, dateFormat, preferences, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSorting(sortBy: String, sortDirection: SortDirection) {
|
||||
val sortCallback =
|
||||
when (sortBy) {
|
||||
autoSortByTitle -> BaseNoteTitleSort(this, sortDirection)
|
||||
autoSortByModifiedDate -> BaseNoteModifiedDateSort(this, sortDirection)
|
||||
else -> BaseNoteCreationDateSort(this, sortDirection)
|
||||
}
|
||||
replaceSorting(sortCallback)
|
||||
fun setNotesSort(notesSort: NotesSort) {
|
||||
this.notesSort = notesSort
|
||||
replaceSortCallback(notesSort.createCallback())
|
||||
}
|
||||
|
||||
fun getItem(position: Int): Item? {
|
||||
|
@ -103,7 +95,17 @@ class BaseNoteAdapter(
|
|||
list.replaceAll(items)
|
||||
}
|
||||
|
||||
private fun replaceSorting(sortCallback: SortedListAdapterCallback<Item>) {
|
||||
private fun NotesSort.createCallback() =
|
||||
when (sortedBy) {
|
||||
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.MODIFIED_DATE ->
|
||||
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.CREATION_DATE ->
|
||||
BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.COLOR -> BaseNoteColorSort(this@BaseNoteAdapter, sortDirection)
|
||||
}
|
||||
|
||||
private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {
|
||||
val mutableList = mutableListOf<Item>()
|
||||
for (i in 0 until list.size()) {
|
||||
mutableList.add(list[i])
|
||||
|
@ -115,7 +117,7 @@ class BaseNoteAdapter(
|
|||
|
||||
private fun handleCheck(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val baseNote = list[position] as BaseNote
|
||||
(holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id))
|
||||
(holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id), baseNote.color)
|
||||
}
|
||||
|
||||
private fun <T> SortedList<T>.toList(): List<T> {
|
||||
|
|
|
@ -2,12 +2,14 @@ package com.philkes.notallyx.presentation.view.main
|
|||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DataSource
|
||||
|
@ -18,36 +20,44 @@ import com.bumptech.glide.request.RequestListener
|
|||
import com.bumptech.glide.request.target.Target
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.SpanRepresentation
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.hasUpcomingNotification
|
||||
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
|
||||
import com.philkes.notallyx.presentation.applySpans
|
||||
import com.philkes.notallyx.presentation.bindLabels
|
||||
import com.philkes.notallyx.presentation.displayFormattedTimestamp
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.presentation.extractColor
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByCreationDate
|
||||
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByModifiedDate
|
||||
import com.philkes.notallyx.presentation.view.misc.TextSize
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
import java.io.File
|
||||
|
||||
data class BaseNoteVHPreferences(
|
||||
val textSize: TextSize,
|
||||
val maxItems: Int,
|
||||
val maxLines: Int,
|
||||
val maxTitleLines: Int,
|
||||
val hideLabels: Boolean,
|
||||
val hideImages: Boolean,
|
||||
)
|
||||
|
||||
class BaseNoteVH(
|
||||
private val binding: RecyclerBaseNoteBinding,
|
||||
private val dateFormat: String,
|
||||
private val textSize: String,
|
||||
private val maxItems: Int,
|
||||
maxLines: Int,
|
||||
maxTitle: Int,
|
||||
listener: ListItemListener,
|
||||
private val dateFormat: DateFormat,
|
||||
private val preferences: BaseNoteVHPreferences,
|
||||
listener: ItemListener,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
val title = TextSize.getDisplayTitleSize(textSize)
|
||||
val body = TextSize.getDisplayBodySize(textSize)
|
||||
val title = preferences.textSize.displayTitleSize
|
||||
val body = preferences.textSize.displayBodySize
|
||||
|
||||
binding.apply {
|
||||
Title.setTextSize(TypedValue.COMPLEX_UNIT_SP, title)
|
||||
|
@ -59,73 +69,107 @@ class BaseNoteVH(
|
|||
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
|
||||
}
|
||||
|
||||
Title.maxLines = maxTitle
|
||||
Note.maxLines = maxLines
|
||||
Title.maxLines = preferences.maxTitleLines
|
||||
Note.maxLines = preferences.maxLines
|
||||
|
||||
root.setOnClickListener { listener.onClick(adapterPosition) }
|
||||
root.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
|
||||
|
||||
root.setOnLongClickListener {
|
||||
listener.onLongClick(adapterPosition)
|
||||
listener.onLongClick(absoluteAdapterPosition)
|
||||
return@setOnLongClickListener true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCheck(checked: Boolean) {
|
||||
fun updateCheck(checked: Boolean, color: String) {
|
||||
if (checked) {
|
||||
binding.root.strokeWidth = 3.dp
|
||||
} else {
|
||||
binding.root.strokeWidth = if (color == BaseNote.COLOR_DEFAULT) 1.dp else 0
|
||||
}
|
||||
binding.root.isChecked = checked
|
||||
}
|
||||
|
||||
fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean, sortBy: String) {
|
||||
updateCheck(checked)
|
||||
fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean, sortBy: NotesSortBy) {
|
||||
updateCheck(checked, baseNote.color)
|
||||
|
||||
when (baseNote.type) {
|
||||
Type.NOTE -> bindNote(baseNote.body, baseNote.spans)
|
||||
Type.LIST -> bindList(baseNote.items)
|
||||
Type.NOTE -> bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
|
||||
Type.LIST -> bindList(baseNote.items, baseNote.title.isEmpty())
|
||||
}
|
||||
val (date, datePrefixResId) =
|
||||
when (sortBy) {
|
||||
autoSortByCreationDate -> Pair(baseNote.timestamp, R.string.creation_date)
|
||||
autoSortByModifiedDate -> Pair(baseNote.modifiedTimestamp, R.string.modified_date)
|
||||
NotesSortBy.CREATION_DATE -> Pair(baseNote.timestamp, R.string.creation_date)
|
||||
NotesSortBy.MODIFIED_DATE ->
|
||||
Pair(baseNote.modifiedTimestamp, R.string.modified_date)
|
||||
else -> Pair(null, null)
|
||||
}
|
||||
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
|
||||
|
||||
setColor(baseNote.color)
|
||||
setImages(baseNote.images, imageRoot)
|
||||
setFiles(baseNote.files)
|
||||
|
||||
binding.Title.apply {
|
||||
text = baseNote.title
|
||||
isVisible = baseNote.title.isNotEmpty()
|
||||
updatePadding(
|
||||
bottom =
|
||||
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
|
||||
)
|
||||
setCompoundDrawablesWithIntrinsicBounds(
|
||||
if (baseNote.type == Type.LIST && preferences.maxItems < 1)
|
||||
R.drawable.checkbox_small
|
||||
else 0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
Operations.bindLabels(binding.LabelGroup, baseNote.labels, textSize)
|
||||
if (preferences.hideLabels) {
|
||||
binding.LabelGroup.visibility = GONE
|
||||
} else {
|
||||
binding.LabelGroup.bindLabels(
|
||||
baseNote.labels,
|
||||
preferences.textSize,
|
||||
binding.Note.isVisible || binding.Title.isVisible,
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty(baseNote)) {
|
||||
if (baseNote.isEmpty()) {
|
||||
binding.Title.apply {
|
||||
setText(getEmptyMessage(baseNote))
|
||||
visibility = View.VISIBLE
|
||||
setText(baseNote.getEmptyMessage())
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
setColor(baseNote.color)
|
||||
|
||||
binding.RemindersView.isVisible = baseNote.reminders.any { it.hasUpcomingNotification() }
|
||||
}
|
||||
|
||||
private fun bindNote(body: String, spans: List<SpanRepresentation>) {
|
||||
binding.LinearLayout.visibility = View.GONE
|
||||
private fun bindNote(body: String, spans: List<SpanRepresentation>, isTitleEmpty: Boolean) {
|
||||
binding.LinearLayout.visibility = GONE
|
||||
|
||||
binding.Note.apply {
|
||||
text = body.applySpans(spans)
|
||||
if (preferences.maxLines < 1) {
|
||||
isVisible = isTitleEmpty
|
||||
maxLines = if (isTitleEmpty) 1 else preferences.maxLines
|
||||
} else {
|
||||
isVisible = body.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindList(items: List<ListItem>) {
|
||||
private fun bindList(items: List<ListItem>, isTitleEmpty: Boolean) {
|
||||
binding.apply {
|
||||
Note.visibility = View.GONE
|
||||
Note.visibility = GONE
|
||||
if (items.isEmpty()) {
|
||||
LinearLayout.visibility = View.GONE
|
||||
LinearLayout.visibility = GONE
|
||||
} else {
|
||||
LinearLayout.visibility = View.VISIBLE
|
||||
val filteredList = items.take(maxItems)
|
||||
LinearLayout.visibility = VISIBLE
|
||||
val forceShowFirstItem = preferences.maxItems < 1 && isTitleEmpty
|
||||
val filteredList = items.take(if (forceShowFirstItem) 1 else preferences.maxItems)
|
||||
LinearLayout.children.forEachIndexed { index, view ->
|
||||
if (view.id != R.id.ItemsRemaining) {
|
||||
if (index < filteredList.size) {
|
||||
|
@ -133,47 +177,43 @@ class BaseNoteVH(
|
|||
(view as TextView).apply {
|
||||
text = item.body
|
||||
handleChecked(this, item.checked)
|
||||
visibility = View.VISIBLE
|
||||
visibility = VISIBLE
|
||||
if (item.isChild) {
|
||||
val layoutParams = layoutParams as LinearLayout.LayoutParams
|
||||
layoutParams.marginStart = 20.dp(context)
|
||||
setLayoutParams(layoutParams)
|
||||
updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
marginStart = 20.dp
|
||||
}
|
||||
}
|
||||
} else view.visibility = View.GONE
|
||||
if (index == filteredList.lastIndex) {
|
||||
updatePadding(bottom = 0)
|
||||
}
|
||||
}
|
||||
} else view.visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
if (items.size > maxItems) {
|
||||
if (preferences.maxItems > 0 && items.size > preferences.maxItems) {
|
||||
ItemsRemaining.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = (items.size - maxItems).toString()
|
||||
visibility = VISIBLE
|
||||
text = (items.size - preferences.maxItems).toString()
|
||||
}
|
||||
} else ItemsRemaining.visibility = View.GONE
|
||||
} else ItemsRemaining.visibility = GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setColor(color: Color) {
|
||||
private fun setColor(color: String) {
|
||||
binding.root.apply {
|
||||
if (color == Color.DEFAULT) {
|
||||
val stroke = ContextCompat.getColorStateList(context, R.color.chip_stroke)
|
||||
setStrokeColor(stroke)
|
||||
setCardBackgroundColor(0)
|
||||
} else {
|
||||
strokeColor = 0
|
||||
val colorInt = Operations.extractColor(color, context)
|
||||
val colorInt = context.extractColor(color)
|
||||
setCardBackgroundColor(colorInt)
|
||||
}
|
||||
setControlsContrastColorForAllViews(colorInt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setImages(images: List<FileAttachment>, mediaRoot: File?) {
|
||||
|
||||
binding.apply {
|
||||
if (images.isNotEmpty()) {
|
||||
ImageView.visibility = View.VISIBLE
|
||||
Message.visibility = View.GONE
|
||||
if (images.isNotEmpty() && !preferences.hideImages) {
|
||||
ImageView.visibility = VISIBLE
|
||||
Message.visibility = GONE
|
||||
|
||||
val image = images[0]
|
||||
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
|
||||
|
@ -192,7 +232,7 @@ class BaseNoteVH(
|
|||
target: Target<Drawable>?,
|
||||
isFirstResource: Boolean,
|
||||
): Boolean {
|
||||
Message.visibility = View.VISIBLE
|
||||
Message.visibility = VISIBLE
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -211,15 +251,15 @@ class BaseNoteVH(
|
|||
if (images.size > 1) {
|
||||
ImageViewMore.apply {
|
||||
text = images.size.toString()
|
||||
visibility = View.VISIBLE
|
||||
visibility = VISIBLE
|
||||
}
|
||||
} else {
|
||||
ImageViewMore.visibility = View.GONE
|
||||
ImageViewMore.visibility = GONE
|
||||
}
|
||||
} else {
|
||||
ImageView.visibility = View.GONE
|
||||
Message.visibility = View.GONE
|
||||
ImageViewMore.visibility = View.GONE
|
||||
ImageView.visibility = GONE
|
||||
Message.visibility = GONE
|
||||
ImageViewMore.visibility = GONE
|
||||
Glide.with(ImageView).clear(ImageView)
|
||||
}
|
||||
}
|
||||
|
@ -228,37 +268,37 @@ class BaseNoteVH(
|
|||
private fun setFiles(files: List<FileAttachment>) {
|
||||
binding.apply {
|
||||
if (files.isNotEmpty()) {
|
||||
FileViewLayout.visibility = View.VISIBLE
|
||||
FileViewLayout.visibility = VISIBLE
|
||||
FileView.text = files[0].originalName
|
||||
if (files.size > 1) {
|
||||
FileViewMore.apply {
|
||||
text = getQuantityString(R.plurals.more_files, files.size - 1)
|
||||
visibility = View.VISIBLE
|
||||
visibility = VISIBLE
|
||||
}
|
||||
} else {
|
||||
FileViewMore.visibility = View.GONE
|
||||
FileViewMore.visibility = GONE
|
||||
}
|
||||
} else {
|
||||
FileViewLayout.visibility = View.GONE
|
||||
FileViewLayout.visibility = GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEmpty(baseNote: BaseNote): Boolean {
|
||||
return with(baseNote) {
|
||||
private fun shouldOnlyDisplayTitle(baseNote: BaseNote) =
|
||||
when (baseNote.type) {
|
||||
Type.NOTE -> preferences.maxLines < 1
|
||||
Type.LIST -> preferences.maxItems < 1
|
||||
}
|
||||
|
||||
private fun BaseNote.isEmpty() = title.isBlank() && hasNoContents() && images.isEmpty()
|
||||
|
||||
private fun BaseNote.hasNoContents() = body.isEmpty() && items.isEmpty()
|
||||
|
||||
private fun BaseNote.getEmptyMessage() =
|
||||
when (type) {
|
||||
Type.NOTE -> title.isBlank() && body.isBlank() && images.isEmpty()
|
||||
Type.LIST -> title.isBlank() && items.isEmpty() && images.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmptyMessage(baseNote: BaseNote): Int {
|
||||
return when (baseNote.type) {
|
||||
Type.NOTE -> R.string.empty_note
|
||||
Type.LIST -> R.string.empty_list
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChecked(textView: TextView, checked: Boolean) {
|
||||
if (checked) {
|
||||
|
|
|
@ -3,19 +3,20 @@ package com.philkes.notallyx.presentation.view.main
|
|||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.databinding.RecyclerColorBinding
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
|
||||
class ColorAdapter(private val listener: ListItemListener) : RecyclerView.Adapter<ColorVH>() {
|
||||
|
||||
private val colors = Color.values()
|
||||
class ColorAdapter(
|
||||
private val colors: List<String>,
|
||||
private val selectedColor: String?,
|
||||
private val listener: ItemListener,
|
||||
) : RecyclerView.Adapter<ColorVH>() {
|
||||
|
||||
override fun getItemCount() = colors.size
|
||||
|
||||
override fun onBindViewHolder(holder: ColorVH, position: Int) {
|
||||
val color = colors[position]
|
||||
holder.bind(color)
|
||||
holder.bind(color, color == selectedColor)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorVH {
|
||||
|
|
|
@ -1,20 +1,56 @@
|
|||
package com.philkes.notallyx.presentation.view.main
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.Color
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.databinding.RecyclerColorBinding
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
import com.philkes.notallyx.utils.Operations
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.presentation.extractColor
|
||||
import com.philkes.notallyx.presentation.getColorFromAttr
|
||||
import com.philkes.notallyx.presentation.getContrastFontColor
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
|
||||
class ColorVH(private val binding: RecyclerColorBinding, listener: ListItemListener) :
|
||||
class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.CardView.setOnClickListener { listener.onClick(adapterPosition) }
|
||||
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
|
||||
binding.CardView.setOnLongClickListener {
|
||||
listener.onLongClick(absoluteAdapterPosition)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(color: Color) {
|
||||
val value = Operations.extractColor(color, binding.root.context)
|
||||
binding.CardView.setCardBackgroundColor(value)
|
||||
fun bind(color: String, isSelected: Boolean) {
|
||||
val showAddIcon = color == BaseNote.COLOR_NEW
|
||||
val context = binding.root.context
|
||||
val value =
|
||||
if (showAddIcon) context.getColorFromAttr(R.attr.colorOnSurface)
|
||||
else context.extractColor(color)
|
||||
val controlsColor = context.getContrastFontColor(value)
|
||||
binding.apply {
|
||||
CardView.apply {
|
||||
setCardBackgroundColor(value)
|
||||
contentDescription = color
|
||||
if (isSelected) {
|
||||
strokeWidth = 4.dp
|
||||
strokeColor = controlsColor
|
||||
} else {
|
||||
strokeWidth = 1.dp
|
||||
strokeColor = controlsColor
|
||||
}
|
||||
}
|
||||
CardIcon.apply {
|
||||
if (showAddIcon) {
|
||||
setImageResource(R.drawable.add)
|
||||
} else if (isSelected) {
|
||||
setImageResource(R.drawable.checked_circle)
|
||||
}
|
||||
imageTintList = ColorStateList.valueOf(controlsColor)
|
||||
isVisible = showAddIcon || isSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.main
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.philkes.notallyx.databinding.RecyclerLabelBinding
|
||||
import com.philkes.notallyx.presentation.view.misc.StringDiffCallback
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
|
||||
class LabelAdapter(private val listener: ListItemListener) :
|
||||
ListAdapter<String, LabelVH>(StringDiffCallback()) {
|
||||
|
||||
override fun onBindViewHolder(holder: LabelVH, position: Int) {
|
||||
val label = getItem(position)
|
||||
holder.bind(label)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
|
||||
return LabelVH(binding, listener)
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.main
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.databinding.RecyclerLabelBinding
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
|
||||
|
||||
class LabelVH(private val binding: RecyclerLabelBinding, listener: ListItemListener) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.root.apply {
|
||||
setOnClickListener { listener.onClick(adapterPosition) }
|
||||
setOnLongClickListener {
|
||||
listener.onLongClick(adapterPosition)
|
||||
return@setOnLongClickListener true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(value: String) {
|
||||
binding.root.text = value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.philkes.notallyx.presentation.view.main.label
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.philkes.notallyx.databinding.RecyclerLabelBinding
|
||||
|
||||
class LabelAdapter(private val listener: LabelListener) :
|
||||
ListAdapter<LabelData, LabelVH>(DiffCallback) {
|
||||
|
||||
override fun onBindViewHolder(holder: LabelVH, position: Int) {
|
||||
val label = getItem(position)
|
||||
holder.bind(label)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
|
||||
return LabelVH(binding, listener)
|
||||
}
|
||||
}
|
||||
|
||||
data class LabelData(val label: String, var visibleInNavigation: Boolean)
|
||||
|
||||
private object DiffCallback : DiffUtil.ItemCallback<LabelData>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.philkes.notallyx.presentation.view.main.label
|
||||
|
||||
interface LabelListener {
|
||||
|
||||
fun onClick(position: Int)
|
||||
|
||||
fun onEdit(position: Int)
|
||||
|
||||
fun onDelete(position: Int)
|
||||
|
||||
fun onToggleVisibility(position: Int)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package com.philkes.notallyx.presentation.view.main.label
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.databinding.RecyclerLabelBinding
|
||||
|
||||
class LabelVH(private val binding: RecyclerLabelBinding, listener: LabelListener) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.apply {
|
||||
LabelText.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
|
||||
EditButton.setOnClickListener { listener.onEdit(absoluteAdapterPosition) }
|
||||
DeleteButton.setOnClickListener { listener.onDelete(absoluteAdapterPosition) }
|
||||
VisibilityButton.setOnClickListener {
|
||||
listener.onToggleVisibility(absoluteAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(value: LabelData) {
|
||||
binding.LabelText.text = value.label
|
||||
binding.VisibilityButton.setImageResource(
|
||||
if (value.visibleInNavigation) R.drawable.visibility else R.drawable.visibility_off
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.philkes.notallyx.presentation.view.main
|
||||
package com.philkes.notallyx.presentation.view.main.label
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
|
@ -1,4 +1,4 @@
|
|||
package com.philkes.notallyx.presentation.view.main
|
||||
package com.philkes.notallyx.presentation.view.main.label
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.databinding.RecyclerSelectableLabelBinding
|
||||
|
@ -10,7 +10,7 @@ class SelectableLabelVH(
|
|||
|
||||
init {
|
||||
binding.root.setOnCheckedChangeListener { _, isChecked ->
|
||||
onChecked(adapterPosition, isChecked)
|
||||
onChecked(absoluteAdapterPosition, isChecked)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.philkes.notallyx.presentation.view.main.reminder
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.philkes.notallyx.data.dao.NoteReminder
|
||||
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
|
||||
|
||||
class NoteReminderAdapter(private val listener: NoteReminderListener) :
|
||||
ListAdapter<NoteReminder, NoteReminderVH>(NoteReminderDiffCallback()) {
|
||||
|
||||
override fun onBindViewHolder(holder: NoteReminderVH, position: Int) {
|
||||
val reminder = getItem(position)
|
||||
holder.bind(reminder)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteReminderVH {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = RecyclerNoteReminderBinding.inflate(inflater, parent, false)
|
||||
return NoteReminderVH(binding, listener)
|
||||
}
|
||||
}
|
||||
|
||||
interface NoteReminderListener {
|
||||
fun openReminder(reminder: NoteReminder)
|
||||
|
||||
fun openNote(reminder: NoteReminder)
|
||||
}
|
||||
|
||||
class NoteReminderDiffCallback : DiffUtil.ItemCallback<NoteReminder>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package com.philkes.notallyx.presentation.view.main.reminder
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.dao.NoteReminder
|
||||
import com.philkes.notallyx.data.model.findNextNotificationDate
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
|
||||
|
||||
class NoteReminderVH(
|
||||
private val binding: RecyclerNoteReminderBinding,
|
||||
private val listener: NoteReminderListener,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(value: NoteReminder) {
|
||||
binding.apply {
|
||||
Layout.setOnClickListener { listener.openReminder(value) }
|
||||
val context = itemView.context
|
||||
NoteTitle.text = value.title.ifEmpty { context.getText(R.string.empty_note) }
|
||||
val nextNotificationDate = value.reminders.findNextNotificationDate()
|
||||
NextNotification.text =
|
||||
nextNotificationDate?.let {
|
||||
"${context.getText(R.string.next)}: ${nextNotificationDate.toText()}"
|
||||
} ?: context.getString(R.string.elapsed)
|
||||
Reminders.text = value.reminders.size.toString()
|
||||
OpenNote.setOnClickListener { listener.openNote(value) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package com.philkes.notallyx.presentation.view.main.reminder
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.philkes.notallyx.data.model.Reminder
|
||||
import com.philkes.notallyx.databinding.RecyclerReminderBinding
|
||||
|
||||
class ReminderAdapter(private val listener: ReminderListener) :
|
||||
ListAdapter<Reminder, ReminderVH>(ReminderDiffCallback()) {
|
||||
|
||||
override fun onBindViewHolder(holder: ReminderVH, position: Int) {
|
||||
val reminder = getItem(position)
|
||||
holder.bind(reminder)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReminderVH {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = RecyclerReminderBinding.inflate(inflater, parent, false)
|
||||
return ReminderVH(binding, listener)
|
||||
}
|
||||
}
|
||||
|
||||
interface ReminderListener {
|
||||
fun delete(reminder: Reminder)
|
||||
|
||||
fun edit(reminder: Reminder)
|
||||
}
|
||||
|
||||
class ReminderDiffCallback : DiffUtil.ItemCallback<Reminder>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.philkes.notallyx.presentation.view.main.reminder
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.Reminder
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.databinding.RecyclerReminderBinding
|
||||
|
||||
class ReminderVH(
|
||||
private val binding: RecyclerReminderBinding,
|
||||
private val listener: ReminderListener,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(value: Reminder) {
|
||||
binding.apply {
|
||||
DateTime.text = value.dateTime.toText()
|
||||
Repetition.text =
|
||||
value.repetition?.toText(itemView.context)
|
||||
?: itemView.context.getText(R.string.reminder_no_repetition)
|
||||
EditButton.setOnClickListener { listener.edit(value) }
|
||||
DeleteButton.setOnClickListener { listener.delete(value) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.philkes.notallyx.presentation.view.main.sorting
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
class BaseNoteColorSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
|
||||
ItemSort(adapter, sortDirection) {
|
||||
|
||||
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int {
|
||||
val sort =
|
||||
note1.compareColor(note2).takeIf { it != 0 } ?: return -note1.compareModified(note2)
|
||||
return if (sortDirection == SortDirection.ASC) sort else -1 * sort
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.compareColor(other: BaseNote) = color.compareTo(other.color)
|
|
@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
|
||||
BaseNoteSort(adapter, sortDirection) {
|
||||
ItemSort(adapter, sortDirection) {
|
||||
|
||||
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int {
|
||||
val sort = note1.timestamp.compareTo(note2.timestamp)
|
||||
val sort = note1.compareCreated(note2)
|
||||
return if (sortDirection == SortDirection.ASC) sort else -1 * sort
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.compareCreated(other: BaseNote) = timestamp.compareTo(other.timestamp)
|
||||
|
|
|
@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
|
||||
BaseNoteSort(adapter, sortDirection) {
|
||||
ItemSort(adapter, sortDirection) {
|
||||
|
||||
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int {
|
||||
val sort = note1.modifiedTimestamp.compareTo(note2.modifiedTimestamp)
|
||||
val sort = note1.compareModified(note2)
|
||||
return if (sortDirection == SortDirection.ASC) sort else -1 * sort
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.compareModified(other: BaseNote) = modifiedTimestamp.compareTo(other.modifiedTimestamp)
|
||||
|
|
|
@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
|
||||
BaseNoteSort(adapter, sortDirection) {
|
||||
ItemSort(adapter, sortDirection) {
|
||||
|
||||
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int {
|
||||
val sort = note1.title.compareTo(note2.title)
|
||||
val sort = note1.compareTitle(note2)
|
||||
return if (sortDirection == SortDirection.ASC) sort else -1 * sort
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseNote.compareTitle(other: BaseNote) = title.compareTo(other.title)
|
||||
|
|
|
@ -5,9 +5,9 @@ import androidx.recyclerview.widget.SortedListAdapterCallback
|
|||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Header
|
||||
import com.philkes.notallyx.data.model.Item
|
||||
import com.philkes.notallyx.presentation.view.misc.SortDirection
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
abstract class BaseNoteSort(
|
||||
abstract class ItemSort(
|
||||
adapter: RecyclerView.Adapter<*>?,
|
||||
private val sortDirection: SortDirection,
|
||||
) : SortedListAdapterCallback<Item>(adapter) {
|
|
@ -3,10 +3,9 @@ package com.philkes.notallyx.presentation.view.misc
|
|||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
|
||||
class EditTextAutoClearFocus(context: Context, attributeSet: AttributeSet) :
|
||||
AppCompatEditText(context, attributeSet) {
|
||||
HighlightableEditText(context, attributeSet) {
|
||||
|
||||
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
|
||||
|
|
|
@ -1,193 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.misc
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Editable
|
||||
import android.text.Spanned
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.URLSpan
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import com.philkes.notallyx.data.model.isNoteUrl
|
||||
import com.philkes.notallyx.data.model.isWebUrl
|
||||
import com.philkes.notallyx.presentation.clone
|
||||
import com.philkes.notallyx.presentation.createTextWatcherWithHistory
|
||||
import com.philkes.notallyx.presentation.removeSelectionFromSpan
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeHistory
|
||||
import com.philkes.notallyx.utils.changehistory.EditTextWithHistoryChange
|
||||
|
||||
/**
|
||||
* [AppCompatEditText] whose changes (text edits or span changes) are pushed to [changeHistory].
|
||||
* *
|
||||
*/
|
||||
class EditTextWithHistory(context: Context, attrs: AttributeSet) :
|
||||
AppCompatEditText(context, attrs) {
|
||||
|
||||
var isActionModeOn = false
|
||||
private var changeHistory: ChangeHistory? = null
|
||||
private var updateModel: ((text: Editable) -> Unit)? = null
|
||||
private var textWatcher: TextWatcher? = null
|
||||
|
||||
/**
|
||||
* If this is called every future text or span change is pushed to [changeHistory].
|
||||
*
|
||||
* @param updateModel Function that is called when undo/redo of [changeHistory] is triggered *
|
||||
*/
|
||||
fun initHistory(changeHistory: ChangeHistory, updateModel: (text: Editable) -> Unit) {
|
||||
this.textWatcher?.let { removeTextChangedListener(it) }
|
||||
this.changeHistory = changeHistory
|
||||
this.updateModel = updateModel
|
||||
this.textWatcher =
|
||||
createTextWatcherWithHistory(
|
||||
changeHistory,
|
||||
{ text, start, count ->
|
||||
val changedText = text.substring(start, start + count)
|
||||
if (changedText.isWebUrl() || changedText.isNoteUrl()) {
|
||||
this.text?.setSpan(
|
||||
URLSpan(changedText),
|
||||
start,
|
||||
start + count,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { text: Editable ->
|
||||
updateModel(text.clone())
|
||||
}
|
||||
this.textWatcher?.let { addTextChangedListener(it) }
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
||||
if (!isActionModeOn) {
|
||||
super.onWindowFocusChanged(hasWindowFocus)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"You should not access text Editable directly, use other member functions to edit/read text properties.",
|
||||
replaceWith = ReplaceWith("changeText/applyWithoutTextWatcher/..."),
|
||||
)
|
||||
override fun getText(): Editable? {
|
||||
return super.getText()
|
||||
}
|
||||
|
||||
fun getTextClone(): Editable {
|
||||
return super.getText()!!.clone()
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
applyWithoutTextWatcher { super.setText(text, type) }
|
||||
}
|
||||
|
||||
fun applyWithoutTextWatcher(
|
||||
callback: EditTextWithHistory.() -> Unit
|
||||
): Pair<Editable, Editable> {
|
||||
val textBefore = super.getText()!!.clone()
|
||||
val editTextWatcher = textWatcher
|
||||
editTextWatcher?.let { removeTextChangedListener(it) }
|
||||
callback()
|
||||
editTextWatcher?.let { addTextChangedListener(it) }
|
||||
return Pair(textBefore, super.getText()!!.clone())
|
||||
}
|
||||
|
||||
fun getSpanRange(span: CharacterStyle): Pair<Int, Int> {
|
||||
val text = super.getText()!!
|
||||
return Pair(text.getSpanStart(span), text.getSpanEnd(span))
|
||||
}
|
||||
|
||||
fun getSpanText(span: CharacterStyle): String {
|
||||
val (spanStart, spanEnd) = getSpanRange(span)
|
||||
return super.getText()!!.substring(spanStart, spanEnd)
|
||||
}
|
||||
|
||||
fun getSelectionText(): String? {
|
||||
if (selectionStart == -1 || selectionEnd == -1) {
|
||||
return null
|
||||
}
|
||||
return super.getText()!!.substring(selectionStart, selectionEnd)
|
||||
}
|
||||
|
||||
fun clearFormatting(start: Int = selectionStart, end: Int = selectionEnd) {
|
||||
changeTextWithHistory { text -> text.removeSelectionFromSpan(start, end) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes [span] from `text`.
|
||||
*
|
||||
* @param removeText if this is `true` the text of the [span] is removed from `text`.
|
||||
*/
|
||||
fun removeSpan(span: CharacterStyle, removeText: Boolean = false) {
|
||||
val (start, end) = getSpanRange(span)
|
||||
changeTextWithHistory { text ->
|
||||
text.removeSelectionFromSpan(start, end)
|
||||
if (removeText) {
|
||||
text.delete(start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addSpan(spanText: String, span: CharacterStyle, position: Int = selectionStart) {
|
||||
changeTextWithHistory { text ->
|
||||
text.insert(position, spanText)
|
||||
text.setSpan(
|
||||
span,
|
||||
position,
|
||||
position + spanText.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [oldSpan] with [newSpan].
|
||||
*
|
||||
* @param spanText if this is not `null`, the spans text is also updated,
|
||||
*/
|
||||
fun updateSpan(oldSpan: CharacterStyle, newSpan: CharacterStyle, spanText: String?) {
|
||||
val (oldSpanStart, oldSpanEnd) = getSpanRange(oldSpan)
|
||||
changeTextWithHistory { text ->
|
||||
text.removeSpan(oldSpan)
|
||||
if (spanText != null) {
|
||||
text.replace(oldSpanStart, oldSpanEnd, spanText)
|
||||
text.setSpan(
|
||||
newSpan,
|
||||
oldSpanStart,
|
||||
oldSpanStart + spanText.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
} else {
|
||||
text.setSpan(newSpan, oldSpanStart, oldSpanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applySpan(span: CharacterStyle, start: Int = selectionStart, end: Int = selectionEnd) {
|
||||
changeTextWithHistory { text ->
|
||||
text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to change `text` with according [EditTextWithHistoryChange] pushed automatically
|
||||
* to [changeHistory]. This method is used by all other members functions.
|
||||
*/
|
||||
fun changeTextWithHistory(callback: (text: Editable) -> Unit) {
|
||||
val (textBefore, textAfter) = changeText(callback)
|
||||
updateModel?.invoke(textAfter.clone())
|
||||
changeHistory?.push(
|
||||
EditTextWithHistoryChange(this, textBefore, textAfter) { text ->
|
||||
updateModel?.invoke(text.clone())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to change `text` without triggering the [TextWatcher], which would push a
|
||||
* [EditTextWithHistoryChange] to [changeHistory].
|
||||
*
|
||||
* @return Clones of `text` before the changes and `text` after. *
|
||||
*/
|
||||
fun changeText(callback: (text: Editable) -> Unit): Pair<Editable, Editable> {
|
||||
return applyWithoutTextWatcher { callback(super.getText()!!) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package com.philkes.notallyx.presentation.view.misc
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.KeyListener
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import com.philkes.notallyx.presentation.clone
|
||||
import com.philkes.notallyx.presentation.showKeyboard
|
||||
|
||||
open class EditTextWithWatcher(context: Context, attrs: AttributeSet) :
|
||||
AppCompatEditText(context, attrs) {
|
||||
var textWatcher: TextWatcher? = null
|
||||
private var onSelectionChange: ((selStart: Int, selEnd: Int) -> Unit)? = null
|
||||
private var keyListenerInstance: KeyListener? = null
|
||||
|
||||
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||
super.onSelectionChanged(selStart, selEnd)
|
||||
onSelectionChange?.invoke(selStart, selEnd)
|
||||
}
|
||||
|
||||
fun setOnSelectionChange(callback: (selStart: Int, selEnd: Int) -> Unit) {
|
||||
this.onSelectionChange = callback
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
applyWithoutTextWatcher { super.setText(text, type) }
|
||||
}
|
||||
|
||||
fun setText(text: Editable) {
|
||||
super.setText(text, BufferType.EDITABLE)
|
||||
}
|
||||
|
||||
fun setCanEdit(value: Boolean) {
|
||||
if (!value) {
|
||||
clearFocus()
|
||||
}
|
||||
keyListener?.let { keyListenerInstance = it }
|
||||
keyListener = if (value) keyListenerInstance else null // Disables text editing
|
||||
isCursorVisible = true
|
||||
isFocusable = value
|
||||
isFocusableInTouchMode = value
|
||||
setTextIsSelectable(true)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
|
||||
setOnClickListener {
|
||||
if (value) {
|
||||
context.showKeyboard(this)
|
||||
}
|
||||
}
|
||||
setOnFocusChangeListener { v, hasFocus ->
|
||||
if (hasFocus && value) {
|
||||
context.showKeyboard(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"You should not access text Editable directly, use other member functions to edit/read text properties.",
|
||||
replaceWith = ReplaceWith("changeText/applyWithoutTextWatcher/..."),
|
||||
)
|
||||
override fun getText(): Editable? {
|
||||
return getTextSafe()
|
||||
}
|
||||
|
||||
fun getTextClone(): Editable {
|
||||
return getTextSafe().clone()
|
||||
}
|
||||
|
||||
fun applyWithoutTextWatcher(
|
||||
callback: EditTextWithWatcher.() -> Unit
|
||||
): Pair<Editable, Editable> {
|
||||
val textBefore = getTextClone()
|
||||
val editTextWatcher = textWatcher
|
||||
editTextWatcher?.let { removeTextChangedListener(it) }
|
||||
callback()
|
||||
editTextWatcher?.let { addTextChangedListener(it) }
|
||||
return Pair(textBefore, getTextClone())
|
||||
}
|
||||
|
||||
fun changeText(callback: (text: Editable) -> Unit): Pair<Editable, Editable> {
|
||||
return applyWithoutTextWatcher { callback(getTextSafe()!!) }
|
||||
}
|
||||
|
||||
private fun getTextSafe() = super.getText() ?: Editable.Factory.getInstance().newEditable("")
|
||||
|
||||
fun focusAndSelect(
|
||||
start: Int = selectionStart,
|
||||
end: Int = selectionEnd,
|
||||
inputMethodManager: InputMethodManager? = null,
|
||||
) {
|
||||
requestFocus()
|
||||
if (start > -1) {
|
||||
setSelection(start, if (end < 0) start else end)
|
||||
}
|
||||
inputMethodManager?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package com.philkes.notallyx.presentation.view.misc
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import android.text.style.BackgroundColorSpan
|
||||
import android.text.style.CharacterStyle
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import com.philkes.notallyx.presentation.removeSelectionFromSpans
|
||||
import com.philkes.notallyx.presentation.withAlpha
|
||||
|
||||
/**
|
||||
* [AppCompatEditText] whose changes (text edits or span changes) are pushed to [changeHistory].
|
||||
* *
|
||||
*/
|
||||
open class HighlightableEditText(context: Context, attrs: AttributeSet) :
|
||||
EditTextWithWatcher(context, attrs) {
|
||||
|
||||
fun getSpanRange(span: CharacterStyle): Pair<Int, Int> {
|
||||
val text = super.getText()!!
|
||||
return Pair(text.getSpanStart(span), text.getSpanEnd(span))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes [span] from `text`.
|
||||
*
|
||||
* @param removeText if this is `true` the text of the [span] is removed from `text`.
|
||||
*/
|
||||
protected fun removeSpan(span: CharacterStyle, removeText: Boolean = false) {
|
||||
val (start, end) = getSpanRange(span)
|
||||
text?.removeSelectionFromSpans(start, end)
|
||||
if (removeText) {
|
||||
text?.delete(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun applySpan(
|
||||
span: CharacterStyle,
|
||||
start: Int = selectionStart,
|
||||
end: Int = selectionEnd,
|
||||
) {
|
||||
text?.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
|
||||
private val highlightedSpans: MutableList<CharacterStyle> = mutableListOf()
|
||||
private var selectedHighlightedSpan: CharacterStyle? = null
|
||||
|
||||
fun clearHighlights() {
|
||||
highlightedSpans.apply {
|
||||
forEach { span -> removeSpan(span) }
|
||||
clear()
|
||||
}
|
||||
selectedHighlightedSpan = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibly highlight text from [startIdx] to [endIdx]. If [selected] is true the text is
|
||||
* highlighted uniquely. There can only be one [selected] highlight.
|
||||
*
|
||||
* @return Vertical offset to highlighted text line.
|
||||
*/
|
||||
fun highlight(startIdx: Int, endIdx: Int, selected: Boolean): Int? {
|
||||
// TODO: Could be replaced with EditText.highlights? (API >= 34)
|
||||
if (selected) {
|
||||
selectedHighlightedSpan?.unselect()
|
||||
}
|
||||
highlightedSpans
|
||||
.filter { getSpanRange(it) == Pair(startIdx, endIdx) }
|
||||
.forEach {
|
||||
removeSpan(it)
|
||||
highlightedSpans.remove(it)
|
||||
}
|
||||
val span = HighlightSpan(if (selected) highlightColor else highlightColor.withAlpha(0.1f))
|
||||
applySpan(span, startIdx, endIdx)
|
||||
highlightedSpans.add(span)
|
||||
if (selected) {
|
||||
selectedHighlightedSpan = span
|
||||
}
|
||||
return layout?.let {
|
||||
val line = layout.getLineForOffset(startIdx)
|
||||
layout.getLineTop(line)
|
||||
}
|
||||
}
|
||||
|
||||
fun unselectHighlight() {
|
||||
selectedHighlightedSpan?.unselect()
|
||||
}
|
||||
|
||||
private fun CharacterStyle.unselect() {
|
||||
val (previousHighlightedStartIdx, previousHighlightedEndIdx) = getSpanRange(this)
|
||||
if (previousHighlightedStartIdx != -1) {
|
||||
removeSpan(this)
|
||||
highlight(previousHighlightedStartIdx, previousHighlightedEndIdx, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HighlightSpan(@ColorInt color: Int) : BackgroundColorSpan(color)
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue